diff --git a/.gitignore b/.gitignore index 935470d1..bf27a9d0 100644 --- a/.gitignore +++ b/.gitignore @@ -146,5 +146,12 @@ electron/ gently/ui/tui/node_modules/ gently/ui/tui/dist/ -# Runtime storage accidentally created on Linux when GENTLY_STORAGE_PATH="D:/" resolves literally -D:/ +# Stray local storage: GENTLY_STORAGE_PATH default (D:\Gently3) resolves +# literally to ./D:/ under the repo on Linux. Not data we track. +/D:/ +.superpowers/ + +# Runtime operator overrides (written by the Settings panel) +config/config.local.yml +config/settings.local.yml +config/dashboard_defaults.json diff --git a/README.md b/README.md index 7bf25135..7f7a5b16 100644 --- a/README.md +++ b/README.md @@ -175,6 +175,9 @@ uv run python launch_gently.py --no-api # Don't auto-open a browser — open the printed URL yourself uv run python launch_gently.py --no-browser +# Skip login — disable accounts (localhost-control mode; same as GENTLY_NO_AUTH=1) +uv run python launch_gently.py --no-auth + # Resume a previous session uv run python launch_gently.py --resume # interactive picker uv run python launch_gently.py --resume latest # most recent session @@ -211,8 +214,9 @@ First-run admin account created — sign in at the URL above: - **Lost it?** There's no reset command yet — delete `/auth/users.yaml` and restart to bootstrap a fresh `admin` (this clears all accounts). -- **Just trying it locally?** `GENTLY_NO_AUTH=1` disables accounts entirely - (legacy mode: localhost gets control, remote callers need `X-Gently-Token`). +- **Just trying it locally?** Launch with `--no-auth` (or set `GENTLY_NO_AUTH=1`) + to disable accounts entirely (localhost gets control, remote callers need + `X-Gently-Token`). Handy if you've lost the admin password. Accounts live under `/auth/` (`users.yaml` + `secret.key`), outside the repo. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 00000000..cd2621f9 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,171 @@ +# Gently — Concurrency & Runtime Architecture + +How the system runs temperature telemetry, device-state polling, experiments, +image acquisition, and perception/VLM "at the same time" without stepping on +itself. The short version: **almost nothing runs truly in parallel — the design +quarantines the blocking work and serializes the hardware, then keeps both event +loops responsive by offloading and decoupling everything slow.** + +> Scope: the concurrency model. For storage layout see `CLAUDE.md`; for the +> device/hardware plugin model see `docs/asi-plugin-architecture.md`. + +## 1. Two processes, bridged by HTTP + a shared filesystem + +This split is the load-bearing fact of the whole design. + +``` +┌────────────────────────────────────────┐ ┌────────────────────────────────────────┐ +│ APP / VIZ process (FastAPI :8080) │ │ DEVICE-LAYER process (aiohttp :60610) │ +│ gently/app/agent.py │ │ gently/hardware/dispim/device_layer.py │ +│ │ HTTP │ │ +│ • agent + TimelapseOrchestrator │◄──────►│ • the ONLY code touching hardware: │ +│ • Perceiver (gently_perception, VLM) │ (DiSPIM│ MMCore/pymmcore, Ophyd, Bluesky RE │ +│ • AsyncAnthropic clients │ Client)│ • plan queue + single executor │ +│ • EventBus + WebSocket ConnectionMgr │ │ • 3 state pollers + camera/LS streamers │ +│ • TemperatureSampler, DeviceStateMonitor│ │ │ +└──────────────┬──────────────────────────┘ └───────────────┬──────────────────────────┘ + │ shared filesystem (incoming/ TIFF staging, session store) + └──────────────────────────────────────────────────┘ +``` + +- The **device-layer process** (`device_layer.py`) is the *only* code that ever + touches the microscope: MMCore/pymmcore, the Ophyd devices, and a Bluesky + `RunEngine`. All truly blocking hardware work is quarantined here. +- The **app/viz process** (`app/agent.py`, FastAPI+uvicorn) owns everything + cognitive and user-facing: the agent, the `TimelapseOrchestrator`, the + `Perceiver` (external `gently_perception` VLM package), the Anthropic clients, + the in-process `EventBus`, the WebSocket `ConnectionManager`, and the pollers. +- The app reaches hardware **only** through `DiSPIMClient` (`client.py`), over a + single shared `aiohttp.ClientSession`. It never imports MMCore. + +Because blocking hardware calls live in a separate process, the app-side loop +stays a light cooperative-asyncio world. + +## 2. One asyncio event loop per process + +- **Device loop:** HTTP routes + the RunEngine driver + the plan queue/executor + + three state pollers (XY ~5 Hz, piezo/galvo ~1 Hz, full property cache ~15 s) + + subscriber-gated camera/lightsheet SSE streamers. +- **App loop:** FastAPI + the `TimelapseOrchestrator` acquisition loop + + `TemperatureSampler` (1 Hz) + `DeviceStateMonitor` + `Perceiver` calls + one + coroutine per open browser WebSocket. + +## 3. The core trick: hardware is serialized, not parallelized + +Every Bluesky plan — a move, a snap, a volume/burst acquisition, a focus/calibration +sweep — is submitted as a `PlanRequest` onto **one `asyncio.Queue` +(`self._plan_queue`), drained by one `_plan_executor` task**. Only one plan owns +the hardware at a time. + +- `submit_plan` enqueues and `await`s a per-request `asyncio.Future`; the executor + sets the result/exception, which flows back to the awaiting HTTP handler. On + failure the executor records it in `_plan_execution_log` and continues to the + next queued plan. +- Underneath, **pymmcore's internal `g_core_lock` is the real mutex** serializing + every actual core call across pollers *and* plans. The `DiSPIMSystem` facade is + the single place the process touches MMCore. + +A microscope has one stage and one camera; single-file execution is correct, not +a limitation. + +## 4. How constant polling coexists with long experiments + +Four cooperating mechanisms keep the loops responsive while a plan runs: + +1. **Offload blocking reads.** Every MMCore read, camera grab, SAM call, and + transient temperature probe goes through `asyncio.to_thread`; the camera + sequence runs on its own `threading.Thread` returning an Ophyd `Status`. The + loop itself never sits on I/O. +2. **Split pollers by cadence.** The three device-state pollers are independent + tasks so a slow (~1.5 s) full-state-cache read cannot stall the ~5 Hz XY path. +3. **`pause_state_updates()` — a reference-counted async context manager.** Heavy + plans (a `frozenset` of names) wrap execution in it, incrementing a counter; + every poller/streamer checks `if self._state_pause_counter > 0` and *skips its + MMCore read*, emitting only ~2 s heartbeats. The plan gets full serial/camera + bandwidth instead of the pollers fighting it for `g_core_lock`. Nested heavy + sections stack and unwind cleanly. +4. **Telemetry bypasses the plan queue.** `GET /api/temperature/status` reads the + temperature Ophyd device directly (`temp.read()` over serial/MQTT — a device + wholly separate from MMCore); `GET /api/devices/state` returns the cached + `_state_latest` snapshot. Neither sits behind a running experiment, so status + polls are never blocked by a long acquisition. (MMCore push callbacks also + mirror joystick/property changes into `_state_latest` via + `loop.call_soon_threadsafe` with ~50 ms debouncing.) + +### Temperature specifically +The vendor SDK backend (serial or MQTT) runs its own **background daemon thread** +that ingests the controller's 500 ms telemetry broadcast into a cache +(`self.telemetry`); `get_water_temp()`/`get_system_state()` return the cached +value (non-blocking). `TemperatureController.read()` returns that cache, and +`TemperatureSampler` (`interval_sec=1.0`) polls it at 1 Hz → persists + emits +`TEMPERATURE_UPDATE`. So we ride the telemetry indirectly, resampled at 1 Hz. + +## 5. Image data travels via the filesystem, not JSON + +Arrays over ~1 MB are written as **TIFF into the shared `incoming/` staging dir**; +only a small `{__file_ref__, path, shape, dtype}` dict crosses HTTP (`serialize_value`). +The client resolves the ref with `tifffile` and hands the decoded array to +`register_volume`, which renames the file into the session store and stamps it +with the latest temperature sample — avoiding a multi-GB JSON blob and a double +decode. + +## 6. Perception / VLM / events are decoupled (fire-and-forget) + +- **Perception never gates acquisition.** The `TimelapseOrchestrator` does + `asyncio.create_task(self._run_perception(...))` rather than awaiting it inline, + so a slow VLM call doesn't hold up the next embryo. Inside the task the + `Perceiver` and the Claude client are awaited cooperatively, and every Claude + call is wrapped in `asyncio.wait_for(timeout=30)` returning a safe fallback + instead of raising into the loop. +- **The `EventBus` publishes without awaiting handlers** (`event_bus.py`): sync + handlers run inline, async handlers are scheduled via `asyncio.ensure_future` / + `loop.call_soon_threadsafe`; high-volume telemetry types skip the bounded + history deque, so one slow WebSocket client delays only its own broadcast. + +## 7. Backpressure & failure isolation + +- **SSE streams** use per-subscriber queues bounded at `maxsize=4`; device-state + broadcasts drop slow subscribers, camera/lightsheet streams drop the *oldest* + frame and push the newest so steady clients keep fresh frames. +- **WebSocket fan-out** (`ConnectionManager.broadcast`) sends per client under an + `asyncio.Lock` and drops clients that error. +- **Failure = a gap, not a crash.** A failed temperature poll logs once and backs + off (`1.0 s → min(interval*10, 30 s)`); a stalled SSE forces a watchdog + reconnect after 60 s (chosen to tolerate the quiet windows during heavy plans); + volume acquisition always disables lasers on error to protect the sample. +- **Config safety.** `POST /api/temperature/config` refuses (`409`) while the + RunEngine is not idle or a ramp holds the controller lock; `health_check()` is + read-only and never flips the connected flag, so a transient status-poll timeout + can't disconnect an in-flight acquisition. + +## 8. Known limits & bottlenecks + +- **RunEngine on the loop.** `self.RE(plan)` is invoked synchronously on the + device-layer loop, so while a plan runs the loop is largely occupied — which is + exactly why heavy plans quiet the pollers and the app-side watchdog tolerates + 60 s of silence. Whether the telemetry HTTP handlers stay fully responsive + mid-acquisition depends on Bluesky's internal threading (external package) and + is not verified from repo code. +- **Unbounded in-flight perception.** `_perception_tasks` is a plain set that + self-prunes; sustained fast acquisition against a slow VLM could grow concurrent + Claude calls without an explicit cap. A semaphore is the obvious guard if + cadence is ever pushed. +- **~~Synchronous O(n) prediction writes~~ — FIXED.** `store_prediction` used to + re-parse the entire `predictions.jsonl` on every append to compute the next id. + Now O(1) via a bounded tail read (`_last_jsonl_record`, `file_store.py`). Other + `FileStore` JSONL writes remain synchronous on the app loop but are single-line + appends. +- **External VLM internals.** How `gently_perception`'s `Perceiver` implements its + VLM call (async httpx vs sync-in-thread) is not inspectable from this repo; the + orchestrator awaits it, implying a coroutine. + +## The model in one line + +Two single-threaded event loops; hardware fenced into one process behind a +one-at-a-time plan queue; everything blocking pushed into threads; and the +slow/cognitive work (VLM, persistence, UI) decoupled with fire-and-forget tasks — +so the system *feels* concurrent while the microscope itself stays strictly +serialized. + +--- +*Generated from a code-grounded architecture pass (Claude Opus 4.8), 2026-07-01.* diff --git a/docs/HEURISTICS-AUDIT.md b/docs/HEURISTICS-AUDIT.md new file mode 100644 index 00000000..249b27cc --- /dev/null +++ b/docs/HEURISTICS-AUDIT.md @@ -0,0 +1,112 @@ +# Heuristics audit — where to use the model (as a typed-output function) instead + +Codebase sweep (5 parallel scanners + synthesis) for heuristics that **fake +judgment** an LLM would do better — in the spirit of the genotype→channel +refactor (drop the lookup table, let the model infer, keep a typed provenance +record + confirm-when-unsure). The flip side — logic that **must stay +deterministic** (safety, math, calibration, transport) — is listed at the end so +we don't mistakenly LLM-ify it. + +The unifying move for every candidate: **LLM with a typed structured-output +schema + provenance + a confirm/UNCERTAIN escape**, never free-text-then-parse. + +## Model candidates (ranked) + +### High value + +1. **Hatching / time-to-stage prediction** — `organisms/celegans/developmental_tracker.py` + *(the closest twin of genotype→channel; medium effort)* + Three hardcoded 20 °C lookup tables (`STAGE_TIMING_20C`, `TIME_TO_HATCHING`, + `TIMING_VARIABILITY`) plus magic `{HIGH:1.0, MEDIUM:1.5, LOW:2.0}` uncertainty + fudge factors. Structurally **can't use the rig's actual temperature** (we run + a TEC), the strain, or the embryo's observed progression rate. Let the model + produce a calibrated, explained interval; **keep the literature table as a + deterministic sanity bracket** and flag when the estimate falls outside it. + → `{ predicted_minutes_to_hatching, low, high, basis, assumptions{temperature_c,strain,used_observed_rate}, confidence, reasoning }` + +2. **Citation → PubMed query** — `harness/plan_mode/tools/research.py` (`_search_pmid`) + A regex that only handles "Surname et al YEAR …" + six hand-rolled query- + relaxation strategies + a stopword/word-position ladder that drops load-bearing + nouns. The model parses the sloppy citation and proposes relaxed queries; **code + keeps the deterministic esearch call and never fabricates a PMID.** + → `{ author_last, year, journal, topic_keywords[], organism, pubmed_query, alt_queries[], confidence }` + +3. **Lab-history retrieval** — `harness/plan_mode/tools/lab_context.py`, `harness/memory/interface.py` + Semantic recall faked by substring-OR over query tokens (matches "we"/"before", + misses every paraphrase). Feed the model the candidate records and have it + **rank/select from provided ids only** (no fabrication). Read-only, no + acquisition risk. + → `{ matches:[{kind,id,summary,relevance,why_relevant}], answer }` + +4. **Stage-label parse via 22-entry synonym dict** — `developmental_tracker.py` (`_parse_stage_name`) + *(small effort, pure robustness win)* The Vision call already classifies; the + brittleness is a plain-text `STAGE:/CONFIDENCE:` block scraped line-by-line, with + off-vocabulary phrasings silently collapsing to `UNKNOWN` (which kills the + downstream hatching prediction). Constrained-enum structured output deletes the + parser + synonym table. + → `{ stage: enum(...), confidence: enum(high|medium|low), is_transitional, reasoning }` + +### Medium value (mostly small — fix the output contract, not the judgment) + +5. **Calibration Vision calls** — `hardware/dispim/claude_client.py` + Four Vision calls return positional free text recovered by `'yes' in first_line` + / `re.search(r'\d+')` / first-valid-letter, with silent defaults (so "no, this is + not yes…" reads as *yes*). Typed output deletes the parse + silent-default layer. + +6. **ML architecture ranking** — `ml/architectures.py` (`get_suitable_architectures`) + Hard feasibility gates (VRAM / dataset) are correct **and stay**; the `+2/+1/+1` + point-score ranking that follows discards the per-arch prose. Let the model rank + the *pre-filtered feasible set* (ids constrained to that set). + +7. **Training label normalization** — `ml/data_loader.py` (`build_labels_from_store`) + Class space built by exact-string identity over free-text human annotations — + "1.5-fold" and "1.5 fold" become different classes. Model normalizes to the + canonical staging vocabulary, flags novel/ambiguous ones. + +### Lower value + +8. **"Plan has a control?"** — `plan_mode/tools/validation.py` — substring scan of a + 6-word keyword set; a scientific judgment over the whole plan. Non-blocking + warning → safe for the model. +9. **CGC HTML scraping** — `research.py` (`_cgc_search`) — positional multi-group + regex over fetched HTML; structured extraction the model does better (HTTP GET + stays code; **mark strain names low-confidence to avoid sending someone to order + a hallucinated strain**). + +### Cross-cutting batch (small each): typed output for the detector/verifier cluster +`harness/detection/verifier.py`, `app/detectors/hatching.py`, +`app/detectors/dopaminergic_signal.py`, `hardware/dispim/sam_detection.py` — all +already make the right model call but reconstruct the verdict via +`startswith`/regex-JSON-scraping with silent defaults. A batch move to native +structured output **strictly reduces parse-induced false negatives** without +touching the deterministic vote-tally/consensus/enum-dispatch downstream. + +**Reference implementations already in the repo (imitate, don't change):** +`dopaminergic_signal`'s perceiver→classifier rubric (typed enums, UNCERTAIN +escape, conservative-on-tie) and onboarding's `_extract_with_llm` (typed +extraction, degrade-to-verbatim fallback). + +## Keep deterministic (do NOT LLM-ify) +Safety, math, calibration, and transport — where a hallucinated value is unsafe +or breaks reproducibility: +- Laser-power safety limits + wavelength→MM-property map (`hardware/dispim/devices/optical.py`) +- SPIM trigger-timing arithmetic, piezo–galvo calibration, MM framing (`dispim/config.py`) +- Calibration prior EMA + R²≥0.75 slope-lock gate (`dispim/calibration.py`) +- SwitchBot GATT byte commands / status decoding (`hardware/switchbot.py`) +- Temperature setpoint bound [0,99.9] °C + stabilization I/O (`hardware/temperature.py`) +- Autofocus signal-processing, curve fitting, adaptive-sweep stop rules (`analysis/core.py`, `analysis/focus.py`) +- Classical-CV ROI detection + pixel→stage coordinate transforms (`detection.py`, `sam_detection.py` geometry) +- Timelapse rule dispatch + `confirm_timepoints` debounce + monotonic power ramp (`app/orchestration/timelapse.py`) +- Volume→b64 dark/flat calibration + fixed brightness scaling (`dopaminergic_signal._volume_to_b64` — deliberately non-adaptive) +- Wake-router debounce/throttle/stage-transition gate (`app/wake_router.py`) +- Plan hardware limits, detector-preset membership, dependency-cycle DFS, stage-order normalization (`plan_mode/tools/validation.py`) +- Ensemble vote tally + 0.70 quorum / unanimity consensus (`detection/verifier.py`) +- ML metric/aggregation math: confusion matrix, F1, federated averaging (`ml/evaluation.py`, `federated.py`) +- Core imaging geometry (max-projection, crop bounds, Euler rotations) + UI event reduction/routing/security (`core/imaging.py`, `ui/web/*`) +- Device-state SSE watchdog/staleness timers (`app/device_state_monitor.py`) +- Reference-type dispatch (PMID/DOI/URL by canonical syntax), `os.path.isfile` checks (`research.py`) + +## Note +`gap_assessment.conversation_weight` (the 0.25/0.1/0.05 readiness scalar) is now +largely **vestigial** — it only returns 'heavy' (lab onboarding) or 'none' — so +it's not worth an API call. Left off the candidate list. diff --git a/docs/superpowers/PR-PLAN.md b/docs/superpowers/PR-PLAN.md new file mode 100644 index 00000000..042c5e5c --- /dev/null +++ b/docs/superpowers/PR-PLAN.md @@ -0,0 +1,32 @@ +# Wrap-up PR plan — the temperature-experiment + Operations goal + +**Principle (user-stated):** capture the breadth of work as **separate PRs**, each a distinct unit of +work with its own identity/ownership and its own tests, **additive on top of PR #58** +(`integration/ux2-all`, the UX-v2 stack), stacked so they **compose into the final product** in order. +Each PR diffs cleanly against its parent in the stack; integrate the chain when rig-verified. + +## The stack (base = PR #58 `integration/ux2-all`) + +| # | Branch | Unit of work | Parent | Tests / review | +|---|--------|--------------|--------|----------------| +| 1 | `feature/temperature-interface` (A) | Temperature persistence (`append/read_temperature_sample`) + sampler service + SVG temperature graph w/ setpoint line | #58 | per-task + whole-branch review; graph Chrome-audited | +| 2 | `feature/manual-mode-live-view` (B1) | Manual-mode imaging: lightsheet brightfield live view (sequence acquisition), illumination control (LED/laser presets), galvo/piezo scan params + **4 acquire-safety fixes** (C1/I1/I2/I3) | A | per-task + whole-branch (opus) review | +| 3 | `feature/temp-change-tactic` (C) | Automated temp-change burst protocol (`wait_for_temperature_lock` + driver), burst-acquisition wiring, protocol events + agent tool | B1 | per-task + whole-branch review + fixes | +| 4 | `feature/operations-tab` (D) | **Operations: the agent-authored Operation Plan** — typed declare tool, store, route, execution-linkage (tactic_id + updater), plan-item seeding, operation-spine renderer + live binding | C | 10 tasks + whole-branch (opus) review + 6 fixes; 105 tests; Chrome-audited | +| 5 | `feature/tactics-library` (G) | Save / list / apply reusable typed tactics (on D's substrate), mirroring plan-templates; apply→Operation Plan | D | 3 tasks + whole-branch review + fixes; ~64 tests | +| 6 | `feature/embryo-roles-observability` (D2) | Per-embryo **strain** field + roles-as-**use** (lineaging + subject/reference) + multi-embryo Operations roster lens (role + strain) | G | 4 tasks + whole-branch review + fix; 54 tests; Chrome-audited | +| 7 | `feature/session-plan-linking` (F) | Session↔**plans** link/delink: multi-plan model (`unlink_plan_item_session` + reverse-query), link/delink endpoints, Plans-tab controls + session Linked-plans panel | D2 | 4 tasks + whole-branch review + fix; ~70 tests; both surfaces Chrome-audited | +| 8 | `feature/manual-mode-dual-camera` (B2) | Dual-camera config + laser-preset browser + timelapse config form (extends B1 manual mode) | F | TBD (SDD) | + +Notes: +- Each branch is the natural unit of "distinct enough to own a PR." Sub-parts (e.g. B1's safety fixes) + stay inside their branch — granular enough to capture the work, not so granular it's noise. +- A/B1/C are **kept-as-is pending rig verification** (not merged); D/G/D2 build on top. The stack is + intact but unmerged — PRs can open stacked and merge the chain once verified on the rig. +- "Easy to put together into a final product" = the linear stack already composes; the integration + point is #58 → `development`. + +## At wrap-up +1. Verify each branch's tests pass (the SDD ledgers + whole-branch reviews are the evidence trail). +2. Open the stacked PRs in order (each targets its parent branch), each description capturing its unit. +3. Rebase/integrate the chain onto #58 when rig-verified; #58 → `development` as the final integration. diff --git a/docs/superpowers/notes/2026-06-28-lightsheet-fps-measurement.md b/docs/superpowers/notes/2026-06-28-lightsheet-fps-measurement.md new file mode 100644 index 00000000..d1a4abd9 --- /dev/null +++ b/docs/superpowers/notes/2026-06-28-lightsheet-fps-measurement.md @@ -0,0 +1,57 @@ +# Lightsheet live-view FPS measurement + transport decision (B1 Task 7) + +Status: **deferred to the rig** — the numbers below must be filled in on the microscope +(the streamer needs the real `pymmcore` core, SPIM camera, and rpyc transport; it cannot run +on the Linux dev box). This file is the measurement protocol + the decision gate. + +## What to measure + +With the Manual view open and lightsheet live running, record three rates: + +| Metric | Where to read it | +|---|---| +| **device grab rate** (frames peeked/encoded /s) | device-layer log / instrument `_lightsheet_streamer` | +| **delivered rate** (frames broadcast /s) | device-layer `_broadcast_lightsheet` | +| **browser paint rate** | the Manual-view FPS readout (`computeLightsheetFps`) | + +Record at two resolution/quality settings: +- default **512 px / JPEG q70** (`_ls_target_max_dim=512`, `_ls_jpeg_quality=70`) +- reduced **384 px / q60** + +Note the exposure used (the peek floor is `max(exposure, 1/30 s)`). + +## Target + +**≥ ~15 fps usable for focus** (stretch 25–30), end-to-end latency < ~150 ms. + +## Diagnosis rule (the gate) + +- **device grab < target** → limiter is exposure / readout / rpyc, **not** transport. Tune + exposure, the 512 px size, and JPEG quality. A binary transport path will NOT help — stop here. +- **device grab ≥ target but browser paint < target** → transport is the bottleneck → build the + **binary WebSocket path** (below). + +## Conditional escalation — binary WebSocket path + +Only if the diagnosis points to transport. The current path is base64-JPEG-in-JSON over SSE → +EventBus → `ConnectionManager.broadcast` (`json.dumps` + `send_text`) — `connection_manager.py` +has **no `send_bytes` path** (confirmed). Escalation, on the **agent→browser hop** (where cost +multiplies per client): + +- push raw JPEG bytes via `websocket.send_bytes(prefix + jpeg)` (a 1-byte type tag identifies a + lightsheet frame), bypassing **base64 (+33%)**, the **per-client `json.dumps`**, and the + **EventBus fan-out**; +- browser `onmessage` (binary) → `createImageBitmap(new Blob([buf]))` → `ctx.drawImage`; +- the device→agent SSE stays as-is (single consumer = the monitor, so its base64 cost is paid + once, not per browser). + +Re-measure after building; record the before/after numbers here. + +## Results (fill in on the rig) + +| setting | device grab fps | delivered fps | browser paint fps | exposure | notes | +|---|---|---|---|---|---| +| 512px/q70 | _TBD_ | _TBD_ | _TBD_ | _TBD_ | | +| 384px/q60 | _TBD_ | _TBD_ | _TBD_ | _TBD_ | | + +Decision: _TBD (transport bottleneck? build binary path Y/N)_ diff --git a/docs/superpowers/plans/2026-06-16-notebook-foundation.md b/docs/superpowers/plans/2026-06-16-notebook-foundation.md new file mode 100644 index 00000000..4d6a480c --- /dev/null +++ b/docs/superpowers/plans/2026-06-16-notebook-foundation.md @@ -0,0 +1,645 @@ +# Notebook Foundation Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build the unit-testable foundation of the shared lab notebook — the unified `Note` model and a file-backed `NotebookStore` (write / read / scope-query / rebuildable reverse-indexes / link & supersede) — with no UI, API, or agent wiring. + +**Architecture:** A new self-contained module `gently/harness/memory/notebook.py`. One `Note` dataclass (three kinds: observation/finding/question) with author, status, confidence, scope facets (strains/embryos/sessions/threads), typed links, basis, and artifact pointers — orthogonal fields, not subtypes. `NotebookStore` persists one YAML per note under `notebook/notes/{id}_{slug}.yaml` (atomic write, mirroring `FileContextStore`), maintains rebuildable reverse-indexes by strain/embryo/thread, and answers scope+kind+status queries. This is Increment 1's keystone from the design doc (`docs/superpowers/specs/2026-06-16-shared-lab-notebook-design.md`). + +**Tech Stack:** Python 3.11, dataclasses, `str`-Enums, PyYAML, pytest (fixtures in `tests/conftest.py`, flat `tests/` layout). + +**Follow-on plans (NOT in scope here):** producer wiring (`apply_updates` → notebook), `/api/notebook` + Notebook tab (UI), retrieval/embeddings + brainstorm. Each ships on top of this foundation. + +--- + +### Task 1: The `Note` model + +**Files:** +- Create: `gently/harness/memory/notebook.py` +- Test: `tests/test_notebook_store.py` + +- [ ] **Step 1: Write the failing test** + +```python +# tests/test_notebook_store.py +"""Tests for the shared lab notebook: Note model + NotebookStore.""" + +from datetime import datetime + +from gently.harness.memory.model import Confidence +from gently.harness.memory.notebook import ( + Author, + Note, + NoteKind, + NoteStatus, + note_from_dict, + note_to_dict, +) + + +class TestNoteModel: + def test_round_trip_minimal(self): + n = Note(id="abc123", kind=NoteKind.OBSERVATION, body="dim rings at 10 ms") + d = note_to_dict(n) + assert d["kind"] == "observation" + assert d["author"] == "agent" # default + assert d["status"] == "confirmed" # default + back = note_from_dict(d) + assert back == n + + def test_round_trip_full(self): + n = Note( + id="def456", + kind=NoteKind.FINDING, + body="temperature shifts timing ~12 min/degC", + author=Author.AGENT, + title="Temp shifts timing", + status=NoteStatus.PROPOSED, + confidence=Confidence.MEDIUM, + strains=["N2", "OH904"], + embryos=["emb_0007"], + sessions=["20260615_1432_x"], + threads=["q_division_temp"], + basis=["obs_1", "obs_2"], + links=[{"rel": "supports", "to": "q_division_temp"}], + artifacts=[{"kind": "projection", "session": "s1", "embryo": "emb_0007", "t": 42}], + created_at=datetime(2026, 6, 16, 11, 0, 0), + updated_at=datetime(2026, 6, 16, 11, 0, 0), + ) + back = note_from_dict(note_to_dict(n)) + assert back == n + assert note_to_dict(n)["confidence"] == "medium" +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `python -m pytest tests/test_notebook_store.py::TestNoteModel -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'gently.harness.memory.notebook'` + +- [ ] **Step 3: Write minimal implementation** + +```python +# gently/harness/memory/notebook.py +""" +The shared lab notebook — unified memory entry (Note) and file-backed store. + +One Note kind taxonomy (observation / finding / question); everything else +(author, status, confidence, scope, links) is an orthogonal field. See +docs/superpowers/specs/2026-06-16-shared-lab-notebook-design.md. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import datetime +from enum import Enum +from typing import Any + +from .model import Confidence + + +class NoteKind(str, Enum): + OBSERVATION = "observation" # immutable record of what was seen/done/read/noted + FINDING = "finding" # revisable, supersedable believed claim + QUESTION = "question" # open inquiry; large ones are the thread spine + + +class Author(str, Enum): + HUMAN = "human" + AGENT = "agent" + PERCEPTION = "perception" + + +class NoteStatus(str, Enum): + OPEN = "open" # questions not yet answered + PROPOSED = "proposed" # agent-drafted finding awaiting human confirm + CONFIRMED = "confirmed" # accepted observation/finding (default) + ANSWERED = "answered" # question resolved + SUPERSEDED = "superseded" # replaced by a newer note + + +@dataclass +class Note: + id: str + kind: NoteKind + body: str + author: Author = Author.AGENT + title: str | None = None + status: NoteStatus = NoteStatus.CONFIRMED + confidence: Confidence | None = None + strains: list[str] = field(default_factory=list) + embryos: list[str] = field(default_factory=list) + sessions: list[str] = field(default_factory=list) + threads: list[str] = field(default_factory=list) + basis: list[str] = field(default_factory=list) # note ids this rests on + links: list[dict] = field(default_factory=list) # [{"rel": ..., "to": ...}] + artifacts: list[dict] = field(default_factory=list) # FileStore pointers + superseded_by: str | None = None + created_at: datetime = field(default_factory=datetime.now) + updated_at: datetime = field(default_factory=datetime.now) + + +def note_to_dict(n: Note) -> dict[str, Any]: + return { + "id": n.id, + "kind": n.kind.value, + "body": n.body, + "author": n.author.value, + "title": n.title, + "status": n.status.value, + "confidence": n.confidence.value if n.confidence else None, + "strains": list(n.strains), + "embryos": list(n.embryos), + "sessions": list(n.sessions), + "threads": list(n.threads), + "basis": list(n.basis), + "links": list(n.links), + "artifacts": list(n.artifacts), + "superseded_by": n.superseded_by, + "created_at": n.created_at.isoformat(), + "updated_at": n.updated_at.isoformat(), + } + + +def note_from_dict(d: dict[str, Any]) -> Note: + conf = d.get("confidence") + return Note( + id=d["id"], + kind=NoteKind(d["kind"]), + body=d.get("body", ""), + author=Author(d.get("author", "agent")), + title=d.get("title"), + status=NoteStatus(d.get("status", "confirmed")), + confidence=Confidence(conf) if conf else None, + strains=list(d.get("strains") or []), + embryos=list(d.get("embryos") or []), + sessions=list(d.get("sessions") or []), + threads=list(d.get("threads") or []), + basis=list(d.get("basis") or []), + links=list(d.get("links") or []), + artifacts=list(d.get("artifacts") or []), + superseded_by=d.get("superseded_by"), + created_at=datetime.fromisoformat(d["created_at"]), + updated_at=datetime.fromisoformat(d["updated_at"]), + ) +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `python -m pytest tests/test_notebook_store.py::TestNoteModel -v` +Expected: PASS (2 passed) + +- [ ] **Step 5: Commit** + +```bash +git add gently/harness/memory/notebook.py tests/test_notebook_store.py +git commit -m "feat(notebook): unified Note model + dict serialization" +``` + +--- + +### Task 2: `NotebookStore` — write & read a note + +**Files:** +- Modify: `gently/harness/memory/notebook.py` (append `NotebookStore`) +- Test: `tests/test_notebook_store.py` (append) + +- [ ] **Step 1: Write the failing test** + +```python +# append to tests/test_notebook_store.py +from gently.harness.memory.notebook import NotebookStore + + +class TestNotebookStoreReadWrite: + def test_write_assigns_id_and_persists(self, tmp_path): + store = NotebookStore(tmp_path / "notebook") + n = Note(id="", kind=NoteKind.OBSERVATION, body="bean stage at t40") + note_id = store.write_note(n) + assert note_id # non-empty id assigned + files = list((tmp_path / "notebook" / "notes").glob("*.yaml")) + assert len(files) == 1 + assert files[0].name.startswith(note_id + "_") + + def test_get_note_round_trip(self, tmp_path): + store = NotebookStore(tmp_path / "notebook") + n = Note(id="", kind=NoteKind.FINDING, body="x", strains=["N2"], threads=["t1"]) + note_id = store.write_note(n) + got = store.get_note(note_id) + assert got is not None + assert got.id == note_id + assert got.kind == NoteKind.FINDING + assert got.strains == ["N2"] + + def test_get_missing_returns_none(self, tmp_path): + store = NotebookStore(tmp_path / "notebook") + assert store.get_note("nope") is None +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `python -m pytest tests/test_notebook_store.py::TestNotebookStoreReadWrite -v` +Expected: FAIL — `ImportError: cannot import name 'NotebookStore'` + +- [ ] **Step 3: Write minimal implementation** + +```python +# append to gently/harness/memory/notebook.py +import copy +import os +import re +import uuid +from pathlib import Path + +import yaml + + +class NotebookStore: + """File-backed store for notebook Notes. One YAML per note under notes/; + flat pool, rebuildable reverse-indexes (added in Task 3).""" + + def __init__(self, notebook_dir: Path): + self.root = Path(notebook_dir) + self.notes_dir = self.root / "notes" + self.index_dir = self.root / "index" + self.notes_dir.mkdir(parents=True, exist_ok=True) + self.index_dir.mkdir(parents=True, exist_ok=True) + + # ---- helpers (mirror FileContextStore conventions) ---- + @staticmethod + def _gen_id() -> str: + return str(uuid.uuid4())[:8] + + @staticmethod + def _slugify(text: str) -> str: + slug = re.sub(r"[^a-z0-9]+", "-", (text or "").lower()).strip("-") + return slug[:30] + + def _write_yaml(self, path: Path, data: dict) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_suffix(".tmp") + with open(tmp, "w", encoding="utf-8") as fh: + yaml.safe_dump(data, fh, default_flow_style=False, allow_unicode=True, sort_keys=False) + os.replace(str(tmp), str(path)) + + def _read_yaml(self, path: Path) -> dict | None: + try: + with open(path, encoding="utf-8") as fh: + return yaml.safe_load(fh) + except OSError: + return None + + def _note_path(self, note_id: str) -> Path | None: + return next(self.notes_dir.glob(f"{note_id}_*.yaml"), None) + + # ---- read/write ---- + def write_note(self, note: Note) -> str: + if not note.id: + note.id = self._gen_id() + note.updated_at = datetime.now() + slug = self._slugify(note.title or note.body or note.kind.value) + # remove any stale file for this id (slug may have changed) + old = self._note_path(note.id) + if old is not None: + old.unlink() + self._write_yaml(self.notes_dir / f"{note.id}_{slug}.yaml", note_to_dict(note)) + return note.id + + def get_note(self, note_id: str) -> Note | None: + path = self._note_path(note_id) + if path is None: + return None + data = self._read_yaml(path) + return note_from_dict(data) if data else None +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `python -m pytest tests/test_notebook_store.py::TestNotebookStoreReadWrite -v` +Expected: PASS (3 passed) + +- [ ] **Step 5: Commit** + +```bash +git add gently/harness/memory/notebook.py tests/test_notebook_store.py +git commit -m "feat(notebook): NotebookStore write_note/get_note with atomic YAML" +``` + +--- + +### Task 3: Reverse-indexes (by strain / embryo / thread) + rebuild + +**Files:** +- Modify: `gently/harness/memory/notebook.py` (`NotebookStore`) +- Test: `tests/test_notebook_store.py` (append) + +- [ ] **Step 1: Write the failing test** + +```python +# append to tests/test_notebook_store.py +class TestNotebookIndex: + def test_index_updated_on_write(self, tmp_path): + store = NotebookStore(tmp_path / "notebook") + a = store.write_note(Note(id="", kind=NoteKind.OBSERVATION, body="a", strains=["N2"])) + b = store.write_note(Note(id="", kind=NoteKind.OBSERVATION, body="b", strains=["N2", "OH904"])) + assert set(store.ids_for_strain("N2")) == {a, b} + assert store.ids_for_strain("OH904") == [b] + assert store.ids_for_strain("missing") == [] + + def test_index_by_embryo_and_thread(self, tmp_path): + store = NotebookStore(tmp_path / "notebook") + a = store.write_note(Note(id="", kind=NoteKind.FINDING, body="a", + embryos=["e1"], threads=["t1"])) + assert store.ids_for_embryo("e1") == [a] + assert store.ids_for_thread("t1") == [a] + + def test_rebuild_index_from_disk(self, tmp_path): + store = NotebookStore(tmp_path / "notebook") + a = store.write_note(Note(id="", kind=NoteKind.OBSERVATION, body="a", strains=["N2"])) + # a fresh store over the same dir must rebuild the index by scanning notes/ + store2 = NotebookStore(tmp_path / "notebook") + assert store2.ids_for_strain("N2") == [a] +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `python -m pytest tests/test_notebook_store.py::TestNotebookIndex -v` +Expected: FAIL — `AttributeError: 'NotebookStore' object has no attribute 'ids_for_strain'` + +- [ ] **Step 3: Write minimal implementation** + +Modify `NotebookStore.__init__` to add index state + rebuild, extend `write_note` to update the index, and add the index methods. Replace the existing `__init__` and `write_note` with these versions and add the new methods: + +```python + # --- replace __init__ --- + def __init__(self, notebook_dir: Path): + self.root = Path(notebook_dir) + self.notes_dir = self.root / "notes" + self.index_dir = self.root / "index" + self.notes_dir.mkdir(parents=True, exist_ok=True) + self.index_dir.mkdir(parents=True, exist_ok=True) + # reverse indexes: facet -> {value: [note_id, ...]} + self._index: dict[str, dict[str, list[str]]] = { + "strain": {}, "embryo": {}, "thread": {} + } + self.rebuild_index() + + # --- add: facet extraction + index maintenance --- + _FACETS = {"strain": "strains", "embryo": "embryos", "thread": "threads"} + + def _index_note(self, note: Note) -> None: + for facet, attr in self._FACETS.items(): + for value in getattr(note, attr): + bucket = self._index[facet].setdefault(value, []) + if note.id not in bucket: + bucket.append(note.id) + + def rebuild_index(self) -> None: + """Rebuild reverse-indexes by scanning notes/ (the notes are authoritative; + indexes are disposable caches).""" + self._index = {"strain": {}, "embryo": {}, "thread": {}} + for f in sorted(self.notes_dir.glob("*.yaml")): + data = self._read_yaml(f) + if data: + self._index_note(note_from_dict(data)) + + def ids_for_strain(self, strain: str) -> list[str]: + return list(self._index["strain"].get(strain, [])) + + def ids_for_embryo(self, embryo: str) -> list[str]: + return list(self._index["embryo"].get(embryo, [])) + + def ids_for_thread(self, thread: str) -> list[str]: + return list(self._index["thread"].get(thread, [])) +``` + +Then add an index-update at the end of `write_note` (just before `return note.id`): + +```python + self._index_note(note) + return note.id +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `python -m pytest tests/test_notebook_store.py::TestNotebookIndex -v` +Expected: PASS (3 passed) + +- [ ] **Step 5: Commit** + +```bash +git add gently/harness/memory/notebook.py tests/test_notebook_store.py +git commit -m "feat(notebook): rebuildable reverse-indexes by strain/embryo/thread" +``` + +--- + +### Task 4: `query_notes` — filter by kind / author / status / scope + +**Files:** +- Modify: `gently/harness/memory/notebook.py` (`NotebookStore`) +- Test: `tests/test_notebook_store.py` (append) + +- [ ] **Step 1: Write the failing test** + +```python +# append to tests/test_notebook_store.py +class TestNotebookQuery: + def _seed(self, tmp_path): + store = NotebookStore(tmp_path / "notebook") + store.write_note(Note(id="o1", kind=NoteKind.OBSERVATION, body="o", strains=["N2"])) + store.write_note(Note(id="f1", kind=NoteKind.FINDING, body="f", + status=NoteStatus.PROPOSED, strains=["N2"], threads=["t1"])) + store.write_note(Note(id="q1", kind=NoteKind.QUESTION, body="q", + status=NoteStatus.OPEN, threads=["t1"])) + return store + + def test_query_by_kind(self, tmp_path): + store = self._seed(tmp_path) + ids = {n.id for n in store.query_notes(kind=NoteKind.FINDING)} + assert ids == {"f1"} + + def test_query_by_thread_scope(self, tmp_path): + store = self._seed(tmp_path) + ids = {n.id for n in store.query_notes(thread="t1")} + assert ids == {"f1", "q1"} + + def test_query_by_thread_and_kind(self, tmp_path): + store = self._seed(tmp_path) + ids = {n.id for n in store.query_notes(thread="t1", kind=NoteKind.QUESTION)} + assert ids == {"q1"} + + def test_query_by_status(self, tmp_path): + store = self._seed(tmp_path) + ids = {n.id for n in store.query_notes(status=NoteStatus.OPEN)} + assert ids == {"q1"} + + def test_query_all_sorted_newest_first(self, tmp_path): + store = self._seed(tmp_path) + notes = store.query_notes() + assert len(notes) == 3 + ts = [n.created_at for n in notes] + assert ts == sorted(ts, reverse=True) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `python -m pytest tests/test_notebook_store.py::TestNotebookQuery -v` +Expected: FAIL — `AttributeError: 'NotebookStore' object has no attribute 'query_notes'` + +- [ ] **Step 3: Write minimal implementation** + +```python +# add to NotebookStore + def query_notes( + self, + *, + kind: NoteKind | None = None, + author: Author | None = None, + status: NoteStatus | None = None, + strain: str | None = None, + embryo: str | None = None, + thread: str | None = None, + ) -> list[Note]: + """Structural query: narrow by scope via the indexes, then filter by + kind/author/status. Returned newest-first. (No semantic ranking here — + that's a later increment.)""" + # 1. candidate ids — intersect any scope facets given, else all notes + scope_sets: list[set[str]] = [] + if strain is not None: + scope_sets.append(set(self.ids_for_strain(strain))) + if embryo is not None: + scope_sets.append(set(self.ids_for_embryo(embryo))) + if thread is not None: + scope_sets.append(set(self.ids_for_thread(thread))) + if scope_sets: + candidate_ids: set[str] | None = set.intersection(*scope_sets) + else: + candidate_ids = None # means "all" + + # 2. load + filter + results: list[Note] = [] + if candidate_ids is not None: + notes = [n for n in (self.get_note(i) for i in candidate_ids) if n] + else: + notes = [ + note_from_dict(d) + for d in (self._read_yaml(f) for f in self.notes_dir.glob("*.yaml")) + if d + ] + for n in notes: + if kind is not None and n.kind != kind: + continue + if author is not None and n.author != author: + continue + if status is not None and n.status != status: + continue + results.append(n) + results.sort(key=lambda n: n.created_at, reverse=True) + return results +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `python -m pytest tests/test_notebook_store.py::TestNotebookQuery -v` +Expected: PASS (5 passed) + +- [ ] **Step 5: Commit** + +```bash +git add gently/harness/memory/notebook.py tests/test_notebook_store.py +git commit -m "feat(notebook): query_notes by kind/author/status/scope" +``` + +--- + +### Task 5: `link_notes` and `supersede_note` + +**Files:** +- Modify: `gently/harness/memory/notebook.py` (`NotebookStore`) +- Test: `tests/test_notebook_store.py` (append) + +- [ ] **Step 1: Write the failing test** + +```python +# append to tests/test_notebook_store.py +class TestNotebookLinkSupersede: + def test_link_notes_adds_typed_edge(self, tmp_path): + store = NotebookStore(tmp_path / "notebook") + a = store.write_note(Note(id="", kind=NoteKind.FINDING, body="a")) + b = store.write_note(Note(id="", kind=NoteKind.QUESTION, body="b")) + store.link_notes(a, "supports", b) + got = store.get_note(a) + assert {"rel": "supports", "to": b} in got.links + + def test_supersede_marks_old_and_points_new(self, tmp_path): + store = NotebookStore(tmp_path / "notebook") + old = store.write_note(Note(id="", kind=NoteKind.FINDING, body="old claim")) + new = store.write_note(Note(id="", kind=NoteKind.FINDING, body="better claim")) + store.supersede_note(old, new) + old_n = store.get_note(old) + new_n = store.get_note(new) + assert old_n.status == NoteStatus.SUPERSEDED + assert old_n.superseded_by == new + assert {"rel": "refines", "to": old} in new_n.links +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `python -m pytest tests/test_notebook_store.py::TestNotebookLinkSupersede -v` +Expected: FAIL — `AttributeError: 'NotebookStore' object has no attribute 'link_notes'` + +- [ ] **Step 3: Write minimal implementation** + +```python +# add to NotebookStore + def link_notes(self, from_id: str, rel: str, to_id: str) -> None: + """Add a typed edge from one note to another (append-only).""" + note = self.get_note(from_id) + if note is None: + raise KeyError(from_id) + edge = {"rel": rel, "to": to_id} + if edge not in note.links: + note.links.append(edge) + self.write_note(note) + + def supersede_note(self, old_id: str, new_id: str) -> None: + """Mark old as superseded (kept, never deleted) and link the new note + back to it as a refinement — the chain is the intellectual history.""" + old = self.get_note(old_id) + if old is None: + raise KeyError(old_id) + old.status = NoteStatus.SUPERSEDED + old.superseded_by = new_id + self.write_note(old) + self.link_notes(new_id, "refines", old_id) +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `python -m pytest tests/test_notebook_store.py -v` +Expected: PASS (all tests across all classes pass) + +- [ ] **Step 5: Commit** + +```bash +git add gently/harness/memory/notebook.py tests/test_notebook_store.py +git commit -m "feat(notebook): link_notes + supersede_note (append-only history)" +``` + +--- + +## Self-Review + +**Spec coverage (against the design doc §2 data model):** +- Three kinds (Observation/Finding/Question) → Task 1 `NoteKind`. ✓ +- Orthogonal fields (author/status/confidence/scope/links/artifacts) → Task 1 `Note`. ✓ +- Flat note pool + rebuildable reverse-indexes (strain/embryo/thread) → Tasks 2-3. ✓ +- "By question + by strain + links coexist over flat YAML, no DB" → Tasks 3-4 (indexes + scope-intersect query). ✓ +- Append-only / supersede-never-overwrite → Task 5 `supersede_note`. ✓ +- Typed links graph → Tasks 1 (`links`) + 5 (`link_notes`). ✓ +- *Deferred to follow-on plans (correctly out of scope):* inquiry-thread object, working-memory split, producer wiring, API/tab, embeddings/retrieval, consolidation. Noted in header. + +**Placeholder scan:** No TBD/TODO; every code step shows complete code; commands have expected output. ✓ + +**Type consistency:** `Note`, `NoteKind`, `Author`, `NoteStatus`, `note_to_dict`/`note_from_dict`, and `NotebookStore.{write_note,get_note,rebuild_index,ids_for_strain,ids_for_embryo,ids_for_thread,query_notes,link_notes,supersede_note}` are named identically across all tasks and tests. `Confidence` is imported from `.model` (confirmed to exist). ✓ diff --git a/docs/superpowers/plans/2026-06-16-notebook-live-edge.md b/docs/superpowers/plans/2026-06-16-notebook-live-edge.md new file mode 100644 index 00000000..9265d769 --- /dev/null +++ b/docs/superpowers/plans/2026-06-16-notebook-live-edge.md @@ -0,0 +1,188 @@ +# Notebook Live Edge Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:executing-plans. Steps use checkbox (`- [ ]`) syntax. + +**Goal:** Make the Home "Agent's view" panel surface recent notebook activity — the ambient "live edge" of the notebook (design §3, two-faced presentation), complementing the Notebook tab (the reading room). + +**Architecture:** Add an optional `limit` to the notes read API (TDD). Extend `context-surface.js` to also fetch recent notes and render a "From the notebook" section whose rows click through to the Notebook tab. Reuse the existing `cx-dot` colors (amber/blue/green) for kinds — no new CSS. + +**Tech Stack:** FastAPI, pytest + TestClient (venv), vanilla JS. + +**Out of scope:** retrieval/"Ask the notebook" (next increment); proactive surfacing. + +--- + +### Task 1: `limit` param on `GET /api/notebook/notes` + +**Files:** +- Modify: `gently/ui/web/routes/notebook.py` +- Test: `tests/test_notebook_api.py` (append) + +- [ ] **Step 1: Write the failing test** + +```python +# append to tests/test_notebook_api.py +class TestLimit: + def test_limit_returns_newest(self, file_context_store): + client = TestClient(_make_app(_seed(file_context_store))) + data = client.get("/api/notebook/notes?limit=1").json() + assert len(data["notes"]) == 1 # newest-first, capped +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `.venv/bin/python -m pytest tests/test_notebook_api.py::TestLimit -v` +Expected: FAIL — returns 2 notes, not 1. + +- [ ] **Step 3: Write minimal implementation** + +In `gently/ui/web/routes/notebook.py`, add a `limit` param to `list_notes` and slice. Replace the `list_notes` signature and the final return: + +```python + @router.get("/api/notebook/notes") + async def list_notes( + kind: str | None = None, + author: str | None = None, + status: str | None = None, + strain: str | None = None, + embryo: str | None = None, + thread: str | None = None, + limit: int | None = None, + ): + nb = _nb() + if nb is None: + return {"available": False, "notes": []} + notes = nb.query_notes( + kind=_coerce(NoteKind, kind), + author=_coerce(Author, author), + status=_coerce(NoteStatus, status), + strain=strain, + embryo=embryo, + thread=thread, + ) + if limit is not None and limit >= 0: + notes = notes[:limit] + return {"available": True, "notes": [note_to_dict(n) for n in notes]} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `.venv/bin/python -m pytest tests/test_notebook_api.py -q` +Expected: all pass + +- [ ] **Step 5: Commit** + +```bash +git add gently/ui/web/routes/notebook.py tests/test_notebook_api.py +git commit -m "feat(notebook): limit param on GET /api/notebook/notes" +``` + +--- + +### Task 2: "From the notebook" section in the Agent's-view panel + +**Files:** +- Modify: `gently/ui/web/static/js/context-surface.js` + +Verification is browser-based (chrome-devtools), not pytest. + +- [ ] **Step 1: Extend `fetchAndRender` to also pull recent notes** + +Replace `fetchAndRender` with a version that fetches both endpoints and passes notes to `render`: + +```javascript + async function fetchAndRender() { + if (!el || loading) return; + loading = true; + try { + const [ctx, nb] = await Promise.all([ + fetch('/api/context').then(r => r.json()).catch(() => ({})), + fetch('/api/notebook/notes?limit=5').then(r => r.json()).catch(() => ({})), + ]); + render(ctx || {}, (nb && nb.notes) || []); + } catch (e) { /* keep last render */ } + finally { loading = false; } + } +``` + +- [ ] **Step 2: Render the notebook section** + +Replace `render(data)` with `render(data, notes)`. Add the notebook section and include notes in the empty-state check. Replace the whole `render` function body: + +```javascript + function render(data, notes) { + if (!el) return; + notes = notes || []; + const hc = hasControl(); + const questions = data.questions || [], watchpoints = data.watchpoints || [], expectations = data.expectations || []; + el.classList.remove('hidden'); + if (!questions.length && !watchpoints.length && !expectations.length && !notes.length) { + el.innerHTML = '
Agent’s view
' + + '
Nothing yet — the agent’s notes, expectations, and open questions appear here as it works.
'; + return; + } + + const qHtml = questions.map(it => ` +
+ + ${esc(it.content)} + ${hc ? '' : ''} + ${hc ? '' : ''} +
`).join(''); + const wHtml = watchpoints.map(it => ` +
+ + ${esc(it.target)}${it.condition ? ' — ' + esc(it.condition) : ''} + ${hc ? '' : ''} +
`).join(''); + const eHtml = expectations.map(it => ` +
+ + ${esc(it.target)}${it.prediction ? ': ' + esc(it.prediction) : ''} + ${hc ? '' : ''} +
`).join(''); + // kind → existing cx-dot color: observation=blue, finding=green, question=amber + const dotFor = (k) => k === 'finding' ? 'cx-e' : (k === 'question' ? 'cx-q' : 'cx-w'); + const nHtml = notes.map(n => ` +
+ + ${esc(n.title || n.body)} +
`).join(''); + + el.innerHTML = '
Agent’s view
' + + section('Open questions', qHtml) + section('Watching', wHtml) + + section('Expectations', eHtml) + section('From the notebook', nHtml); + wire(); + } +``` + +- [ ] **Step 3: Make notebook rows click through to the Notebook tab** + +In `wire()`, after the existing `.cx-item` loop, add a handler for note rows (append inside `wire`, before its closing brace): + +```javascript + el.querySelectorAll('.cx-note').forEach(row => { + row.style.cursor = 'pointer'; + row.addEventListener('click', () => { + if (typeof switchTab === 'function') switchTab('notebook'); + }); + }); +``` + +- [ ] **Step 4: Verify in the browser** + +Restart the server, open `http://localhost:8080`, confirm the Home "Agent's view" panel shows a "From the notebook" section with recent notes, and clicking a row switches to the Notebook tab. Use chrome-devtools (navigate, evaluate_script to click, take_screenshot, list_console_messages → zero errors). + +- [ ] **Step 5: Commit** + +```bash +git add gently/ui/web/static/js/context-surface.js +git commit -m "feat(notebook): Agent's-view live edge — recent notes section -> Notebook tab" +``` + +--- + +## Self-Review +**Spec coverage:** design §3 two-faced presentation — the ambient live edge now surfaces recent notebook notes on Home, clicking through to the tab. ✓ Reuses `/api/notebook/notes` (+ new `limit`) and existing `cx-dot` colors. ✓ +**Placeholder scan:** none. ✓ +**Type consistency:** `render(data, notes)`, `dotFor`, `limit` param, `switchTab('notebook')` all consistent; kinds map to existing cx classes. ✓ diff --git a/docs/superpowers/plans/2026-06-16-notebook-producer-bridge.md b/docs/superpowers/plans/2026-06-16-notebook-producer-bridge.md new file mode 100644 index 00000000..d518f621 --- /dev/null +++ b/docs/superpowers/plans/2026-06-16-notebook-producer-bridge.md @@ -0,0 +1,251 @@ +# Notebook Producer Bridge Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax. + +**Goal:** Make the existing agent-memory write path actually populate the shared notebook — when `FileContextStore.apply_updates()` records observations and learnings, mirror them into the `NotebookStore` as Notes. + +**Architecture:** Pure converters (`observation_to_note`, `learning_to_note`) in `notebook.py`; a lazy `FileContextStore.notebook` property rooted at `agent_dir/notebook`; a guarded mirror step at the end of `apply_updates`. Builds on the foundation plan (`2026-06-16-notebook-foundation.md`). Backend-only, no UI/agent-loop changes. Transitional dual-write (legacy YAML + notebook) — legacy silos retire in a later increment. + +**Tech Stack:** Python 3.11, dataclasses, PyYAML, pytest (`file_context_store` fixture in `tests/conftest.py`). + +**Out of scope:** wiring the live loop to *call* `apply_updates` (separate increment); read API + Notebook tab; mapping expectations/watchpoints (they're working memory, not notebook entries — see design doc §2). + +--- + +### Task 1: Converters — Observation/Learning → Note + +**Files:** +- Modify: `gently/harness/memory/notebook.py` +- Test: `tests/test_notebook_store.py` (append) + +- [ ] **Step 1: Write the failing test** + +```python +# append to tests/test_notebook_store.py +from datetime import datetime as _dt + +from gently.harness.memory.model import Learning, Observation +from gently.harness.memory.notebook import learning_to_note, observation_to_note + + +class TestConverters: + def test_observation_to_note(self): + obs = Observation( + id="o1", timestamp=_dt(2026, 6, 16, 9, 0, 0), type="milestone", + content="nerve ring formed", embryo_id="e1", session_id="s1", + relates_to=["o0"], gently_refs={"kind": "projection", "t": 42}, + ) + n = observation_to_note(obs) + assert n.id == "o1" + assert n.kind == NoteKind.OBSERVATION + assert n.body == "nerve ring formed" + assert n.author == Author.AGENT + assert n.embryos == ["e1"] + assert n.sessions == ["s1"] + assert {"rel": "relates_to", "to": "o0"} in n.links + assert n.artifacts == [{"kind": "projection", "t": 42}] + assert n.created_at == _dt(2026, 6, 16, 9, 0, 0) + + def test_learning_to_note(self): + lrn = Learning(id="l1", content="rings form by comma", confidence=Confidence.HIGH) + n = learning_to_note(lrn) + assert n.id == "l1" + assert n.kind == NoteKind.FINDING + assert n.body == "rings form by comma" + assert n.status == NoteStatus.PROPOSED # agent-drafted, awaits confirm + assert n.confidence == Confidence.HIGH +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `python -m pytest tests/test_notebook_store.py::TestConverters -v` +Expected: FAIL — `ImportError: cannot import name 'observation_to_note'` + +- [ ] **Step 3: Write minimal implementation** + +Add the model imports and converters to `notebook.py`. Extend the existing model import line: + +```python +from .model import Confidence, Learning, Observation +``` + +Append at end of `notebook.py` (module-level functions, after `note_from_dict`): + +```python +def observation_to_note(obs: Observation) -> Note: + """Bridge a legacy Observation into a notebook Note (kind=observation).""" + return Note( + id=obs.id, + kind=NoteKind.OBSERVATION, + body=obs.content, + author=Author.AGENT, + embryos=[obs.embryo_id] if obs.embryo_id else [], + sessions=[obs.session_id] if obs.session_id else [], + links=[{"rel": "relates_to", "to": r} for r in (obs.relates_to or [])], + artifacts=[obs.gently_refs] if obs.gently_refs else [], + created_at=obs.timestamp, + updated_at=obs.timestamp, + ) + + +def learning_to_note(learning: Learning) -> Note: + """Bridge a legacy Learning into a notebook Note (kind=finding, proposed).""" + return Note( + id=learning.id, + kind=NoteKind.FINDING, + body=learning.content, + author=Author.AGENT, + status=NoteStatus.PROPOSED, + confidence=learning.confidence, + created_at=learning.created_at, + updated_at=learning.created_at, + ) +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `python -m pytest tests/test_notebook_store.py::TestConverters -v` +Expected: PASS (2 passed) + +- [ ] **Step 5: Commit** + +```bash +git add gently/harness/memory/notebook.py tests/test_notebook_store.py +git commit -m "feat(notebook): Observation/Learning -> Note converters" +``` + +--- + +### Task 2: `FileContextStore.notebook` property + +**Files:** +- Modify: `gently/harness/memory/file_store.py` (add property near `apply_updates`) +- Test: `tests/test_notebook_store.py` (append) + +- [ ] **Step 1: Write the failing test** + +```python +# append to tests/test_notebook_store.py +class TestContextStoreNotebook: + def test_notebook_property_rooted_under_agent_dir(self, file_context_store): + nb = file_context_store.notebook + assert nb.root == file_context_store.agent_dir / "notebook" + + def test_notebook_property_is_cached(self, file_context_store): + assert file_context_store.notebook is file_context_store.notebook +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `python -m pytest tests/test_notebook_store.py::TestContextStoreNotebook -v` +Expected: FAIL — `AttributeError: 'FileContextStore' object has no attribute 'notebook'` + +- [ ] **Step 3: Write minimal implementation** + +In `gently/harness/memory/file_store.py`, add this property immediately **before** `def apply_updates(self, updates: ContextUpdates):` (line ~2178): + +```python + @property + def notebook(self): + """The shared lab notebook, rooted at agent_dir/notebook (lazy).""" + nb = getattr(self, "_notebook", None) + if nb is None: + from .notebook import NotebookStore + nb = NotebookStore(self.agent_dir / "notebook") + self._notebook = nb + return nb + +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `python -m pytest tests/test_notebook_store.py::TestContextStoreNotebook -v` +Expected: PASS (2 passed) + +- [ ] **Step 5: Commit** + +```bash +git add gently/harness/memory/file_store.py tests/test_notebook_store.py +git commit -m "feat(notebook): FileContextStore.notebook lazy property" +``` + +--- + +### Task 3: Mirror observations & learnings in `apply_updates` + +**Files:** +- Modify: `gently/harness/memory/file_store.py` (`apply_updates`) +- Test: `tests/test_notebook_store.py` (append) + +- [ ] **Step 1: Write the failing test** + +```python +# append to tests/test_notebook_store.py +class TestApplyUpdatesMirror: + def test_apply_updates_mirrors_observations_and_learnings(self, file_context_store): + from gently.harness.memory.model import ContextUpdates + + cs = file_context_store + obs = Observation(id="o1", timestamp=_dt(2026, 6, 16, 9, 0, 0), + type="milestone", content="ring formed", embryo_id="e1") + lrn = Learning(id="l1", content="rings form by comma", confidence=Confidence.HIGH) + cs.apply_updates(ContextUpdates(new_observations=[obs], new_learnings=[lrn])) + + bodies = {n.body for n in cs.notebook.query_notes()} + assert "ring formed" in bodies + assert "rings form by comma" in bodies + assert cs.notebook.ids_for_embryo("e1") == ["o1"] + + def test_apply_updates_empty_is_noop_for_notebook(self, file_context_store): + from gently.harness.memory.model import ContextUpdates + + cs = file_context_store + cs.apply_updates(ContextUpdates()) + assert cs.notebook.query_notes() == [] +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `python -m pytest tests/test_notebook_store.py::TestApplyUpdatesMirror -v` +Expected: FAIL — `assert "ring formed" in set()` (notebook not populated yet) + +- [ ] **Step 3: Write minimal implementation** + +In `gently/harness/memory/file_store.py`, at the END of `apply_updates` (after the `if updates.new_focus is not None:` block), append: + +```python + # Mirror new observations & learnings into the shared notebook + # (best-effort — a notebook failure never breaks the legacy write). + from .notebook import learning_to_note, observation_to_note + + try: + for obs in updates.new_observations: + self.notebook.write_note(observation_to_note(obs)) + for learning in updates.new_learnings: + self.notebook.write_note(learning_to_note(learning)) + except Exception: + logger.warning("notebook mirror failed", exc_info=True) +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `python -m pytest tests/test_notebook_store.py::TestApplyUpdatesMirror -v` +Expected: PASS (2 passed) + +- [ ] **Step 5: Run full notebook suite + commit** + +Run: `python -m pytest tests/test_notebook_store.py -q` +Expected: all pass + +```bash +git add gently/harness/memory/file_store.py tests/test_notebook_store.py +git commit -m "feat(notebook): apply_updates mirrors observations & learnings into notebook" +``` + +--- + +## Self-Review + +**Spec coverage:** Producer wiring (design doc increment 1b) — `apply_updates` now populates the notebook. ✓ Converters honor the model (Observation→observation note, Learning→finding/proposed). ✓ Working-memory types (expectation/watchpoint) intentionally not mirrored (design §2). ✓ +**Placeholder scan:** none; complete code + commands throughout. ✓ +**Type consistency:** `observation_to_note`/`learning_to_note`, `FileContextStore.notebook`, `NoteKind`/`Author`/`NoteStatus`/`Confidence` match the foundation module and `model.py` (`Observation`, `Learning`, `ContextUpdates` confirmed at `file_store.py:2178-2203`). ✓ diff --git a/docs/superpowers/plans/2026-06-16-notebook-read-api.md b/docs/superpowers/plans/2026-06-16-notebook-read-api.md new file mode 100644 index 00000000..f4a5f20d --- /dev/null +++ b/docs/superpowers/plans/2026-06-16-notebook-read-api.md @@ -0,0 +1,265 @@ +# Notebook Read API Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans (or subagent-driven-development) to implement task-by-task. Steps use checkbox (`- [ ]`) syntax. + +**Goal:** Expose the shared notebook over HTTP so the frontend can read it — list/filter notes, fetch one note, and list inquiry-threads with counts. + +**Architecture:** A new route module `gently/ui/web/routes/notebook.py` exposing `create_router(server)` (the established pattern), reading `server.context_store.notebook` (the `NotebookStore` added in the producer-bridge plan) and serializing via `note_to_dict`. Registered in `routes/__init__.py`. Read-only; authoring/curation is a later increment. + +**Tech Stack:** FastAPI `APIRouter`, pytest + `fastapi.testclient.TestClient`, the `file_context_store` fixture (`tests/conftest.py`). + +**Out of scope:** the Notebook tab UI (next plan, browser-verified); the Agent's-View rewire; retrieval/embeddings. + +--- + +### Task 1: Route module — list & get notes + +**Files:** +- Create: `gently/ui/web/routes/notebook.py` +- Modify: `gently/ui/web/routes/__init__.py` +- Test: `tests/test_notebook_api.py` + +- [ ] **Step 1: Write the failing test** + +```python +# tests/test_notebook_api.py +"""Tests for the notebook read API.""" + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from gently.harness.memory.notebook import Note, NoteKind, NoteStatus + + +def _make_app(context_store): + from gently.ui.web.routes.notebook import create_router + + app = FastAPI() + + class _Server: + pass + + server = _Server() + server.context_store = context_store + app.include_router(create_router(server)) + return app + + +def _seed(cs): + nb = cs.notebook + nb.write_note(Note(id="o1", kind=NoteKind.OBSERVATION, body="ring formed", strains=["N2"])) + nb.write_note(Note(id="f1", kind=NoteKind.FINDING, body="rings by comma", + status=NoteStatus.PROPOSED, strains=["N2"], threads=["t1"])) + return cs + + +class TestListNotes: + def test_no_store_available_false(self): + client = TestClient(_make_app(None)) + data = client.get("/api/notebook/notes").json() + assert data == {"available": False, "notes": []} + + def test_list_all(self, file_context_store): + client = TestClient(_make_app(_seed(file_context_store))) + data = client.get("/api/notebook/notes").json() + assert data["available"] is True + assert {n["id"] for n in data["notes"]} == {"o1", "f1"} + + def test_filter_by_kind(self, file_context_store): + client = TestClient(_make_app(_seed(file_context_store))) + data = client.get("/api/notebook/notes?kind=finding").json() + assert {n["id"] for n in data["notes"]} == {"f1"} + + def test_filter_by_strain(self, file_context_store): + client = TestClient(_make_app(_seed(file_context_store))) + data = client.get("/api/notebook/notes?strain=N2").json() + assert {n["id"] for n in data["notes"]} == {"o1", "f1"} + + def test_invalid_kind_is_ignored(self, file_context_store): + client = TestClient(_make_app(_seed(file_context_store))) + data = client.get("/api/notebook/notes?kind=bogus").json() + assert {n["id"] for n in data["notes"]} == {"o1", "f1"} + + +class TestGetNote: + def test_get_existing(self, file_context_store): + client = TestClient(_make_app(_seed(file_context_store))) + data = client.get("/api/notebook/notes/o1").json() + assert data["id"] == "o1" + assert data["body"] == "ring formed" + + def test_get_missing_404(self, file_context_store): + client = TestClient(_make_app(_seed(file_context_store))) + resp = client.get("/api/notebook/notes/nope") + assert resp.status_code == 404 +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `python -m pytest tests/test_notebook_api.py -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'gently.ui.web.routes.notebook'` + +- [ ] **Step 3: Write minimal implementation** + +Create `gently/ui/web/routes/notebook.py`: + +```python +"""Notebook (shared lab notebook) read routes. + +Exposes the notebook's Notes for the Notebook tab + Agent's-View live edge. +Read-only here; authoring/curation come in a later increment. +""" + +from fastapi import APIRouter, HTTPException + +from gently.harness.memory.notebook import Author, NoteKind, NoteStatus, note_to_dict + + +def _coerce(enum_cls, value): + """Parse a query-param string into an enum; invalid/None → None (no filter).""" + if value is None: + return None + try: + return enum_cls(value) + except ValueError: + return None + + +def create_router(server) -> APIRouter: + router = APIRouter() + + def _nb(): + cs = getattr(server, "context_store", None) + return cs.notebook if cs is not None else None + + @router.get("/api/notebook/notes") + async def list_notes( + kind: str | None = None, + author: str | None = None, + status: str | None = None, + strain: str | None = None, + embryo: str | None = None, + thread: str | None = None, + ): + nb = _nb() + if nb is None: + return {"available": False, "notes": []} + notes = nb.query_notes( + kind=_coerce(NoteKind, kind), + author=_coerce(Author, author), + status=_coerce(NoteStatus, status), + strain=strain, + embryo=embryo, + thread=thread, + ) + return {"available": True, "notes": [note_to_dict(n) for n in notes]} + + @router.get("/api/notebook/notes/{note_id}") + async def get_note(note_id: str): + nb = _nb() + if nb is None: + raise HTTPException(status_code=404, detail="notebook unavailable") + note = nb.get_note(note_id) + if note is None: + raise HTTPException(status_code=404, detail="note not found") + return note_to_dict(note) + + return router +``` + +Then register it in `gently/ui/web/routes/__init__.py`. Add the import after the `images` import line: + +```python +from .notebook import create_router as create_notebook_router +``` + +And add `create_notebook_router,` to the factory tuple in `register_all_routes` (after `create_context_router,`): + +```python + create_context_router, + create_notebook_router, + ): +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `python -m pytest tests/test_notebook_api.py -v` +Expected: PASS (7 passed) + +- [ ] **Step 5: Commit** + +```bash +git add gently/ui/web/routes/notebook.py gently/ui/web/routes/__init__.py tests/test_notebook_api.py +git commit -m "feat(notebook): read API — GET /api/notebook/notes + /notes/{id}" +``` + +--- + +### Task 2: `GET /api/notebook/threads` + +**Files:** +- Modify: `gently/ui/web/routes/notebook.py` +- Test: `tests/test_notebook_api.py` (append) + +- [ ] **Step 1: Write the failing test** + +```python +# append to tests/test_notebook_api.py +class TestThreads: + def test_no_store(self): + client = TestClient(_make_app(None)) + assert client.get("/api/notebook/threads").json() == {"available": False, "threads": []} + + def test_thread_counts(self, file_context_store): + cs = file_context_store + nb = cs.notebook + nb.write_note(Note(id="a", kind=NoteKind.QUESTION, body="q", threads=["t1"])) + nb.write_note(Note(id="b", kind=NoteKind.FINDING, body="f", threads=["t1", "t2"])) + client = TestClient(_make_app(cs)) + data = client.get("/api/notebook/threads").json() + assert data["available"] is True + assert data["threads"] == [{"id": "t1", "count": 2}, {"id": "t2", "count": 1}] +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `python -m pytest tests/test_notebook_api.py::TestThreads -v` +Expected: FAIL — 404 (route not defined) + +- [ ] **Step 3: Write minimal implementation** + +Add this endpoint inside `create_router`, just before `return router`: + +```python + @router.get("/api/notebook/threads") + async def list_threads(): + nb = _nb() + if nb is None: + return {"available": False, "threads": []} + counts: dict[str, int] = {} + for n in nb.query_notes(): + for t in n.threads: + counts[t] = counts.get(t, 0) + 1 + threads = [{"id": t, "count": c} for t, c in sorted(counts.items())] + return {"available": True, "threads": threads} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `python -m pytest tests/test_notebook_api.py -q` +Expected: all pass + +- [ ] **Step 5: Commit** + +```bash +git add gently/ui/web/routes/notebook.py tests/test_notebook_api.py +git commit -m "feat(notebook): read API — GET /api/notebook/threads with counts" +``` + +--- + +## Self-Review + +**Spec coverage:** read surface API for the notebook (design increment 1c, backend half) — query notes by kind/author/status/scope, fetch one, list threads. ✓ Reuses `NotebookStore.query_notes` + `note_to_dict` from the foundation. ✓ Follows `create_router(server)` + `server.context_store` convention (`context.py`). ✓ +**Placeholder scan:** none — complete code + commands. ✓ +**Type consistency:** `create_router`, `note_to_dict`, `NoteKind`/`Author`/`NoteStatus`, `server.context_store.notebook`, `nb.query_notes`/`get_note` all match the foundation + producer-bridge modules. Registration matches the existing tuple in `routes/__init__.py`. ✓ diff --git a/docs/superpowers/plans/2026-06-17-notebook-ask.md b/docs/superpowers/plans/2026-06-17-notebook-ask.md new file mode 100644 index 00000000..c03ea526 --- /dev/null +++ b/docs/superpowers/plans/2026-06-17-notebook-ask.md @@ -0,0 +1,408 @@ +# "Ask the Notebook" Implementation Plan (Increment 2, backend) + +> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:executing-plans. Steps use checkbox (`- [ ]`) syntax. + +**Goal:** Let the notebook be *reasoned with* — given a question (+ optional scope), retrieve relevant Notes, ask Claude to synthesize a **grounded, cited** answer, and return it as a validated structured object. + +**Architecture:** A new module `gently/harness/memory/notebook_ask.py`: structural retrieval (`select_notes`) + a forced-`tool_choice` synthesis call (`answer_question`) that reuses gently's conventions — `anthropic.AsyncAnthropic` (per `chat.py:198`), `settings.models.main` (Opus 4.8), structured output via a pinned tool (per `verifier.py`), and **no self-rated confidence** (per the lab rule). A `POST /api/notebook/ask` route wires retrieval → synthesis. The Claude client is injected so everything is unit-testable with a fake. + +**Tech Stack:** Python 3.11, `anthropic` SDK, FastAPI, pytest + TestClient (venv). + +**Out of scope (later plans):** embeddings/semantic recall (structural-only here); the "Ask" UI box on the Notebook tab; proactive surfacing. + +--- + +### Task 1: Structural retrieval — `select_notes` + +**Files:** +- Create: `gently/harness/memory/notebook_ask.py` +- Test: `tests/test_notebook_ask.py` + +- [ ] **Step 1: Write the failing test** + +```python +# tests/test_notebook_ask.py +"""Tests for 'Ask the notebook' — retrieval + grounded synthesis.""" + +from gently.harness.memory.notebook import Note, NoteKind +from gently.harness.memory.notebook_ask import select_notes + + +def _seed(cs): + nb = cs.notebook + nb.write_note(Note(id="o1", kind=NoteKind.OBSERVATION, body="ring formed", strains=["N2"], threads=["t1"])) + nb.write_note(Note(id="f1", kind=NoteKind.FINDING, body="12 min/degC", strains=["N2"], threads=["t1"])) + nb.write_note(Note(id="x1", kind=NoteKind.OBSERVATION, body="unrelated", strains=["OH904"])) + return nb + + +class TestSelectNotes: + def test_scope_by_thread(self, file_context_store): + nb = _seed(file_context_store) + ids = {n.id for n in select_notes(nb, thread="t1")} + assert ids == {"o1", "f1"} + + def test_scope_by_strain(self, file_context_store): + nb = _seed(file_context_store) + ids = {n.id for n in select_notes(nb, strain="OH904")} + assert ids == {"x1"} + + def test_no_scope_returns_recent_capped(self, file_context_store): + nb = _seed(file_context_store) + notes = select_notes(nb, limit=2) + assert len(notes) == 2 # newest-first, capped +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `.venv/bin/python -m pytest tests/test_notebook_ask.py::TestSelectNotes -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'gently.harness.memory.notebook_ask'` + +- [ ] **Step 3: Write minimal implementation** + +```python +# gently/harness/memory/notebook_ask.py +"""Ask the notebook — retrieve relevant Notes and synthesize a grounded, +cited answer with Claude. Structural retrieval only (semantic recall is a +later increment). See docs/superpowers/specs/2026-06-16-shared-lab-notebook-design.md §4. +""" + +from __future__ import annotations + +from .notebook import Note, NotebookStore + + +def select_notes( + store: NotebookStore, + *, + thread: str | None = None, + strain: str | None = None, + limit: int = 12, +) -> list[Note]: + """Structural narrowing: scope by thread/strain when given, else recent. + Returns newest-first, capped at `limit`.""" + notes = store.query_notes(thread=thread, strain=strain) + return notes[:limit] +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `.venv/bin/python -m pytest tests/test_notebook_ask.py::TestSelectNotes -v` +Expected: PASS (3 passed) + +- [ ] **Step 5: Commit** + +```bash +git add gently/harness/memory/notebook_ask.py tests/test_notebook_ask.py +git commit -m "feat(notebook): select_notes — structural retrieval for ask" +``` + +--- + +### Task 2: Grounded synthesis — `answer_question` (forced tool) + +**Files:** +- Modify: `gently/harness/memory/notebook_ask.py` +- Test: `tests/test_notebook_ask.py` (append) + +- [ ] **Step 1: Write the failing test** + +```python +# append to tests/test_notebook_ask.py +import asyncio + +from gently.harness.memory.notebook_ask import ASK_TOOL, answer_question, build_ask_messages + + +class _FakeBlock: + def __init__(self, inp): + self.type = "tool_use" + self.input = inp + + +class _FakeResp: + def __init__(self, inp): + self.content = [_FakeBlock(inp)] + self.stop_reason = "tool_use" + + +class _FakeMessages: + def __init__(self, captured, inp): + self._captured, self._inp = captured, inp + + async def create(self, **kwargs): + self._captured.update(kwargs) + return _FakeResp(self._inp) + + +class _FakeClient: + def __init__(self, inp): + self.captured = {} + self.messages = _FakeMessages(self.captured, inp) + + +class TestAnswerQuestion: + def test_build_messages_embeds_note_ids(self): + notes = [Note(id="o1", kind=NoteKind.OBSERVATION, body="ring formed")] + msgs = build_ask_messages("what formed?", notes) + text = msgs[0]["content"] + assert "o1" in text and "ring formed" in text and "what formed?" in text + + def test_answer_returns_structured_and_forces_tool(self): + canned = {"answer": "A ring formed.", "points": [{"text": "ring formed", "note_ids": ["o1"]}], + "suggested_next": [], "coverage": "covered"} + client = _FakeClient(canned) + notes = [Note(id="o1", kind=NoteKind.OBSERVATION, body="ring formed")] + out = asyncio.run(answer_question(client, "m", "what formed?", notes)) + assert out == canned + # tool_choice is pinned to the ask tool (forced structured output) + assert client.captured["tool_choice"] == {"type": "tool", "name": ASK_TOOL["name"]} + assert client.captured["model"] == "m" + + def test_answer_no_notes_short_circuits_without_api(self): + client = _FakeClient({"should": "not be used"}) + out = asyncio.run(answer_question(client, "m", "anything?", [])) + assert out["coverage"] == "not_in_notebook" + assert client.captured == {} # no API call when nothing to ground on +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `.venv/bin/python -m pytest tests/test_notebook_ask.py::TestAnswerQuestion -v` +Expected: FAIL — `ImportError: cannot import name 'ASK_TOOL'` + +- [ ] **Step 3: Write minimal implementation** + +Append to `gently/harness/memory/notebook_ask.py`: + +```python +# ASK_TOOL pins the structured output. No confidence field — we don't ask the +# model to self-rate (lab rule); "coverage" is a factual grounding classification. +ASK_TOOL = { + "name": "answer_from_notebook", + "description": "Return a grounded answer built ONLY from the provided notebook entries.", + "input_schema": { + "type": "object", + "properties": { + "answer": {"type": "string", "description": "Direct synthesis grounded in the notes."}, + "points": { + "type": "array", + "description": "Supporting points, each citing the note ids it rests on.", + "items": { + "type": "object", + "properties": { + "text": {"type": "string"}, + "note_ids": {"type": "array", "items": {"type": "string"}}, + }, + "required": ["text", "note_ids"], + }, + }, + "suggested_next": { + "type": "array", + "items": {"type": "string"}, + "description": "Concrete next experiments/moves if the question asks what to do; else empty.", + }, + "coverage": { + "type": "string", + "enum": ["covered", "partial", "not_in_notebook"], + "description": "How well the provided notes cover the question.", + }, + }, + "required": ["answer", "points", "coverage"], + }, +} + +_SYSTEM = ( + "You reason over a shared lab notebook. Answer ONLY from the notebook entries " + "provided — every claim must cite the note id(s) it rests on. If the notes do " + "not contain the answer, say so plainly and set coverage to 'not_in_notebook'. " + "Never invent facts not in the notes. Call the answer_from_notebook tool." +) + + +def _render_notes(notes: list[Note]) -> str: + lines = [] + for n in notes: + scope = [] + if n.strains: + scope.append("strains=" + ",".join(n.strains)) + if n.embryos: + scope.append("embryos=" + ",".join(n.embryos)) + tag = f" [{'; '.join(scope)}]" if scope else "" + lines.append(f"[{n.id}] ({n.kind.value}){tag} {n.body}") + return "\n".join(lines) + + +def build_ask_messages(question: str, notes: list[Note]) -> list[dict]: + body = ( + "Notebook entries:\n" + + _render_notes(notes) + + f"\n\nQuestion: {question}\n\n" + "Answer using only these entries, citing note ids." + ) + return [{"role": "user", "content": body}] + + +async def answer_question(client, model: str, question: str, notes: list[Note]) -> dict: + """Force the ask tool and return its validated input dict. Short-circuits + (no API call) when there are no notes to ground on.""" + if not notes: + return { + "answer": "The notebook doesn't cover this yet.", + "points": [], + "suggested_next": [], + "coverage": "not_in_notebook", + } + resp = await client.messages.create( + model=model, + max_tokens=2048, + system=_SYSTEM, + tools=[ASK_TOOL], + tool_choice={"type": "tool", "name": ASK_TOOL["name"]}, + messages=build_ask_messages(question, notes), + ) + for block in resp.content: + if getattr(block, "type", None) == "tool_use": + return block.input + return {"answer": "", "points": [], "suggested_next": [], "coverage": "not_in_notebook"} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `.venv/bin/python -m pytest tests/test_notebook_ask.py -q` +Expected: all pass + +- [ ] **Step 5: Commit** + +```bash +git add gently/harness/memory/notebook_ask.py tests/test_notebook_ask.py +git commit -m "feat(notebook): answer_question — forced-tool grounded synthesis" +``` + +--- + +### Task 3: `POST /api/notebook/ask` + +**Files:** +- Modify: `gently/ui/web/routes/notebook.py` +- Test: `tests/test_notebook_api.py` (append) + +- [ ] **Step 1: Write the failing test** + +```python +# append to tests/test_notebook_api.py +class _AskBlock: + def __init__(self, inp): + self.type = "tool_use" + self.input = inp + + +class _AskResp: + def __init__(self, inp): + self.content = [_AskBlock(inp)] + self.stop_reason = "tool_use" + + +class _AskMessages: + def __init__(self, inp): + self._inp = inp + + async def create(self, **kwargs): + return _AskResp(self._inp) + + +class _AskClient: + def __init__(self, inp): + self.messages = _AskMessages(inp) + + +def _make_app_with_client(context_store, client): + from gently.ui.web.routes.notebook import create_router + + app = FastAPI() + + class _Server: + pass + + server = _Server() + server.context_store = context_store + server.claude_async = client + app.include_router(create_router(server)) + return app + + +class TestAsk: + def test_ask_returns_grounded_answer(self, file_context_store): + cs = _seed(file_context_store) + canned = {"answer": "A ring formed.", "points": [{"text": "ring", "note_ids": ["o1"]}], + "suggested_next": [], "coverage": "covered"} + client = TestClient(_make_app_with_client(cs, _AskClient(canned))) + resp = client.post("/api/notebook/ask", json={"question": "what happened?"}) + assert resp.status_code == 200 + assert resp.json()["coverage"] == "covered" + + def test_ask_no_store(self): + client = TestClient(_make_app(None)) + resp = client.post("/api/notebook/ask", json={"question": "x"}) + assert resp.json() == {"available": False} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `.venv/bin/python -m pytest tests/test_notebook_api.py::TestAsk -v` +Expected: FAIL — 404/405 (route not defined) + +- [ ] **Step 3: Write minimal implementation** + +In `gently/ui/web/routes/notebook.py`, add `Body` to the fastapi import and add the route inside `create_router`, before `return router`: + +```python + @router.post("/api/notebook/ask") + async def ask( + question: str = Body(..., embed=True), + thread: str | None = Body(None, embed=True), + strain: str | None = Body(None, embed=True), + ): + nb = _nb() + if nb is None: + return {"available": False} + from gently.harness.memory.notebook_ask import answer_question, select_notes + from gently.settings import settings + + notes = select_notes(nb, thread=thread, strain=strain) + client = getattr(server, "claude_async", None) + if client is None: + import anthropic + + client = anthropic.AsyncAnthropic() + result = await answer_question(client, settings.models.main, question, notes) + result["available"] = True + result["note_ids"] = [n.id for n in notes] + return result +``` + +Update the import line at the top of the file: + +```python +from fastapi import APIRouter, Body, HTTPException +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `.venv/bin/python -m pytest tests/test_notebook_api.py -q` +Expected: all pass + +- [ ] **Step 5: Commit** + +```bash +git add gently/ui/web/routes/notebook.py tests/test_notebook_api.py +git commit -m "feat(notebook): POST /api/notebook/ask — grounded notebook Q&A" +``` + +--- + +## Self-Review +**Spec coverage (design §4):** structural retrieval (`select_notes`) → grounded synthesis (`answer_question`, forced tool, cited, "not_in_notebook" valid) → endpoint. ✓ No self-rated confidence (`coverage` is grounding, not correctness-confidence). ✓ Reuses `settings.models.main`, `anthropic.AsyncAnthropic`, the `verifier.py` forced-tool pattern. ✓ Client injected → unit-testable without real API. ✓ +**Deferred (correct):** embeddings/semantic recall; the "Ask" UI; proactive surfacing. +**Placeholder scan:** none — complete code + commands. ✓ +**Type consistency:** `select_notes`, `answer_question`, `ASK_TOOL`, `build_ask_messages` named consistently across tasks/tests; route uses `server.claude_async` (tests inject) with a real `AsyncAnthropic` fallback. ✓ diff --git a/docs/superpowers/plans/2026-06-27-temperature-interface.md b/docs/superpowers/plans/2026-06-27-temperature-interface.md new file mode 100644 index 00000000..cfdaa563 --- /dev/null +++ b/docs/superpowers/plans/2026-06-27-temperature-interface.md @@ -0,0 +1,793 @@ +# Temperature Interface Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Persist live temperature into each imaging session and chart it, so bursts/volumes are correlatable to temperature and the rise/fall trajectory is visible during the temperature-strain experiments. + +**Architecture:** A `FileStore`-backed append-only `temperature.jsonl` per session (mirroring `predictions.jsonl`); a `TemperatureSampler(Service)` in the agent process — modeled on `DeviceStateMonitor` — that, while a session is active, polls the device layer at 1 Hz, appends each reading, holds the latest in memory, and publishes a `TEMPERATURE_UPDATE` event that the viz server already forwards to the browser; acquisition code stamps the latest reading into volume/burst metadata; a FastAPI history route backfills the graph; a hand-rolled SVG component renders water-temp + stepped-setpoint as a card on the Devices tab. + +**Tech Stack:** Python 3 + asyncio, FastAPI, the project's `EventBus`, `FileStore` (file-based YAML/JSONL), vanilla-JS + hand-rolled SVG frontend (no build step), pytest with `asyncio_mode = "auto"`. + +## Global Constraints + +- **No new dependency** — chart is hand-rolled SVG (`createElementNS`), matching `experiment-overview.js`. No charting library. +- **Session-scoped capture only** — sampler persists/emits **only while a session is active**; no always-on facility daemon. +- **Sample schema** (one JSONL line): `{"t": , "water_c": , "setpoint_c": , "state": }`. +- **Append pattern**: reuse `FileStore._append_jsonl` (append mode, `json.dumps(..., default=str)`, trailing `\n`). Meta written via existing `_write_yaml` / `yaml.safe_dump`. +- **Defaults (flippable):** 1 Hz sampling; stamp the **latest sample** (no fresh blocking read) at acquisition. +- **Robustness:** a failed poll = a gap, logged, loop continues; a sampler error never crashes the session. Empty state, **never mock data**. +- **Tests:** `pytest`; `async def test_*` needs no decorator (auto mode); use the `file_store` fixture (`tests/conftest.py:38-45`, `FileStore(tmp_path/...)`). The frontend has **no JS unit harness** — verify it by running the app + Chrome DevTools MCP. +- **Env:** production runs pip + `requirements*.txt` (no `uv`); we add no deps, so nothing to declare. + +--- + +### Task 1: Temperature log store (FileStore methods) + +**Files:** +- Modify: `gently/core/file_store.py` (add two methods on `FileStore`; reuse module-level `_append_jsonl` at `:197-201` and `_read_jsonl` at `:204-215`, and `_session_dir`/`_require_session_dir` at `:255-269`) +- Test: `tests/test_temperature_store.py` (new) + +**Interfaces:** +- Produces: + - `FileStore.append_temperature_sample(self, session_id: str, sample: dict) -> None` — appends one line to `sessions/{folder}/temperature.jsonl`. + - `FileStore.read_temperature_log(self, session_id: str, since: str | None = None) -> list[dict]` — returns samples; if `since` (an ISO-UTC string) is given, only samples with `r["t"] >= since` (lexicographic compare is valid for fixed-format UTC ISO). + +- [ ] **Step 1: Write the failing test** + +```python +# tests/test_temperature_store.py +def _new_session(file_store): + return file_store.create_session(name="temp-test") # returns session_id + +def test_append_and_read_roundtrip(file_store): + sid = _new_session(file_store) + file_store.append_temperature_sample(sid, {"t": "2026-06-27T10:00:00+00:00", "water_c": 28.0, "setpoint_c": 32.0, "state": "heating"}) + file_store.append_temperature_sample(sid, {"t": "2026-06-27T10:00:01+00:00", "water_c": 28.3, "setpoint_c": 32.0, "state": "heating"}) + rows = file_store.read_temperature_log(sid) + assert [r["water_c"] for r in rows] == [28.0, 28.3] + +def test_read_since_filters(file_store): + sid = _new_session(file_store) + for i, t in enumerate(["2026-06-27T10:00:00+00:00", "2026-06-27T10:00:01+00:00", "2026-06-27T10:00:02+00:00"]): + file_store.append_temperature_sample(sid, {"t": t, "water_c": 28.0 + i, "setpoint_c": 32.0, "state": "heating"}) + rows = file_store.read_temperature_log(sid, since="2026-06-27T10:00:01+00:00") + assert [r["water_c"] for r in rows] == [29.0, 30.0] + +def test_read_unknown_session_is_empty(file_store): + assert file_store.read_temperature_log("does-not-exist") == [] +``` + +> NOTE for implementer: confirm the exact session-creation API on `FileStore` (search for `def create_session`). If its signature differs, adjust `_new_session` accordingly — the rest of the test is unaffected. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pytest tests/test_temperature_store.py -v` +Expected: FAIL — `AttributeError: 'FileStore' object has no attribute 'append_temperature_sample'` + +- [ ] **Step 3: Write minimal implementation** + +Add to the `FileStore` class body in `gently/core/file_store.py` (near the other per-session jsonl helpers like `add_prediction`): + +```python +def append_temperature_sample(self, session_id: str, sample: dict) -> None: + """Append one temperature reading to the session's temperature.jsonl.""" + sd = self._require_session_dir(session_id) + _append_jsonl(sd / "temperature.jsonl", sample) + +def read_temperature_log(self, session_id: str, since: str | None = None) -> list[dict]: + """Return temperature samples for a session, optionally filtered to t >= since (ISO-UTC string).""" + sd = self._session_dir(session_id) + if sd is None: + return [] + rows = _read_jsonl(sd / "temperature.jsonl") + if since is not None: + rows = [r for r in rows if str(r.get("t", "")) >= since] + return rows +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pytest tests/test_temperature_store.py -v` +Expected: PASS (3 passed) + +- [ ] **Step 5: Commit** + +```bash +git add gently/core/file_store.py tests/test_temperature_store.py +git commit -m "feat(temperature): session-scoped temperature.jsonl store on FileStore" +``` + +--- + +### Task 2: `TEMPERATURE_UPDATE` event type + +**Files:** +- Modify: `gently/core/event_bus.py` (add enum member near `:86`; add to `_NO_HISTORY_TYPES` near `:187`) +- Test: `tests/test_temperature_event.py` (new) + +**Interfaces:** +- Produces: `EventType.TEMPERATURE_UPDATE` (a new enum member). High-volume → excluded from event history. + +- [ ] **Step 1: Write the failing test** + +```python +# tests/test_temperature_event.py +from gently.core.event_bus import EventType, EventBus + +def test_temperature_update_event_exists(): + assert EventType.TEMPERATURE_UPDATE.value == "TEMPERATURE_UPDATE" + +def test_temperature_update_publishes_to_subscriber(): + bus = EventBus() + seen = [] + bus.subscribe(EventType.TEMPERATURE_UPDATE, lambda e: seen.append(e.data)) + bus.publish(event_type=EventType.TEMPERATURE_UPDATE, data={"x": 1}, source="t") + assert seen == [{"x": 1}] +``` + +> NOTE: confirm `EventBus.subscribe` signature/usage from an existing test (`tests/` has event-bus usage); adjust the subscribe call if the project's API differs (e.g. `subscribe(event_type, handler)` vs `subscribe(handler, event_type)`). + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pytest tests/test_temperature_event.py -v` +Expected: FAIL — `AttributeError: TEMPERATURE_UPDATE` + +- [ ] **Step 3: Write minimal implementation** + +In `gently/core/event_bus.py`, add the member alongside the other `EventType` values, **matching the existing `auto()` style** (`DEVICE_STATE_UPDATE = auto()` at `:86`). The wire protocol serializes `event.event_type.name` (`Event.to_dict` at event_bus.py:232; server.py:377/419), so the browser receives the string `"TEMPERATURE_UPDATE"` regardless — which is what the frontend (Task 7) subscribes to. The test must assert `.name == "TEMPERATURE_UPDATE"` (NOT `.value`, which is an `auto()` int): + +```python + TEMPERATURE_UPDATE = auto() # high-volume telemetry from the temperature controller +``` + +And add it to the high-volume set so it is not retained in history (next to `DEVICE_STATE_UPDATE` in `_NO_HISTORY_TYPES` near `:187`): + +```python + EventType.TEMPERATURE_UPDATE, +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pytest tests/test_temperature_event.py -v` +Expected: PASS (2 passed) + +- [ ] **Step 5: Commit** + +```bash +git add gently/core/event_bus.py tests/test_temperature_event.py +git commit -m "feat(temperature): add TEMPERATURE_UPDATE event type" +``` + +--- + +### Task 3: TemperatureSampler service + +**Files:** +- Create: `gently/app/temperature_sampler.py` +- Test: `tests/test_temperature_sampler.py` (new) +- Reference (template, do not modify): `gently/app/device_state_monitor.py`, `gently/core/service.py:63-172` + +**Interfaces:** +- Consumes: `EventType.TEMPERATURE_UPDATE` (Task 2); `FileStore.append_temperature_sample` (Task 1); a microscope client exposing `async get_temperature() -> dict` returning `{"success": bool, "temperature_c": float, "setpoint_c": float, "state": str, ...}` (`gently/hardware/dispim/client.py:837`). +- Produces: + - `TemperatureSampler(Service)` with `__init__(self, microscope, store, session_id_getter, interval_sec=1.0)`. + - `async on_start(self)` / `async on_stop(self)` (background asyncio loop, like `DeviceStateMonitor`). + - `async _tick(self, bus) -> None` — one poll/append/emit cycle (the unit under test). + - attribute `self.latest: dict | None` — most recent sample (for the acquisition stamp). + - module function `temperature_stamp(latest: dict | None) -> dict | None` — `{"water_c","setpoint_c","state","sampled_at"}` or `None`. + +- [ ] **Step 1: Write the failing test** + +```python +# tests/test_temperature_sampler.py +import asyncio +from gently.core.event_bus import EventBus, EventType +from gently.app.temperature_sampler import TemperatureSampler, temperature_stamp + + +class FakeScope: + def __init__(self, resp): + self.resp = resp + self.calls = 0 + async def get_temperature(self): + self.calls += 1 + if isinstance(self.resp, Exception): + raise self.resp + return self.resp + + +def _capture(bus): + seen = [] + bus.subscribe(EventType.TEMPERATURE_UPDATE, lambda e: seen.append(e.data)) + return seen + + +async def test_tick_appends_emits_and_sets_latest(file_store): + sid = file_store.create_session(name="s") + scope = FakeScope({"success": True, "temperature_c": 28.4, "setpoint_c": 32.0, "state": "heating"}) + bus = EventBus(); seen = _capture(bus) + s = TemperatureSampler(scope, file_store, lambda: sid) + await s._tick(bus) + rows = file_store.read_temperature_log(sid) + assert len(rows) == 1 and rows[0]["water_c"] == 28.4 + assert s.latest["water_c"] == 28.4 + assert seen and seen[0]["sample"]["water_c"] == 28.4 and seen[0]["session_id"] == sid + + +async def test_tick_no_active_session_is_noop(file_store): + scope = FakeScope({"success": True, "temperature_c": 1.0, "setpoint_c": 2.0, "state": "x"}) + bus = EventBus(); seen = _capture(bus) + s = TemperatureSampler(scope, file_store, lambda: None) + await s._tick(bus) + assert scope.calls == 0 and s.latest is None and seen == [] + + +async def test_tick_poll_failure_is_a_gap_not_a_crash(file_store): + sid = file_store.create_session(name="s") + scope = FakeScope(RuntimeError("device down")) + bus = EventBus() + s = TemperatureSampler(scope, file_store, lambda: sid) + # _run swallows; _tick raises — assert the loop-level guard swallows by calling _run-style guard: + try: + await s._tick(bus) + except RuntimeError: + pass # _tick may raise; the loop in _run catches it (see test below) + assert file_store.read_temperature_log(sid) == [] + + +def test_temperature_stamp_shapes(): + assert temperature_stamp(None) is None + assert temperature_stamp({"t": "2026-06-27T10:00:00+00:00", "water_c": 28.4, "setpoint_c": 32.0, "state": "heating"}) == { + "water_c": 28.4, "setpoint_c": 32.0, "state": "heating", "sampled_at": "2026-06-27T10:00:00+00:00", + } +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pytest tests/test_temperature_sampler.py -v` +Expected: FAIL — `ModuleNotFoundError: gently.app.temperature_sampler` + +- [ ] **Step 3: Write minimal implementation** + +```python +# gently/app/temperature_sampler.py +"""Session-scoped temperature sampler — polls the device layer, persists, emits. + +Modeled on gently/app/device_state_monitor.py. While a session is active it polls +the microscope's temperature at a fixed cadence, appends each reading to the +session's temperature.jsonl, holds the latest reading (for acquisition stamping), +and publishes TEMPERATURE_UPDATE for the live graph. A failed poll is a gap, not a +crash; with no active session the loop idles. +""" +from __future__ import annotations + +import asyncio +import logging +from datetime import datetime, timezone + +from gently.core.service import Service +from gently.core.event_bus import get_event_bus, EventType + +logger = logging.getLogger(__name__) + + +def _now_iso() -> str: + return datetime.now(timezone.utc).isoformat() + + +def temperature_stamp(latest: dict | None) -> dict | None: + """Build a temperature meta block from a latest sample, or None if unavailable.""" + if not latest: + return None + return { + "water_c": latest.get("water_c"), + "setpoint_c": latest.get("setpoint_c"), + "state": latest.get("state"), + "sampled_at": latest.get("t"), + } + + +class TemperatureSampler(Service): + def __init__(self, microscope, store, session_id_getter, interval_sec: float = 1.0): + super().__init__(name="temperature-sampler", service_type="monitor") + self._microscope = microscope + self._store = store + self._session_id_getter = session_id_getter + self._interval = interval_sec + self._task: asyncio.Task | None = None + self.latest: dict | None = None + + async def on_start(self) -> None: + self._task = asyncio.create_task(self._run(), name="temperature-sampler-loop") + + async def on_stop(self) -> None: + if self._task is not None: + self._task.cancel() + try: + await self._task + except asyncio.CancelledError: + pass + self._task = None + + async def _run(self) -> None: + bus = get_event_bus() + while True: + try: + await self._tick(bus) + except asyncio.CancelledError: + raise + except Exception as exc: # a gap, never a crash + logger.warning("temperature sampler tick failed: %s", exc) + await asyncio.sleep(self._interval) + + async def _tick(self, bus) -> None: + session_id = self._session_id_getter() + if not session_id: + return + resp = await self._microscope.get_temperature() + if not resp or not resp.get("success", True): + return + water = resp.get("temperature_c") + if water is None: + return + sample = { + "t": _now_iso(), + "water_c": water, + "setpoint_c": resp.get("setpoint_c"), + "state": resp.get("state"), + } + self._store.append_temperature_sample(session_id, sample) + self.latest = sample + bus.publish( + event_type=EventType.TEMPERATURE_UPDATE, + data={"session_id": session_id, "sample": sample}, + source="temperature-sampler", + ) +``` + +> Note the failure test: `_tick` propagates the poll exception, and `_run`'s `except Exception` swallows it. The test asserts the gap (no rows). If you prefer, also add a direct `_run`-guard test that starts the loop briefly and asserts it does not raise — optional. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pytest tests/test_temperature_sampler.py -v` +Expected: PASS (4 passed) + +- [ ] **Step 5: Commit** + +```bash +git add gently/app/temperature_sampler.py tests/test_temperature_sampler.py +git commit -m "feat(temperature): TemperatureSampler service (poll/persist/emit @1Hz)" +``` + +--- + +### Task 4: Wire the sampler into the agent lifecycle + +**Files:** +- Modify: `gently/app/agent.py` — init attribute (near `:212`), construct+start (near the `DeviceStateMonitor` block `:818-827` inside `start_viz_server`), stop (near `:850-855`). +- Test: `tests/test_temperature_sampler_wiring.py` (new) + +**Interfaces:** +- Consumes: `TemperatureSampler` (Task 3); the agent's microscope client (`self.microscope`), its `FileStore`, and `self.session_id`. +- Produces: `agent.temperature_sampler: TemperatureSampler | None` (the live instance, read by the acquisition stamp in Task 6). + +> IMPLEMENTER: confirm the agent's FileStore attribute name before writing the construction line. Search `gently/app/agent.py` for the `FileStore` it uses (likely `self.store`). Use that exact attribute. If the agent reaches the store indirectly, pass whatever object exposes `append_temperature_sample`. + +- [ ] **Step 1: Write the failing test** + +```python +# tests/test_temperature_sampler_wiring.py +from gently.app.temperature_sampler import TemperatureSampler + +def test_agent_initializes_temperature_sampler_attribute(): + # The attribute must exist (None until start_viz_server runs with a microscope). + import gently.app.agent as agent_mod + src = (agent_mod.__file__) + text = open(src, encoding="utf-8").read() + assert "temperature_sampler" in text + assert "TemperatureSampler(" in text +``` + +> This is a light wiring guard (a full agent boot is an integration concern). It fails until the wiring lines exist. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pytest tests/test_temperature_sampler_wiring.py -v` +Expected: FAIL — assertion error ("temperature_sampler" not in source) + +- [ ] **Step 3: Write minimal implementation** + +Near `:212` (with the other monitor attrs, e.g. `self.device_state_monitor = None`): + +```python + self.temperature_sampler = None +``` + +Inside `start_viz_server`, right after the `DeviceStateMonitor` start block (`:818-827`): + +```python + if self.microscope is not None and self.temperature_sampler is None: + from .temperature_sampler import TemperatureSampler + self.temperature_sampler = TemperatureSampler( + self.microscope, self.store, lambda: self.session_id + ) + await self.temperature_sampler.start() +``` + +In the symmetric shutdown path (`:850-855`, where `device_state_monitor.stop()` is awaited): + +```python + if self.temperature_sampler is not None: + await self.temperature_sampler.stop() + self.temperature_sampler = None +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pytest tests/test_temperature_sampler_wiring.py -v` +Expected: PASS (1 passed) + +Then full suite sanity: `pytest -q` — Expected: no new failures. + +- [ ] **Step 5: Commit** + +```bash +git add gently/app/agent.py tests/test_temperature_sampler_wiring.py +git commit -m "feat(temperature): start/stop TemperatureSampler with the agent" +``` + +--- + +### Task 5: History API route + +**Files:** +- Create: `gently/ui/web/routes/temperature.py` +- Modify: `gently/ui/web/routes/__init__.py` (import + add to the factories tuple in `register_all_routes`) +- Test: `tests/test_temperature_route.py` (new) +- Reference (template): `gently/ui/web/routes/experiments.py:1-66`, test template `tests/test_data_catalog.py:69-114` + +**Interfaces:** +- Consumes: `server.gently_store` (a `FileStore`) with `list_sessions()`, `_session_dir(id)`, and `read_temperature_log(id, since=)` (Task 1). +- Produces: `GET /api/temperature/{session_id}/history?since=` → `{"session_id": str, "samples": list[dict]}`; `session_id="current"` resolves to newest session. + +- [ ] **Step 1: Write the failing test** + +```python +# tests/test_temperature_route.py +from unittest.mock import MagicMock +from pathlib import Path +from fastapi import FastAPI +from fastapi.testclient import TestClient +from gently.ui.web.routes.temperature import create_router + + +def _server(samples, sessions=(("sess-1", True),)): + store = MagicMock() + store.list_sessions.return_value = [{"session_id": sid} for sid, _ in sessions] + store._session_dir.side_effect = lambda sid: Path("/x") if any(sid == s for s, _ in sessions) else None + store.read_temperature_log.return_value = samples + srv = MagicMock(); srv.gently_store = store + return srv, store + + +def _client(server): + app = FastAPI(); app.include_router(create_router(server)) + return TestClient(app) + + +def test_history_returns_samples(): + srv, store = _server([{"t": "2026-06-27T10:00:00+00:00", "water_c": 28.0, "setpoint_c": 32.0, "state": "heating"}]) + r = _client(srv).get("/api/temperature/sess-1/history") + assert r.status_code == 200 + body = r.json() + assert body["session_id"] == "sess-1" + assert body["samples"][0]["water_c"] == 28.0 + + +def test_history_passes_since_through(): + srv, store = _server([]) + _client(srv).get("/api/temperature/sess-1/history?since=2026-06-27T10:00:01+00:00") + store.read_temperature_log.assert_called_with("sess-1", since="2026-06-27T10:00:01+00:00") + + +def test_history_current_resolves_newest(): + srv, store = _server([], sessions=(("newest", True),)) + r = _client(srv).get("/api/temperature/current/history") + assert r.status_code == 200 + assert r.json()["session_id"] == "newest" + + +def test_history_unknown_session_404(): + srv, store = _server([], sessions=(("sess-1", True),)) + # _session_dir returns None for unknown -> 404 + store._session_dir.side_effect = lambda sid: None + r = _client(srv).get("/api/temperature/ghost/history") + assert r.status_code == 404 +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pytest tests/test_temperature_route.py -v` +Expected: FAIL — `ModuleNotFoundError: gently.ui.web.routes.temperature` + +- [ ] **Step 3: Write minimal implementation** + +```python +# gently/ui/web/routes/temperature.py +"""Read-only temperature history for the live graph (backfill on mount/reload). + +Live updates ride the TEMPERATURE_UPDATE event channel; this route is backfill only. +Mirrors routes/experiments.py session resolution. +""" +from fastapi import APIRouter, HTTPException + + +def create_router(server) -> APIRouter: + router = APIRouter() + + def _resolve_session(session_id: str): + store = getattr(server, "gently_store", None) + if store is None: + raise HTTPException(status_code=503, detail="FileStore not configured on viz server") + if session_id == "current": + sessions = store.list_sessions() + if not sessions: + raise HTTPException(status_code=404, detail="No sessions in store") + session_id = sessions[0].get("session_id") + if store._session_dir(session_id) is None: + raise HTTPException(status_code=404, detail=f"Session not found: {session_id}") + return session_id + + @router.get("/api/temperature/{session_id}/history") + async def get_history(session_id: str, since: str | None = None): + real_id = _resolve_session(session_id) + store = server.gently_store + samples = store.read_temperature_log(real_id, since=since) + return {"session_id": real_id, "samples": samples} + + return router +``` + +Register it in `gently/ui/web/routes/__init__.py` — add the import and append `create_router` to the factories iterated by `register_all_routes` (follow the existing pattern exactly; alias to avoid name clashes, e.g. `from .temperature import create_router as create_temperature_router`). + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pytest tests/test_temperature_route.py -v` +Expected: PASS (4 passed) + +- [ ] **Step 5: Commit** + +```bash +git add gently/ui/web/routes/temperature.py gently/ui/web/routes/__init__.py tests/test_temperature_route.py +git commit -m "feat(temperature): GET /api/temperature/{session}/history backfill route" +``` + +--- + +### Task 6: Acquisition temperature stamp (burst + volume) + +**Files:** +- Modify: `gently/app/orchestration/exclusive.py` — `_persist_burst_to_disk` (per-frame `metadata` at `:387-407`, `burst.yaml` manifest dict at `:423-442`); the `BurstAcquisition`/`ExclusiveAcquisition` construction to receive a temperature provider. +- Modify: the volume acquisition call site that builds `metadata` for `FileStore.put_volume`/`register_volume` (locate the caller in `gently/app/orchestration/timelapse.py` / `gently/app/tools/acquisition_tools.py`). +- Test: `tests/test_temperature_stamp.py` (new) — covers the pure helper + the volume metadata channel. +- Consumes: `temperature_stamp` and `agent.temperature_sampler.latest` (Tasks 3–4). + +**Interfaces:** +- Produces: a `temperature` block under `metadata` for volumes (`meta["metadata"]["temperature"]`) and under both per-frame `metadata` and the `burst.yaml` manifest top-level for bursts. + +- [ ] **Step 1: Write the failing test** + +```python +# tests/test_temperature_stamp.py +import numpy as np +from gently.app.temperature_sampler import temperature_stamp + + +def test_stamp_none_when_no_reading(): + assert temperature_stamp(None) is None + +def test_volume_metadata_carries_temperature(file_store): + sid = file_store.create_session(name="s") + emb = file_store.create_embryo(sid, position={"x": 0, "y": 0, "z": 0}) # confirm signature + stamp = temperature_stamp({"t": "2026-06-27T10:00:00+00:00", "water_c": 28.4, "setpoint_c": 32.0, "state": "heating"}) + vol = np.zeros((2, 4, 4), dtype="uint16") + file_store.put_volume(sid, emb, timepoint=0, volume=vol, metadata={"temperature": stamp}) + meta = file_store.get_volume_meta(sid, emb, 0) # confirm accessor name + assert meta["metadata"]["temperature"]["water_c"] == 28.4 +``` + +> IMPLEMENTER: confirm `create_embryo` and the volume-meta accessor (`get_volume_meta` or read the `.meta.yaml` directly via `get_volume_path(...).with_suffix` — adjust to the real API). The assertion target — `metadata["temperature"]` round-tripping into `t0000.meta.yaml` — is the contract; `put_volume` already nests the passed `metadata`, so this passes once the helper exists and the accessor is right. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pytest tests/test_temperature_stamp.py -v` +Expected: FAIL — initially on import/accessor; fix the accessor name from the real API, then it exercises the channel. + +- [ ] **Step 3: Write minimal implementation** + +**Volume path** — at the acquisition call site that calls `put_volume`/`register_volume`, fold the stamp into the existing `metadata` dict: + +```python +from gently.app.temperature_sampler import temperature_stamp +# ... where `agent` (or self) holds the sampler and `metadata` is being built: +stamp = temperature_stamp(getattr(getattr(agent, "temperature_sampler", None), "latest", None)) +if stamp is not None: + metadata["temperature"] = stamp +``` + +**Burst path** — `gently/app/orchestration/exclusive.py`: +1. Add a constructor param to the acquisition class that persists bursts: `temperature_provider=None` (a zero-arg callable returning the latest sample dict, or `None`), stored as `self._temperature_provider`. +2. In `_persist_burst_to_disk`, compute once: + +```python +from gently.app.temperature_sampler import temperature_stamp +_temp = temperature_stamp(self._temperature_provider() if self._temperature_provider else None) +``` + +3. Inject into the per-frame `metadata` dict (`:387-407`): add `"temperature": _temp` (only when not None — or always; `None` is acceptable YAML). +4. Inject into the `burst.yaml` manifest dict (`:423-442`): add a top-level `"temperature": _temp`. +5. Where this acquisition class is constructed (the orchestrator that owns bursts — `gently/app/orchestration/timelapse.py`), pass `temperature_provider=lambda: agent.temperature_sampler.latest if agent.temperature_sampler else None` (use the orchestrator's existing agent/store handle; confirm the attribute). + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pytest tests/test_temperature_stamp.py -v` +Expected: PASS + +- [ ] **Step 5: Verify burst wiring by inspection + targeted run** + +The burst persist writes real TIFFs, so it is verified by inspection (the `_temp` block is added in both dict sites) plus, during end-to-end verification (after Task 7), trigger one burst against the mock device and confirm `burst.yaml` and a frame `.meta.yaml` contain a `temperature` block. Note in the commit if the volume call site could not be located and was deferred — do not silently skip it. + +- [ ] **Step 6: Commit** + +```bash +git add gently/app/orchestration/exclusive.py gently/app/orchestration/timelapse.py tests/test_temperature_stamp.py +git commit -m "feat(temperature): stamp latest reading into burst + volume metadata" +``` + +--- + +### Task 7: Frontend — temperature graph component + Devices card + +**Files:** +- Create: `gently/ui/web/static/js/temperature-graph.js` +- Modify: `gently/ui/web/templates/index.html` — add a chart container inside `#devices-content` (the existing small readout is at `:452-465`; mount the chart as a section under the Details/Map view). +- Modify: `gently/ui/web/static/js/devices.js` — initialize the chart in `init()` (`:1556`) and subscribe it to `TEMPERATURE_UPDATE` (next to the `DEVICE_STATE_UPDATE` subscription at `:1561`). +- Reference (SVG style): `gently/ui/web/static/js/experiment-overview.js`; (event API) `gently/ui/web/static/js/event-bus.js:9-48` (`ClientEventBus.on(type, handler)`). + +**No JS unit harness exists** — this task is verified by running the app + Chrome DevTools MCP (see Step 4), consistent with the repo and the "UI audit before done" practice. + +- [ ] **Step 1: Build the component** + +Create `temperature-graph.js` exposing a small object/module: + +```javascript +// gently/ui/web/static/js/temperature-graph.js +// Hand-rolled SVG line chart: water-temp trace + stepped setpoint line. +// No dependency. Backfills from /api/temperature/{session}/history, then appends +// from TEMPERATURE_UPDATE events. Calm empty state, never mock data. +const TemperatureGraph = (() => { + const SVGNS = "http://www.w3.org/2000/svg"; + const MAX_POINTS = 600; // rolling ~10 min @ 1 Hz + let _root = null, _samples = [], _session = "current"; + + function init(container, sessionId) { + _root = container; _session = sessionId || "current"; _samples = []; + backfill(); + ClientEventBus.on("TEMPERATURE_UPDATE", onEvent); + } + + async function backfill() { + try { + const r = await fetch(`/api/temperature/${_session}/history`); + if (!r.ok) { renderEmpty(); return; } + const body = await r.json(); + _session = body.session_id || _session; + _samples = (body.samples || []).slice(-MAX_POINTS); + render(); + } catch (e) { renderEmpty(); } + } + + function onEvent(data) { + if (!data || !data.sample) return; + _samples.push(data.sample); + if (_samples.length > MAX_POINTS) _samples.shift(); + render(); + } + + function renderEmpty() { + _root.innerHTML = '
No temperature data yet
'; + } + + function render() { + if (!_samples.length) { renderEmpty(); return; } + const W = _root.clientWidth || 480, H = 160, pad = 24; + const xs = _samples.map((_, i) => i); + const ws = _samples.map(s => s.water_c).filter(v => v != null); + const sps = _samples.map(s => s.setpoint_c).filter(v => v != null); + const lo = Math.min(...ws, ...sps) - 1, hi = Math.max(...ws, ...sps) + 1; + const sx = i => pad + (i / Math.max(1, xs.length - 1)) * (W - 2 * pad); + const sy = v => H - pad - ((v - lo) / Math.max(0.001, hi - lo)) * (H - 2 * pad); + + const svg = document.createElementNS(SVGNS, "svg"); + svg.setAttribute("viewBox", `0 0 ${W} ${H}`); svg.setAttribute("width", "100%"); + + const line = (pts, cls) => { + const p = document.createElementNS(SVGNS, "polyline"); + p.setAttribute("points", pts); p.setAttribute("class", cls); + p.setAttribute("fill", "none"); svg.appendChild(p); + }; + line(_samples.map((s, i) => s.water_c != null ? `${sx(i)},${sy(s.water_c)}` : "").filter(Boolean).join(" "), "temp-water"); + // Stepped setpoint: carry previous y until it changes. + let sp = []; _samples.forEach((s, i) => { if (s.setpoint_c != null) sp.push(`${sx(i)},${sy(s.setpoint_c)}`); }); + line(sp.join(" "), "temp-setpoint"); + + const last = _samples[_samples.length - 1]; + const readout = document.createElement("div"); + readout.className = "temp-graph-readout"; + readout.textContent = `${last.water_c?.toFixed?.(1) ?? "—"} °C → ${last.setpoint_c?.toFixed?.(1) ?? "—"} °C (${last.state ?? ""})`; + + _root.innerHTML = ""; _root.appendChild(readout); _root.appendChild(svg); + } + + function dispose() { ClientEventBus.off("TEMPERATURE_UPDATE", onEvent); } + return { init, dispose, _render: render, _samples: () => _samples }; +})(); +window.TemperatureGraph = TemperatureGraph; +``` + +Add minimal CSS (in the devices stylesheet) for `.temp-water` (stroke: water color), `.temp-setpoint` (dashed stroke), `.temp-graph-empty` (muted), matching existing palette. + +- [ ] **Step 2: Mount it** + +In `templates/index.html`, add inside `#devices-content` (under the map/details view): + +```html +
+``` + +Load the script (next to the other `static/js/*.js` includes for the devices tab). + +In `devices.js` `init()` (`:1556`), after existing setup: + +```javascript + const tg = document.getElementById('devices-temp-graph'); + if (tg && window.TemperatureGraph) TemperatureGraph.init(tg, 'current'); +``` + +(No extra subscription needed in `devices.js` — the component self-subscribes. Optionally also route the existing small readout off the new event.) + +- [ ] **Step 3: Add the script tag** + +Add `` in `index.html` alongside the other component scripts, **before** `devices.js` loads (so `window.TemperatureGraph` exists when `init()` runs). + +- [ ] **Step 4: Verify live in the app (Chrome DevTools MCP)** + +Use the `run` skill to launch the app with the mock temperature backend and an active session. Then with Chrome DevTools MCP: +- navigate to the Devices tab, take a snapshot/screenshot; +- confirm the empty state shows when no samples, then the water trace + stepped setpoint render and update live as the sampler emits; +- run the UI audit (alignment/spacing/overflow/contrast) per the "UI audit before done" practice and fix any flaws; +- trigger one burst and confirm (Task 6) that `burst.yaml` + a frame `.meta.yaml` carry a `temperature` block. + +- [ ] **Step 5: Commit** + +```bash +git add gently/ui/web/static/js/temperature-graph.js gently/ui/web/templates/index.html gently/ui/web/static/js/devices.js +git commit -m "feat(temperature): live SVG temperature graph on the Devices tab" +``` + +--- + +## Self-Review + +**Spec coverage:** +- Persistence / `temperature.jsonl` → Task 1. ✓ +- Sampler (poll @1 Hz, session-gated, latest-in-memory, SSE) → Tasks 2–4. ✓ +- Per-acquisition stamp (burst + volume) → Task 6. ✓ +- History API (`current` resolution, `since`) → Task 5. ✓ +- Reusable SVG graph on Devices card (water trace + stepped setpoint + readout, backfill + live, empty state) → Task 7. ✓ +- Error/empty handling (gap-not-crash, no-device idle, empty state) → Tasks 3 & 7. ✓ +- Out-of-scope (setpoint control, choreography, always-on) → not implemented, by design. ✓ + +**Open verification items folded into tasks (not placeholders):** session-creation API (Task 1/3/6), `EventBus.subscribe` shape (Task 2), the agent's FileStore attribute (Task 4), the volume-meta accessor + `create_embryo` signature (Task 6), the burst-acquisition construction site (Task 6). Each is an explicit "confirm from the real API" instruction with a concrete fallback, not a TODO. + +**Type consistency:** `temperature_stamp` returns `{water_c, setpoint_c, state, sampled_at}` everywhere; sample lines are `{t, water_c, setpoint_c, state}`; event payload is `{session_id, sample}`; history response is `{session_id, samples}`. Consistent across Tasks 1/3/5/6/7. diff --git a/docs/superpowers/plans/2026-06-28-manual-mode-live-view.md b/docs/superpowers/plans/2026-06-28-manual-mode-live-view.md new file mode 100644 index 00000000..8c84eeeb --- /dev/null +++ b/docs/superpowers/plans/2026-06-28-manual-mode-live-view.md @@ -0,0 +1,728 @@ +# Manual Mode — SPIM Live View (B1) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** A Manual view in the Devices tab with a continuous SPIM single-slice brightfield live view (galvo/piezo/exposure controlled live), brightfield illumination, temperature, and by-hand burst/volume triggers — the hand-driven surface for next week's temperature-strain experiments. + +**Architecture:** A device-layer lightsheet live streamer (MMCore continuous sequence acquisition + peek-latest, parked galvo/piezo), bridged to the browser by a `LightSheetStreamMonitor` (EventBus `LIGHTSHEET_FRAME`) over the existing base64-JPEG/SSE→WS transport; `require_control` FastAPI proxy routes; a Manual view that mirrors the bottom-camera panel. FPS is measured on the rig; a binary transport path is a conditional follow-up. + +**Tech Stack:** Python asyncio + aiohttp (device layer), FastAPI (viz proxy), `pymmcore.CMMCore`, the project EventBus, vanilla-JS + canvas/SVG frontend (no build step), pytest (`asyncio_mode=auto`). + +## Global Constraints + +- **No new dependency.** Reuse `_encode_frame_for_stream` (OpenCV already present), the bottom-camera streamer pattern, A's portable temperature graph. +- **Single SPIM camera** today: `self.devices.get("camera")` (`HamCam1`). No side-A/B selector in B1 (deferred to B2). +- **Live = continuous sequence acquisition**, never a snap loop: `core.startContinuousSequenceAcquisition(0)` → peek `core.getLastImage()` (NEVER `popNextImage` — don't drain) → `stopSequenceAcquisition()` on exit. The core handle is `self.system.core` in the device layer; unwrap rpyc frames with `_safe_obtain`. +- **Park** before/under live: `piezo.setPosition(z)`, `scanner.sa_offset_y.setPosition(deg)` (or `scanner.set_y_offset(deg)`), `scanner.set_spim_state("Idle")`, `piezo.set_spim_state("Idle")`. galvo/piezo updates apply live (no restart); exposure change → stop→`setExposure`→restart. +- **Brightfield safety:** laser forced off in manual live (`setConfig(laser_group, "ALL OFF")` / `set_laser_power(...,0)`). +- **Concurrency:** the streamer honors `self._state_pause_counter > 0` (heavy plan owns MMCore → back off / stop sequence). Only one live stream at a time. +- **Lightsheet stream resolution/quality:** its own config — default `_ls_target_max_dim = 512`, `_ls_jpeg_quality = 70` (higher than the bottom-camera 360/55 thumbnail, for focus). +- **Transport stays JSON/`send_text`** (no binary path exists); a binary hop is Task 8, conditional on the FPS measurement. +- **Auth:** browser-facing writes are FastAPI proxy routes guarded by `Depends(require_control)`; device-layer aiohttp routes have no auth, so the browser must go through the proxy. +- **Tests:** `pytest`; `file_store`-style fixtures; FastAPI `TestClient` + mock client for routes; a fake core for the streamer. Frontend has no JS unit harness → `node --check` + Chrome-MCP harness + UI audit. Much of B1 needs the real rig; off-rig we cover streamer (fake core), routes, client, frontend harness, and defer live/FPS verification. + +--- + +### Task 1: `LIGHTSHEET_FRAME` event type + frontend exclusion + +**Files:** +- Modify: `gently/core/event_bus.py` (enum near `:88`; `_NO_HISTORY_TYPES` near `:186-195`) +- Modify: `gently/ui/web/static/js/websocket.js` (exclusion guard `:104-107`) +- Test: `tests/test_lightsheet_event.py` + +**Interfaces:** +- Produces: `EventType.LIGHTSHEET_FRAME` (declared with `auto()`, matching `BOTTOM_CAMERA_FRAME`), in `_NO_HISTORY_TYPES`. Wire serialization uses `.name` (so the browser receives `"LIGHTSHEET_FRAME"`). + +- [ ] **Step 1: Write the failing test** + +```python +# tests/test_lightsheet_event.py +from gently.core.event_bus import EventType, EventBus, _NO_HISTORY_TYPES + +def test_lightsheet_frame_event_exists(): + assert EventType.LIGHTSHEET_FRAME.name == "LIGHTSHEET_FRAME" + +def test_lightsheet_frame_excluded_from_history(): + assert EventType.LIGHTSHEET_FRAME in _NO_HISTORY_TYPES + +def test_lightsheet_frame_publishes_to_subscriber(): + bus = EventBus() + seen = [] + bus.subscribe(EventType.LIGHTSHEET_FRAME, lambda e: seen.append(e.data)) + bus.publish(event_type=EventType.LIGHTSHEET_FRAME, data={"jpeg_b64": "x"}, source="t") + assert seen == [{"jpeg_b64": "x"}] +``` + +> Confirm `EventBus.subscribe` signature / `_NO_HISTORY_TYPES` exportability against the real file; adapt the import/subscribe if needed, keep the assertions. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pytest tests/test_lightsheet_event.py -v` +Expected: FAIL — `AttributeError: LIGHTSHEET_FRAME` + +- [ ] **Step 3: Write minimal implementation** + +In `gently/core/event_bus.py`, add the member next to `BOTTOM_CAMERA_FRAME` (`:88`): +```python + LIGHTSHEET_FRAME = auto() # Live JPEG frame from the SPIM lightsheet live stream +``` +Add to `_NO_HISTORY_TYPES` (next to `BOTTOM_CAMERA_FRAME`): +```python + EventType.LIGHTSHEET_FRAME, # high-volume live frames — keep out of history +``` +In `gently/ui/web/static/js/websocket.js`, extend the exclusion guard (`:104-107`) so the frame skips the Events table but still reaches `ClientEventBus.emit`: +```javascript + if (msg.event_type !== 'DEVICE_STATE_UPDATE' && + msg.event_type !== 'BOTTOM_CAMERA_FRAME' && + msg.event_type !== 'TEMPERATURE_UPDATE' && + msg.event_type !== 'LIGHTSHEET_FRAME') { +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pytest tests/test_lightsheet_event.py -v` (3 passed) and `node --check gently/ui/web/static/js/websocket.js` (exit 0) + +- [ ] **Step 5: Commit** + +```bash +git add gently/core/event_bus.py gently/ui/web/static/js/websocket.js tests/test_lightsheet_event.py +git commit -m "feat(manual-mode): add LIGHTSHEET_FRAME event type + frontend exclusion" +``` + +--- + +### Task 2: Device-layer lightsheet live streamer (continuous sequence acquisition) + +**Files:** +- Modify: `gently/hardware/dispim/device_layer.py` — add config attrs (near `:160-169`), `_park_lightsheet_sync`, `_grab_lightsheet_frame_sync`, `_lightsheet_streamer`, `_broadcast_lightsheet`, `handle_lightsheet_stream`, `handle_lightsheet_params`; register two routes (near `:2806`). +- Test: `tests/test_lightsheet_streamer.py` +- Reference (mirror): `_bottom_camera_streamer`/`_broadcast_camera`/`handle_bottom_camera_stream`/`_encode_frame_for_stream` (same file); sequence-acq calls in `devices/acquisition.py:186-254`. + +**Interfaces:** +- Consumes: `self.system.core` (`pymmcore.CMMCore`), `self.devices["camera"]` / `["scanner"]` / `["piezo"]`, `self._state_pause_counter`, `self._encode_frame_for_stream` (reused verbatim), `_safe_obtain` (rpyc unwrap, imported as in `acquisition.py`). +- Produces: SSE `GET /api/lightsheet/stream`; `POST /api/lightsheet/live/params` `{galvo, piezo, exposure}`; in-process live param state `self._ls_params`. + +> **Implementer confirmations (cannot be verified off-rig — confirm against the real code, do not guess silently):** +> 1. The device-layer core handle (`self.system.core`) and that `pymmcore.CMMCore` exposes `startContinuousSequenceAcquisition(float)`, `getLastImage()`, `stopSequenceAcquisition()`, `isSequenceRunning()` (standard CMMCore API). If `getLastImage` is unavailable, use `getLastImageMD`/`getNBeforeLastImage`. +> 2. The scanner/piezo park calls: `self.devices["scanner"].sa_offset_y.setPosition(deg)` and `self.devices["piezo"].setPosition(z)`, and `set_spim_state("Idle")` on both (from `devices/scanner.py:271`, `devices/piezo.py:226`, recon §3). +> 3. Whether brightfield live needs the scanner/beam enabled or a static park — confirm against `../micro-manager/plugins/ASIdiSPIM/src/main/java/org/micromanager/asidispim/SetupPanel.java`. Default: static park, laser "ALL OFF". + +- [ ] **Step 1: Write the failing test (fake core + fake devices)** + +```python +# tests/test_lightsheet_streamer.py +import asyncio, numpy as np, pytest +from gently.hardware.dispim.device_layer import DeviceLayer # confirm class name/import + +class FakeCore: + def __init__(self): + self.running = False; self.exposure = None; self.cam = None + self._frame = np.full((64, 64), 1000, dtype=np.uint16) + self.started = 0; self.stopped = 0 + def setCameraDevice(self, n): self.cam = n + def getCameraDevice(self): return self.cam + def setExposure(self, n, ms): self.exposure = ms + def startContinuousSequenceAcquisition(self, interval): self.running = True; self.started += 1 + def stopSequenceAcquisition(self): self.running = False; self.stopped += 1 + def isSequenceRunning(self): return self.running + def getLastImage(self): return self._frame + +class FakeAxis: + def __init__(self): self.pos = None + def setPosition(self, v): self.pos = v + +class FakeScanner: + def __init__(self): self.sa_offset_y = FakeAxis(); self.name="Scanner"; self.state=None + def set_spim_state(self, s): self.state = s + +class FakePiezo(FakeAxis): + def __init__(self): super().__init__(); self.name="Piezo"; self.state=None + def set_spim_state(self, s): self.state = s + +def _streamer(dl): + dl.system = type("S", (), {"core": FakeCore()})() + dl.devices = {"camera": type("C", (), {"name": "HamCam1"})(), + "scanner": FakeScanner(), "piezo": FakePiezo()} + return dl + +async def test_grab_parks_and_peeks(monkeypatch): + dl = _streamer(DeviceLayer.__new__(DeviceLayer)) + dl._state_pause_counter = 0 + dl._ls_target_max_dim = 512; dl._ls_jpeg_quality = 70 + dl._ls_params = {"galvo": 1.5, "piezo": 40.0, "exposure": 20.0} + dl._ls_seq_started = False; dl._ls_applied = {} + img = await asyncio.to_thread(dl._grab_lightsheet_frame_sync) + assert img is not None and img.shape == (64, 64) + assert dl.system.core.running is True # sequence started + assert dl.devices["piezo"].pos == 40.0 # piezo parked + assert dl.devices["scanner"].sa_offset_y.pos == 1.5 # galvo parked + +async def test_exposure_change_restarts_sequence(): + dl = _streamer(DeviceLayer.__new__(DeviceLayer)) + dl._state_pause_counter = 0 + dl._ls_target_max_dim = 512; dl._ls_jpeg_quality = 70 + dl._ls_params = {"galvo": 0.0, "piezo": 50.0, "exposure": 10.0} + dl._ls_seq_started = False; dl._ls_applied = {} + await asyncio.to_thread(dl._grab_lightsheet_frame_sync) + starts = dl.system.core.started + dl._ls_params["exposure"] = 30.0 # exposure change + await asyncio.to_thread(dl._grab_lightsheet_frame_sync) + assert dl.system.core.stopped >= 1 and dl.system.core.started == starts + 1 + assert dl.system.core.exposure == 30.0 +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `pytest tests/test_lightsheet_streamer.py -v` +Expected: FAIL — `AttributeError: _grab_lightsheet_frame_sync` (or import). + +- [ ] **Step 3: Implement the streamer** + +Add config attrs in `__init__` (near `:169`, after the `_cam_*` block): +```python + # Lightsheet (SPIM) live stream — continuous sequence acquisition. + self._ls_subscribers: list[asyncio.Queue] = [] + self._ls_task: asyncio.Task | None = None + self._ls_interval_sec: float = 0.0 # peek as fast as exposure allows + self._ls_target_max_dim: int = 512 + self._ls_jpeg_quality: int = 70 + self._ls_params: dict = {"galvo": 0.0, "piezo": 50.0, "exposure": 20.0} + self._ls_seq_started: bool = False + self._ls_applied: dict = {} # last-applied galvo/piezo/exposure +``` + +Add the grab/park/peek (mirrors the sequence-acq calls from `acquisition.py`; uses `self.system.core`): +```python + def _park_lightsheet_sync(self) -> None: + """Park scanner galvo + imaging piezo at the current live params (static sheet).""" + p = self._ls_params + scanner = self.devices.get("scanner") + piezo = self.devices.get("piezo") + if scanner is not None: + try: scanner.set_spim_state("Idle") + except Exception: pass + scanner.sa_offset_y.setPosition(float(p["galvo"])) + if piezo is not None: + try: piezo.set_spim_state("Idle") + except Exception: pass + piezo.setPosition(float(p["piezo"])) + + def _ensure_lightsheet_sequence_sync(self) -> None: + """Start (or restart on exposure change) the continuous sequence on the SPIM camera.""" + core = self.system.core + cam = self.devices.get("camera") + if cam is None: + raise RuntimeError("No lightsheet camera configured") + p = self._ls_params + need_restart = ( + not self._ls_seq_started + or self._ls_applied.get("exposure") != p["exposure"] + ) + if need_restart: + if core.isSequenceRunning(): + core.stopSequenceAcquisition() + if core.getCameraDevice() != cam.name: + core.setCameraDevice(cam.name) + core.setExposure(cam.name, float(p["exposure"])) + core.startContinuousSequenceAcquisition(self._ls_interval_sec * 1000.0) + self._ls_seq_started = True + self._ls_applied["exposure"] = p["exposure"] + + def _grab_lightsheet_frame_sync(self): + """Park → ensure sequence running → peek the latest frame (never drain).""" + try: + self._park_lightsheet_sync() # galvo/piezo applied live + self._ensure_lightsheet_sequence_sync() # start / restart on exposure + from gently.hardware.dispim.devices.acquisition import _safe_obtain + core = self.system.core + img = core.getLastImage() + try: img = _safe_obtain(img) + except (ImportError, AttributeError): pass + return np.asarray(img) + except Exception as exc: + logger.debug("Lightsheet grab failed: %s", exc) + return None + + def _stop_lightsheet_sequence_sync(self) -> None: + try: + if self.system.core.isSequenceRunning(): + self.system.core.stopSequenceAcquisition() + except Exception: + logger.debug("stop lightsheet sequence failed", exc_info=True) + self._ls_seq_started = False + self._ls_applied = {} +``` + +Add the streamer loop + broadcast (mirror `_bottom_camera_streamer`/`_broadcast_camera`, reusing `_encode_frame_for_stream`): +```python + async def _lightsheet_streamer(self): + logger.info("Lightsheet streamer started") + try: + while self._ls_subscribers: + if self._state_pause_counter > 0: + # Heavy plan owns MMCore: release the sequence and back off. + if self._ls_seq_started: + await asyncio.to_thread(self._stop_lightsheet_sequence_sync) + await asyncio.sleep(0.1) + continue + tick = time.monotonic() + img = await asyncio.to_thread(self._grab_lightsheet_frame_sync) + payload = self._encode_frame_for_stream(img) if img is not None else None + if payload is not None: + await self._broadcast_lightsheet(payload) + elapsed = time.monotonic() - tick + # Pace to at least the exposure; peek-rate caps near the camera rate. + floor = max(self._ls_interval_sec, self._ls_params["exposure"] / 1000.0) + await asyncio.sleep(max(0.0, floor - elapsed)) + except asyncio.CancelledError: + raise + except Exception: + logger.exception("Lightsheet streamer crashed") + finally: + await asyncio.to_thread(self._stop_lightsheet_sequence_sync) + logger.info("Lightsheet streamer exiting") + + async def _broadcast_lightsheet(self, payload): + if not self._ls_subscribers: + return + dead = [] + for q in self._ls_subscribers: + try: + q.put_nowait(payload) + except asyncio.QueueFull: + try: + _ = q.get_nowait(); q.put_nowait(payload) + except Exception: + dead.append(q) + for q in dead: + try: self._ls_subscribers.remove(q) + except ValueError: pass +``` + +> `_encode_frame_for_stream` uses `self._cam_target_max_dim`/`self._cam_jpeg_quality`. To get the 512/70 lightsheet settings without duplicating the encoder, add an optional override: change its signature to `_encode_frame_for_stream(self, img, max_dim=None, quality=None)` defaulting to the `_cam_*` values, and call it `self._encode_frame_for_stream(img, self._ls_target_max_dim, self._ls_jpeg_quality)` from the lightsheet loop. (One-line change to the encoder; bottom-camera behavior unchanged.) + +Add the SSE handler + params handler (mirror `handle_bottom_camera_stream`; the params handler updates `self._ls_params` — galvo/piezo apply on the next grab, exposure triggers the restart path): +```python + async def handle_lightsheet_stream(self, request): + response = web.StreamResponse(status=200, reason="OK", headers={ + "Content-Type": "text/event-stream", "Cache-Control": "no-cache", + "Connection": "keep-alive", "X-Accel-Buffering": "no"}) + await response.prepare(request) + queue: asyncio.Queue = asyncio.Queue(maxsize=4) + self._ls_subscribers.append(queue) + if len(self._ls_subscribers) == 1 and (self._ls_task is None or self._ls_task.done()): + self._ls_task = asyncio.create_task(self._lightsheet_streamer(), name="lightsheet-streamer") + try: + await response.write(b": connected\n\n") + while True: + try: + payload = await asyncio.wait_for(queue.get(), timeout=10.0) + except asyncio.TimeoutError: + await response.write(b": keepalive\n\n"); continue + if payload is None: break + await response.write(f"data: {json.dumps(payload)}\n\n".encode()) + except (asyncio.CancelledError, ConnectionResetError, ConnectionAbortedError): + pass + except Exception: + logger.exception("Lightsheet SSE writer failed") + finally: + try: self._ls_subscribers.remove(queue) + except ValueError: pass + return response + + async def handle_lightsheet_params(self, request): + body = await request.json() + for k in ("galvo", "piezo", "exposure"): + if k in body and body[k] is not None: + self._ls_params[k] = float(body[k]) + return web.json_response({"params": self._ls_params}) +``` + +Register both routes near `:2806`: +```python + self._app.router.add_get("/api/lightsheet/stream", self.handle_lightsheet_stream) + self._app.router.add_post("/api/lightsheet/live/params", self.handle_lightsheet_params) +``` + +- [ ] **Step 4: Run to verify it passes** + +Run: `pytest tests/test_lightsheet_streamer.py -v` +Expected: PASS (2 passed). If `DeviceLayer.__new__` bypassing `__init__` leaves attrs unset, the test sets the needed ones explicitly (it does). + +- [ ] **Step 5: Commit** + +```bash +git add gently/hardware/dispim/device_layer.py tests/test_lightsheet_streamer.py +git commit -m "feat(manual-mode): device-layer lightsheet live streamer (continuous sequence acquisition)" +``` + +--- + +### Task 3: Client methods — `stream_lightsheet` + `set_lightsheet_live_params` + +**Files:** +- Modify: `gently/hardware/dispim/client.py` (add two methods on `DiSPIMMicroscope`, near `stream_bottom_camera` `:913`) +- Test: `tests/test_lightsheet_client.py` + +**Interfaces:** +- Produces: `async def stream_lightsheet(self, timeout=None)` (async generator over `GET /api/lightsheet/stream`, identical SSE parse to `stream_bottom_camera`); `async def set_lightsheet_live_params(self, galvo=None, piezo=None, exposure=None) -> dict` (`POST /api/lightsheet/live/params`). + +- [ ] **Step 1: Write the failing test** + +```python +# tests/test_lightsheet_client.py +import pytest +from gently.hardware.dispim.client import DiSPIMMicroscope + +async def test_set_params_posts_body(monkeypatch): + m = DiSPIMMicroscope.__new__(DiSPIMMicroscope) + sent = {} + async def fake_post(path, body): + sent["path"] = path; sent["body"] = body; return {"params": body} + m._api_post = fake_post # confirm the real low-level POST helper name + res = await m.set_lightsheet_live_params(galvo=1.0, piezo=42.0, exposure=15.0) + assert sent["path"] == "/api/lightsheet/live/params" + assert sent["body"] == {"galvo": 1.0, "piezo": 42.0, "exposure": 15.0} + assert res == {"params": {"galvo": 1.0, "piezo": 42.0, "exposure": 15.0}} + +def test_stream_lightsheet_is_async_generator(): + m = DiSPIMMicroscope.__new__(DiSPIMMicroscope) + import inspect + assert inspect.isasyncgenfunction(m.stream_lightsheet) +``` + +> Confirm the real low-level POST helper (the recon shows `set_led` etc. POST via an internal helper — find whether it's `self._api_post(path, body)` or an inline `self._session.post`). Match it; if `set_lightsheet_live_params` should drop `None` keys, build the body from only the provided args (the test passes all three). + +- [ ] **Step 2: Run to verify it fails** + +Run: `pytest tests/test_lightsheet_client.py -v` → FAIL (no such methods). + +- [ ] **Step 3: Implement** + +```python + async def stream_lightsheet(self, timeout: float | None = None): + """Async generator yielding JPEG frames from the lightsheet live SSE stream. + + Mirrors :meth:`stream_bottom_camera`; subscriber-gated on the device layer. + """ + self._ensure_connected() + client_timeout = aiohttp.ClientTimeout(total=None, sock_read=timeout, sock_connect=10.0) + url = f"{self.http_url}/api/lightsheet/stream" + async with self._session.get(url, timeout=client_timeout) as resp: + resp.raise_for_status() + buf = b"" + async for chunk in resp.content.iter_any(): + if not chunk: + continue + buf += chunk + while b"\n\n" in buf: + event_block, buf = buf.split(b"\n\n", 1) + data_lines = [] + for line in event_block.splitlines(): + if not line or line.startswith(b":"): + continue + if line.startswith(b"data:"): + data_lines.append(line[5:].lstrip()) + if not data_lines: + continue + raw = b"\n".join(data_lines).decode("utf-8", errors="replace") + try: + import json as _json + yield _json.loads(raw) + except Exception as exc: + logger.warning("Malformed lightsheet SSE payload skipped: %s", exc) + + async def set_lightsheet_live_params(self, galvo=None, piezo=None, exposure=None) -> dict: + """POST live galvo/piezo/exposure to the device-layer lightsheet streamer.""" + body = {} + if galvo is not None: body["galvo"] = float(galvo) + if piezo is not None: body["piezo"] = float(piezo) + if exposure is not None: body["exposure"] = float(exposure) + return await self._api_post("/api/lightsheet/live/params", body) +``` + +> If the real POST helper isn't `_api_post`, adapt this one call site (and the test) to the real helper. `stream_lightsheet` copies `stream_bottom_camera` verbatim except the URL. + +- [ ] **Step 4: Run to verify it passes** + +Run: `pytest tests/test_lightsheet_client.py -v` (2 passed) + +- [ ] **Step 5: Commit** + +```bash +git add gently/hardware/dispim/client.py tests/test_lightsheet_client.py +git commit -m "feat(manual-mode): client stream_lightsheet + set_lightsheet_live_params" +``` + +--- + +### Task 4: `LightSheetStreamMonitor` (agent Service) + agent wiring + +**Files:** +- Create: `gently/app/lightsheet_monitor.py` +- Modify: `gently/app/agent.py` (init attr; construct in `start_viz_server` near the bottom-camera monitor `:849-860`; stop in `stop_viz_server` near `:862-869`) +- Test: `tests/test_lightsheet_monitor.py` +- Reference (mirror verbatim, swapping names/event): `gently/app/bottom_camera_monitor.py` + +**Interfaces:** +- Consumes: `microscope.stream_lightsheet()` (Task 3); `EventType.LIGHTSHEET_FRAME` (Task 1). +- Produces: `LightSheetStreamMonitor(Service)` with `running` property, `on_start`/`on_stop`; publishes `LIGHTSHEET_FRAME`. `agent.lightsheet_monitor` (constructed, not started; started via proxy in Task 5). + +- [ ] **Step 1: Write the failing test** + +```python +# tests/test_lightsheet_monitor.py +import asyncio +from gently.core.event_bus import EventType, get_event_bus +from gently.app.lightsheet_monitor import LightSheetStreamMonitor + +class FakeScope: + async def stream_lightsheet(self): + for i in range(3): + yield {"t": float(i), "jpeg_b64": f"f{i}"} + await asyncio.sleep(0) + +async def test_monitor_publishes_frames(): + bus = get_event_bus() + seen = [] + bus.subscribe(EventType.LIGHTSHEET_FRAME, lambda e: seen.append(e.data)) + mon = LightSheetStreamMonitor(FakeScope(), reconnect_delay_sec=0.01) + await mon.start() + await asyncio.sleep(0.05) + await mon.stop() + assert any(d.get("jpeg_b64") == "f0" for d in seen) + assert mon.running is False +``` + +- [ ] **Step 2: Run to verify it fails** — `pytest tests/test_lightsheet_monitor.py -v` → FAIL (no module). + +- [ ] **Step 3: Implement** — copy `gently/app/bottom_camera_monitor.py` to `gently/app/lightsheet_monitor.py` and change: class → `LightSheetStreamMonitor`; `name="lightsheet-monitor"`; the `_run` loop calls `self.microscope.stream_lightsheet()` and publishes `EventType.LIGHTSHEET_FRAME` with `source="lightsheet-monitor"`. (Everything else — Service base, on_start/on_stop, reconnect loop, `_last_frame_ts`, `running` — is identical.) + +Agent wiring in `gently/app/agent.py`: add `self.lightsheet_monitor = None` next to `self.bottom_camera_monitor = None`; in `start_viz_server` (after the bottom-camera monitor construction `:849-860`): +```python + if self.microscope is not None and self.lightsheet_monitor is None: + try: + from .lightsheet_monitor import LightSheetStreamMonitor + self.lightsheet_monitor = LightSheetStreamMonitor(self.microscope) + logger.info("Lightsheet monitor ready (not started)") + except Exception as e: + logger.warning(f"Failed to construct lightsheet monitor: {e}") + self.lightsheet_monitor = None +``` +In `stop_viz_server` (near `:862`): +```python + if self.lightsheet_monitor is not None: + try: + await self.lightsheet_monitor.stop() + except Exception: + logger.exception("Failed to stop lightsheet monitor") + self.lightsheet_monitor = None +``` + +- [ ] **Step 4: Run to verify it passes** — `pytest tests/test_lightsheet_monitor.py -v` (1 passed); `pytest -q` (no new failures). + +- [ ] **Step 5: Commit** + +```bash +git add gently/app/lightsheet_monitor.py gently/app/agent.py tests/test_lightsheet_monitor.py +git commit -m "feat(manual-mode): LightSheetStreamMonitor bridge + agent wiring" +``` + +--- + +### Task 5: Browser proxy routes (`require_control`) + +**Files:** +- Modify: `gently/ui/web/routes/data.py` (add routes in `create_router`, mirroring the bottom-camera + room-light routes) +- Test: `tests/test_lightsheet_routes.py` +- Reference: bottom-camera start/stop/status (`data.py:260-311`), room-light proxy (`data.py:335-355`), `_resolve_client` (`:313`), `require_control` (`:10`). + +**Interfaces:** +- Consumes: `agent.lightsheet_monitor` (Task 4); `client.set_lightsheet_live_params`, `set_led`, `set_laser_power`, `set_camera_led_mode`, `move_to_position`, `acquire_burst`, `acquire_volume`. +- Produces (all `Depends(require_control)` except GET status): `POST /api/devices/lightsheet/live/{start,stop}`, `GET /api/devices/lightsheet/live/status`, `POST /api/devices/lightsheet/live/params`, `POST /api/devices/led/set`, `POST /api/devices/laser/off`, `POST /api/devices/camera/led_mode`, `POST /api/devices/stage/move`, `POST /api/devices/acquire/{burst,volume}`. + +- [ ] **Step 1: Write the failing test (TestClient + mock client/monitor)** + +```python +# tests/test_lightsheet_routes.py +from unittest.mock import MagicMock, AsyncMock +from fastapi import FastAPI +from fastapi.testclient import TestClient +from gently.ui.web.routes.data import create_router +import gently.ui.web.auth as auth + +def _app(client=None, monitor=None): + server = MagicMock() + server.agent_bridge.agent.client = client + server.agent_bridge.agent.lightsheet_monitor = monitor + app = FastAPI(); app.include_router(create_router(server)) + # legacy localhost = CONTROL; TestClient client.host is "testclient" → force CONTROL: + app.dependency_overrides[auth.require_control] = lambda: True + return TestClient(app) + +def test_live_params_forwards(): + client = MagicMock(); client.set_lightsheet_live_params = AsyncMock(return_value={"params": {}}) + r = _app(client=client).post("/api/devices/lightsheet/live/params", + json={"galvo": 1.0, "piezo": 40.0, "exposure": 20.0}) + assert r.status_code == 200 + client.set_lightsheet_live_params.assert_awaited_once_with(galvo=1.0, piezo=40.0, exposure=20.0) + +def test_acquire_burst_forwards(): + client = MagicMock(); client.acquire_burst = AsyncMock(return_value={"success": True, "request_id": "b1"}) + r = _app(client=client).post("/api/devices/acquire/burst", + json={"frames": 60, "mode": "1hz", "num_slices": 1, "exposure_ms": 5.0}) + assert r.status_code == 200 and r.json().get("request_id") == "b1" + +def test_live_start_requires_monitor(): + r = _app(monitor=None).post("/api/devices/lightsheet/live/start") + assert r.status_code == 503 +``` + +> Confirm the `require_control` override mechanism: in legacy mode `TestClient` requests are not localhost, so override the dependency (as above) to isolate route logic. A separate test can assert the gate by NOT overriding and expecting 403. + +- [ ] **Step 2: Run to verify it fails** — `pytest tests/test_lightsheet_routes.py -v` → FAIL (routes 404). + +- [ ] **Step 3: Implement** — in `create_router`, add (mirroring the referenced routes). Live start/stop/status copy the bottom-camera versions verbatim, swapping `bottom_camera_monitor` → `lightsheet_monitor`. Then: +```python + @router.post("/api/devices/lightsheet/live/params", dependencies=[Depends(require_control)]) + async def lightsheet_live_params(payload: dict = Body(...)): # noqa: B008 + client = _resolve_client() + if client is None: + raise HTTPException(status_code=503, detail="Microscope not connected") + try: + res = await client.set_lightsheet_live_params( + galvo=payload.get("galvo"), piezo=payload.get("piezo"), + exposure=payload.get("exposure")) + except Exception as exc: + logger.exception("lightsheet live params failed") + raise HTTPException(status_code=502, detail=f"params failed: {exc}") from exc + return res + + @router.post("/api/devices/led/set", dependencies=[Depends(require_control)]) + async def led_set(payload: dict = Body(...)): # noqa: B008 + client = _resolve_client() + if client is None: raise HTTPException(status_code=503, detail="Microscope not connected") + try: return await client.set_led(str(payload.get("state", "Closed"))) + except Exception as exc: + raise HTTPException(status_code=502, detail=f"led failed: {exc}") from exc + + @router.post("/api/devices/laser/off", dependencies=[Depends(require_control)]) + async def laser_off(): + client = _resolve_client() + if client is None: raise HTTPException(status_code=503, detail="Microscope not connected") + try: return await client.set_laser_power(488, 0) # confirm signature; force off + except Exception as exc: + raise HTTPException(status_code=502, detail=f"laser off failed: {exc}") from exc + + @router.post("/api/devices/camera/led_mode", dependencies=[Depends(require_control)]) + async def camera_led_mode(payload: dict = Body(...)): # noqa: B008 + client = _resolve_client() + if client is None: raise HTTPException(status_code=503, detail="Microscope not connected") + try: return await client.set_camera_led_mode(bool(payload.get("use_led", False))) + except Exception as exc: + raise HTTPException(status_code=502, detail=f"camera led mode failed: {exc}") from exc + + @router.post("/api/devices/stage/move", dependencies=[Depends(require_control)]) + async def stage_move(payload: dict = Body(...)): # noqa: B008 + client = _resolve_client() + if client is None: raise HTTPException(status_code=503, detail="Microscope not connected") + try: return await client.move_to_position(float(payload["x"]), float(payload["y"])) + except KeyError: raise HTTPException(status_code=400, detail="x and y required") + except Exception as exc: + raise HTTPException(status_code=502, detail=f"stage move failed: {exc}") from exc + + @router.post("/api/devices/acquire/burst", dependencies=[Depends(require_control)]) + async def acquire_burst(payload: dict = Body(...)): # noqa: B008 + client = _resolve_client() + if client is None: raise HTTPException(status_code=503, detail="Microscope not connected") + try: + return await client.acquire_burst( + frames=int(payload.get("frames", 60)), mode=str(payload.get("mode", "1hz")), + num_slices=int(payload.get("num_slices", 1)), + exposure_ms=float(payload.get("exposure_ms", 5.0))) + except Exception as exc: + raise HTTPException(status_code=502, detail=f"burst failed: {exc}") from exc + + @router.post("/api/devices/acquire/volume", dependencies=[Depends(require_control)]) + async def acquire_volume(payload: dict = Body(...)): # noqa: B008 + client = _resolve_client() + if client is None: raise HTTPException(status_code=503, detail="Microscope not connected") + try: + return await client.acquire_volume( + num_slices=int(payload.get("num_slices", 50)), + exposure_ms=float(payload.get("exposure_ms", 10.0))) + except Exception as exc: + raise HTTPException(status_code=502, detail=f"volume failed: {exc}") from exc +``` +Add the live start/stop/status routes by copying `start_bottom_camera_stream`/`stop_bottom_camera_stream`/`get_bottom_camera_status` (`data.py:260-311`) under `/api/devices/lightsheet/live/...` with `getattr(agent, "lightsheet_monitor", None)`. + +- [ ] **Step 4: Run to verify it passes** — `pytest tests/test_lightsheet_routes.py -v` (3 passed); `pytest -q` (no new failures). + +- [ ] **Step 5: Commit** + +```bash +git add gently/ui/web/routes/data.py tests/test_lightsheet_routes.py +git commit -m "feat(manual-mode): require_control proxy routes for lightsheet live + illumination + acquire" +``` + +--- + +### Task 6: Manual view (frontend) + +**Files:** +- Modify: `gently/ui/web/templates/index.html` — add a `data-view="manual"` button to `#devices-view-switcher` (`:445-449`); add a `#devices-view-manual` container with the live canvas + control rail (model the camera panel `:569-608`); load A's `temperature-graph.js` if not already loaded on this page. +- Modify: `gently/ui/web/static/js/devices.js` — add `'manual'` to `VIEWS` (`:17`); a `handleLightsheetFrame` (mirror `handleCameraFrame` `:1111`); live toggle (mirror `toggleCameraStream`); galvo/piezo/exposure controls (debounced POST `live/params`); illumination toggles; acquire buttons; FPS readout; embed `TemperatureGraph.init`; a `'v'`-style key handled only if not deferred — leave existing keys, add nothing conflicting. +- Modify: the devices stylesheet for the manual panel (reuse `.devices-camera-*` styles where possible). + +**No JS unit harness** — verified by `node --check` + a Chrome-MCP harness (like A) + a UI audit. + +- [ ] **Step 1: Markup** — add to `#devices-view-switcher`: +```html + +``` +Add the view container after `#devices-view-optical3d` (`:716`-ish), with: a live `` (or ``) + placeholder + FPS/side overlay + Start/Stop toggle (`#devices-ls-toggle`); a right rail with exposure input, galvo slider (`#devices-ls-galvo`), piezo slider (`#devices-ls-piezo`), illumination toggles (LED `#devices-ls-led`, camera-LED-mode, room light, a static "Laser: OFF" indicator), a temperature setpoint + `
`, and Snap-volume / Burst buttons + a `#devices-ls-lastcap` card. Mirror the `.devices-camera-*` class structure for the image stage so the existing zoom/pan inline machinery can be reused or replicated. + +- [ ] **Step 2: JS — frame paint + controls** + +Add `'manual'` to `VIEWS`. Add a frame handler mirroring `handleCameraFrame` (separate DOM ids `_lsImg`/`_lsMeta`, its own FPS window) and subscribe `ClientEventBus.on('LIGHTSHEET_FRAME', handleLightsheetFrame)` in `setupCameraWiring` (or a new `setupManualWiring`). Live toggle hits `/api/devices/lightsheet/live/start|stop`. Galvo/piezo/exposure inputs: on `input`, **debounce ~120 ms**, then `fetch('/api/devices/lightsheet/live/params', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({galvo, piezo, exposure})})`. Illumination toggles POST their routes. Acquire buttons POST `/api/devices/acquire/burst|volume` with the current params, show "acquiring…" then render the result ref in `#devices-ls-lastcap`. Embed A's graph: `if (window.TemperatureGraph) TemperatureGraph.init(document.getElementById('devices-ls-tempgraph'), 'current')`. FPS readout from the frame handler's window (reuse the `computeCameraFps` approach). + +- [ ] **Step 3: `node --check`** — `node --check gently/ui/web/static/js/devices.js` (exit 0). + +- [ ] **Step 4: Chrome-MCP harness verification** — build a standalone harness (like A's): copy `event-bus.js`, `temperature-graph.js`, the new manual JS, and `main.css`; stub `fetch` for `live/params` + status + acquire; feed simulated `LIGHTSHEET_FRAME` events (a moving synthetic gradient that shifts when galvo/piezo "params" change) to demonstrate the slide-and-see; screenshot; run the alignment/spacing/contrast UI audit and fix flaws. Live in-app + FPS verification is deferred to the rig. + +- [ ] **Step 5: Commit** + +```bash +git add gently/ui/web/templates/index.html gently/ui/web/static/js/devices.js gently/ui/web/static/css/main.css +git commit -m "feat(manual-mode): Manual view — SPIM live canvas, galvo/piezo/exposure, illumination, acquire, temp" +``` + +--- + +### Task 7: FPS measurement (rig) + conditional binary transport + +**Files:** +- Create: `docs/superpowers/notes/2026-06-28-lightsheet-fps-measurement.md` (record the numbers) +- (Conditional) Modify: `gently/ui/web/connection_manager.py` + `agent_ws`/`server.py` + `websocket.js` for a binary frame path. + +**This task is a measurement + a gate, not unconditional code.** + +- [ ] **Step 1:** On the rig, start lightsheet live and record from the Manual-view FPS readout + device logs: **device grab rate**, **delivered rate**, **browser paint rate**, at 512 px/q70 and at a reduced 384 px/q60. Write them into the notes file with the exposure used. +- [ ] **Step 2: Diagnose.** If device grab < ~15 fps → limiter is exposure/readout/rpyc, not transport — tune exposure/size/quality, stop here. If device grab ≥ target but browser paint < target → transport is the bottleneck → do Step 3. +- [ ] **Step 3 (conditional): binary WebSocket path.** Add a `send_bytes`-based frame channel: device→agent SSE stays; on the agent→browser hop, push the raw JPEG via `websocket.send_bytes(prefix + jpeg)` (small type byte), bypassing base64 + the per-client `json.dumps` + the EventBus fan-out; browser `onmessage` binary → `createImageBitmap(new Blob([buf]))` → `ctx.drawImage`. Re-measure and record. +- [ ] **Step 4: Commit** the notes (and any binary-path code, if built). + +```bash +git add docs/superpowers/notes/2026-06-28-lightsheet-fps-measurement.md +git commit -m "docs(manual-mode): lightsheet live FPS measurement + transport decision" +``` + +--- + +## Self-Review + +**Spec coverage:** §2.1 streamer → Task 2; §2.2 monitor → Task 4; §2.3 proxy routes → Task 5; §2.4 client methods → Task 3; §2.5 Manual view → Task 6; §2.6 concurrency (`_state_pause_counter` back-off) → Task 2 streamer loop; §2.7 brightfield laser-off → Tasks 2 & 5 (`laser/off`); §3 transport baseline + measurement + binary escalation → Tasks 1/4 (baseline) + Task 7 (measure/escalate); §4 error handling → Tasks 2/5 (try/except, 503/502); §5 testing → each task's tests + Task 6 harness; LIGHTSHEET_FRAME event → Task 1. Single-camera (no side A/B) reflected throughout. ✓ + +**Open confirmations (explicit, not placeholders):** pymmcore sequence-acq method availability + core handle (Task 2); scanner/piezo park method names (Task 2); the SetupPanel scanner/beam question (Task 2); the client low-level POST helper name (Task 3); the `require_control` test-override mechanism (Task 5). Each names a concrete fallback. + +**Type consistency:** frame payload `{t, shape, downsample, mime, jpeg_b64}` (reused encoder) across Tasks 2/3/4/6; live params `{galvo, piezo, exposure}` across Tasks 2/3/5/6; event `LIGHTSHEET_FRAME` across Tasks 1/4/6; `lightsheet_monitor` attr across Tasks 4/5. Consistent. diff --git a/docs/superpowers/plans/2026-06-28-operations-tab.md b/docs/superpowers/plans/2026-06-28-operations-tab.md new file mode 100644 index 00000000..46625492 --- /dev/null +++ b/docs/superpowers/plans/2026-06-28-operations-tab.md @@ -0,0 +1,76 @@ +# Operations — agent-authored Operation Plan (D v3) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development. Steps use `- [ ]`. + +**Goal:** The agent emits a typed Operation Plan (its tactics, planned/active/done); Operations renders it ⊕ live telemetry. Built backend-first (the user-directed, low-rework part), then the renderer. + +**Architecture:** A typed `OperationPlan` in `FileContextStore` (new domain), written by a forced-tool agent call, served on a route, rendered by a data-driven operation-spine generalized for tactic kinds, with live-telemetry binding + scenario test mode. + +**Tech Stack:** Python (FileContextStore YAML, the `@tool`/forced-`tool_choice` pattern, EventBus `CONTEXT_UPDATED`), FastAPI route, vanilla-JS + SVG renderer, pytest. + +## Global Constraints +- Source of truth = the agent's typed plan (NOT a backend reconstruction). Live telemetry binds onto declared tactics; the agent declares tactic identity, the system supplies live values. +- Plan schema (see spec §1): `{session_id,title,goal,tactics:[{id,name,kind,state,scope,rationale,structure,live_bind,relations}],updated_at,updated_reason}`. `kind∈{standing_timelapse,reactive_monitor,scripted_protocol,exclusive_burst,oneshot,custom}`, `state∈{planned,active,done}`. No round-robin. +- Forced typed output mirrors `gently/harness/memory/notebook_ask.py` (ASK_TOOL + `tool_choice={'type':'tool',...}` → validated `block.input`) OR the `@tool` auto-schema (`harness/tools/registry.py`). +- Store mirrors `session_intents`/`active/` domains; fire the existing `CONTEXT_UPDATED`. +- Route mirrors `gently/ui/web/routes/context.py` (`/api/context`). +- Renderer = the validated operation-spine (harness `scratchpad/opsdesign/harness.html`) with the audit fixes (queued reads cocked + "next" marker; active phase/status is the headline; flatten — no card-in-card; whole-left-column amber + colored left edge per state; copy "queued"; mono values). Generalize for the tactic kinds (standing→per-embryo cadence strip; reactive→watch/reaction/status; scripted→phases). +- Frontend: no JS harness → `node --check` + Chrome-MCP audit across scenario fixtures. +- Git hygiene: stage only your files by explicit path; never `git add -A`. + +--- + +### Task 1: OperationPlan model + FileContextStore domain +**Files:** Create/extend `gently/harness/memory/model.py` (an `OperationPlan`/`Tactic` dataclass or a documented dict schema); Modify `gently/harness/memory/file_store.py` (`set_operation_plan(session_id, plan)` / `get_operation_plan(session_id)`, YAML under `agent/operation_plans/{session_id}.yaml`, fire `CONTEXT_UPDATED`). Test: `tests/test_operation_plan_store.py`. +- [ ] Confirm the real `FileContextStore` domain pattern from `set_session_intent`/`create_session_intent` (~file_store.py:758) + the `_notify_*`/`CONTEXT_UPDATED` emit. Mirror it. +- [ ] TDD: set→get round-trip preserves the tactics list + states; `CONTEXT_UPDATED` fired on set. `pytest tests/test_operation_plan_store.py -v`; `pytest -q` clean. Commit `feat(operations): OperationPlan store domain in FileContextStore`. + +### Task 2: `declare_operation_plan` agent tool (forced typed output) +**Files:** Create `gently/app/tools/operation_plan_tools.py` (+ register in `tools/__init__`). Test: `tests/test_operation_plan_tool.py`. +- [ ] Confirm the `@tool` decorator + the forced-tool pattern (registry.py auto-schema, or a literal schema like notebook_ask.ASK_TOOL). The tool accepts the typed plan (tactics list) and writes it via `store.set_operation_plan`. Resolve the store from context (mirror existing memory tools). +- [ ] TDD: calling the tool with a plan persists it (get returns it) + returns a confirmation; missing store → error. `pytest tests/test_operation_plan_tool.py -v`; `pytest -q` clean. Commit `feat(operations): declare_operation_plan typed agent tool`. + +### Task 3: Route `GET /api/operation_plan/{session_id}` +**Files:** Create `gently/ui/web/routes/operation_plan.py` (+ register in `routes/__init__.py`). Test: `tests/test_operation_plan_route.py`. +- [ ] Mirror `routes/context.py` / `routes/temperature.py` `_resolve_session` + `server.context_store`/`gently_store` resolution. Return the stored plan; 404/empty handled; `session=current` resolves newest. +- [ ] TDD (TestClient + mock store): returns the plan; empty when none. `pytest tests/test_operation_plan_route.py -v`; `pytest -q` clean. Commit `feat(operations): GET /api/operation_plan/{session} route`. + +### Task 4: The operation-spine renderer + scenario library (frontend) +**Files:** Create `gently/ui/web/static/js/operations-scenarios.js` (plan fixtures: temp_strain, expression_onset, hatching_detect, transmission_survey, decided_plan, async_multi, idle); Rewrite the Overview path in `gently/ui/web/static/js/experiment-overview.js` to the data-driven operation-spine (generalized per tactic kind, audit fixes); port CSS to `experiment.css`. Reference: `scratchpad/opsdesign/harness.html`. +- [ ] Port the renderer (renderOperation/renderTactic/renderReadout/renderPhase) + add per-kind rendering: standing→per-embryo cadence strip; reactive→watch/reaction/status; scripted→phase stepper; exclusive/oneshot→compact. Audit fixes baked in. `?scenario=` dev mode loads a fixture. +- [ ] `node --check` both JS; build a Chrome-MCP harness at `scratchpad/opsv3/` (real repo files) for the controller to audit across fixtures. Commit `feat(operations): operation-spine renderer + plan scenario library (data-driven)`. + +### Task 5: Live binding + refresh +**Files:** Modify `experiment-overview.js` (fetch `/api/operation_plan`, bind live telemetry from `/strategy`/get_status onto tactics by `live_bind`, subscribe `CONTEXT_UPDATED` + tactic events → debounced refetch). +- [ ] Bind temperature/current-burst/cadence/signal onto declared tactics' readouts/progress; live refresh on plan-change + telemetry events (debounced). `node --check`; harness check. Commit `feat(operations): live telemetry binding + event-driven refresh`. + +## Self-Review +- Store→Task1; tool→Task2; route→Task3; renderer+scenarios→Task4 (audit fixes); live binding→Task5. ✓ +- Open confirmations: FileContextStore domain pattern (T1), the forced-tool/@tool pattern (T2), the route store-resolution (T3), the Overview render seam + harness renderer (T4), the live telemetry sources (T5). +- Type consistency: the plan/tactic schema is identical across store (T1), tool (T2), route (T3), fixtures+renderer (T4), binding (T5). + +--- +## Execution-linkage tasks (added after recon — close the planning→execution loop) + +### Task 6: `transition_tactic` store helper +**Files:** Modify `gently/harness/memory/file_store.py` (add `transition_tactic(session_id, tactic_id, state=None, **bind)` next to `set/get_operation_plan` ~:818 — read the plan, find the tactic by `id`, set `state` and merge `bind` into its `live`/`structure`, write back, fire `CONTEXT_UPDATED`; no-op if plan/tactic absent). Test: `tests/test_transition_tactic.py`. +- [ ] TDD: declare a plan, transition a tactic planned→active with a `request_id` bind → get shows the new state + bound value; unknown tactic_id → no-op (no crash). Commit `feat(operations): transition_tactic store helper`. + +### Task 7: tactic_id threading + start-edge marking in execution tools +**Files:** Modify `gently/app/tools/timelapse_tools.py` (`enable_monitoring_mode`, `queue_burst`, stop/pause), `gently/app/tools/temperature_protocol_tools.py`, and the burst/protocol event payloads (`exclusive.py` BURST_*, `temperature_protocol.py` TEMP_PROTOCOL_*) to carry an optional `tactic_id`. On execute, the tool calls `cs.transition_tactic(session, tactic_id, 'active')`; on stop/pause → 'done'/'paused'. Test: `tests/test_tool_tactic_linkage.py`. +- [ ] Add optional `tactic_id` param; thread into event `data`; flip the plan tactic active on execute (guard: only if a plan + tactic_id exist). TDD with a fake context store capturing transitions. Commit `feat(operations): link execution tools to plan tactics via tactic_id`. + +### Task 8: `OperationPlanUpdater` service (completion edges via the bus) +**Files:** Create `gently/app/operation_plan_updater.py` (a `Service` modeled on `gently/app/temperature_sampler.py` / `TimelineManager`); wire in `gently/app/agent.py` beside the temperature sampler (~:838-849). Test: `tests/test_operation_plan_updater.py`. +- [ ] Subscribe `BURST_COMPLETE`, `TEMP_PROTOCOL_COMPLETED`, `EMBRYO_CADENCE_CHANGED`, `TRIGGER_FIRED`; on each, resolve the tactic (by `tactic_id` in payload, else by kind+embryo) and `cs.transition_tactic(session, tactic_id, 'done', **bind)` binding live values (request_id/mp4_path/setpoint/cadence). Mirror the sampler's start/stop lifecycle + session_id getter. TDD against a fake bus + store. Commit `feat(operations): OperationPlanUpdater — execution events transition plan tactics`. + +--- +## Plan-item ↔ operation linkage tasks (added — tactics planned at plan time) + +### Task 9: PlanItem/ImagingSpec tactical outline +**Files:** Modify `gently/harness/memory/model.py` (`ImagingSpec` ~:200 / `PlanItem` ~:265 — add optional `tactics: list[dict]` outline field: each entry a lightweight tactic `{kind, name, target?, scope?, structure?}`). Ensure the plan-mode planning tools (`gently/harness/plan_mode/tools/planning.py` create_plan_item/update_plan_item) accept/persist it. Test: extend the plan-item model/store tests. +- [ ] Confirm the real ImagingSpec/PlanItem dataclass + how planning tools set the spec. Add the optional `tactics` outline (default empty), persisted in the campaign plan YAML. TDD: a plan item round-trips its tactics outline. Commit `feat(operations): plan-item tactical outline (plan tactics with the imaging spec)`. + +### Task 10: Operation Plan goal/plan_item linkage + seeding +**Files:** Modify `gently/app/tools/operation_plan_tools.py` (or a small seeding helper / the agent): resolve the current session's `plan_item_id`/`campaign_id`/goal from the `session_intent` (`get_current_session_intent` ~file_store.py:788) → set them on the Operation Plan top-level; when a session linked to a plan item with a `tactics` outline begins (or on first declare), SEED the Operation Plan's `planned` tactics from the outline. Test: `tests/test_operation_plan_seeding.py`. +- [ ] Confirm the session_intent→plan_item linkage accessors. On declare/seed, populate `plan_item_id`/`campaign_id`/`goal` from the linked plan item and seed `planned` tactics from its outline (idempotent — don't clobber tactics already active/done). TDD with a fake store: a session linked to a plan item with an outline produces a seeded Operation Plan. Commit `feat(operations): seed Operation Plan goal + planned tactics from the linked plan item`. diff --git a/docs/superpowers/plans/2026-06-28-tactics-library.md b/docs/superpowers/plans/2026-06-28-tactics-library.md new file mode 100644 index 00000000..a845bfa8 --- /dev/null +++ b/docs/superpowers/plans/2026-06-28-tactics-library.md @@ -0,0 +1,35 @@ +# Tactics Library (G) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development. Steps use `- [ ]`. + +**Goal:** Save / list / apply typed tactics (D's tactic objects) as reusable templates — a near-copy of the plan-template pattern. + +**Architecture:** A tactic-library domain in `FileContextStore` (save/list/get/apply), agent tools, and a read route — mirroring `save_plan_template`/`apply_plan_template`. + +## Global Constraints +- Mirror the plan-template pattern: `_plans.py:551-667` (`save_plan_template`/`list_plan_templates`/`get_plan_template`/`apply_plan_template`) + `plan_mode/tools/templates.py`. +- A saved tactic is a TEMPLATE (planned form): `{id,name,slug,kind,structure,scope_hint?,description,params?,created_at,created_by}` — NO live state. `apply_tactic` returns a fresh planned tactic (new run id, state="planned", no `live`). +- Store domain `agent/tactic_library/{id}_{slug}.yaml`; fire `CONTEXT_UPDATED`. +- Git hygiene: stage only your files by explicit path; never `git add -A`. + +--- + +### Task 1: Tactic-library store domain +**Files:** Modify `gently/harness/memory/file_store.py` (or `_plans.py` if templates live there) — add `save_tactic(tactic, name=None) -> str`, `list_tactics() -> list[dict]`, `get_tactic(id_or_name) -> dict | None`, `apply_tactic(id_or_name) -> dict | None`. Test: `tests/test_tactic_library_store.py`. +- [ ] Confirm the exact plan-template implementation (`_plans.py:551-667`) — the id/slug generation, the YAML write helper, the `_notify`/`CONTEXT_UPDATED` emit — and mirror it for tactics at `agent/tactic_library/`. `apply_tactic` returns a deep copy with a fresh `id` (e.g. `tac_<8hex>`), `state="planned"`, and `live`/run-state stripped. +- [ ] TDD: save a tactic → list shows it → get returns it → apply returns a fresh planned tactic (new id, no live, state=planned); get/apply unknown → None. `pytest tests/test_tactic_library_store.py -v`; `pytest -q` clean. Commit `feat(tactics-library): tactic-library store domain (save/list/get/apply)`. + +### Task 2: Agent tools +**Files:** Create `gently/app/tools/tactic_library_tools.py` (+ register in `tools/__init__`). Test: `tests/test_tactic_library_tools.py`. +- [ ] Mirror `plan_mode/tools/templates.py` (the `@tool` usage + store resolution from context). Tools: `save_tactic(name, tactic, description="")` → `store.save_tactic`; `list_tactics()` → `store.list_tactics`; `apply_tactic(id_or_name)` → `store.apply_tactic` then append the planned tactic to the current Operation Plan (`get_operation_plan(session)` → append → `set_operation_plan`; create a minimal plan if none). Resolve the context store + session from the agent (as `declare_operation_plan` does). Register the module. +- [ ] TDD (fake context store + session): save persists; apply adds a planned tactic to the current Operation Plan; list returns the library; missing store → error. `pytest tests/test_tactic_library_tools.py -v`; `pytest -q` clean. Commit `feat(tactics-library): save/list/apply_tactic agent tools`. + +### Task 3: Route `GET /api/tactic_library` +**Files:** Create `gently/ui/web/routes/tactic_library.py` (+ register in `routes/__init__.py`). Test: `tests/test_tactic_library_route.py`. +- [ ] Mirror `routes/operation_plan.py` — resolve `server.context_store`, return `{tactics: store.list_tactics()}`; empty list when none. Register the router. +- [ ] TDD (TestClient + mock store): returns the library; empty when none. `pytest tests/test_tactic_library_route.py -v`; `pytest -q` clean. Commit `feat(tactics-library): GET /api/tactic_library route`. + +## Self-Review +- Store→Task1; tools→Task2; route→Task3. ✓ +- Open confirmations: the plan-template implementation to mirror (T1), the `@tool` + store/session resolution (T2), the route store handle (T3). +- Type consistency: the saved-tactic dict shape is identical across store (T1), tools (T2), route (T3); `apply_tactic`'s fresh planned tactic matches D's tactic schema. diff --git a/docs/superpowers/plans/2026-06-28-temp-change-burst-tactic.md b/docs/superpowers/plans/2026-06-28-temp-change-burst-tactic.md new file mode 100644 index 00000000..8c740b3f --- /dev/null +++ b/docs/superpowers/plans/2026-06-28-temp-change-burst-tactic.md @@ -0,0 +1,280 @@ +# Temp-Change Burst Tactic (C) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development. Steps use checkbox (`- [ ]`) syntax. + +**Goal:** A scripted temperature-change burst protocol — brightfield bursts before a setpoint change, during the ramp (until lock), and after — launchable as an agent tool, observable on the Experiment tab. + +**Architecture:** A thin async `TimelapseOrchestrator.run_temp_change_burst_protocol` driver composing existing `BurstAcquisition` (extended to force lasers off), `set_temperature`/`get_temperature`, and brightfield primitives; new timeline EventTypes so the tactic + setpoint changes render; an agent tool that launches the driver via `asyncio.create_task`. + +**Tech Stack:** Python asyncio, the gently EventBus + TimelineManager, pytest (`asyncio_mode=auto`). + +## Global Constraints +- C composes A (temperature stamp/persistence) + B1 (`set_laser_config("ALL OFF")`). No new deps. +- Brightfield every burst: `laser_config="ALL OFF"`; lasers never left on, even on error/cancel. +- Lock contract: poll `client.get_temperature()["state"]` until `"LOCKED" in state` (the device reports `'[ SYSTEM LOCKED ]'`). +- Bursts are temperature-stamped automatically (A) and emit `BURST_START/COMPLETE` (render for free). +- New EventTypes use `auto()` (wire serializes `.name`). +- Tests: fakes for client + burst; `asyncio_mode=auto` (no decorator). Rig-deferred: real ramp timing. +- Git hygiene: stage only your files by explicit path; never `git add -A` (pre-existing untracked screenshots/mockups + uv.lock are not yours). + +--- + +### Task 1: Timeline EventTypes for the tactic + +**Files:** Modify `gently/core/event_bus.py` (3 new `auto()` members near the other domain events); Modify `gently/harness/session/timeline.py` (add 3 entries to the EventType→subtype map, near the `BURST_*` entries ~line 213-225). Test: `tests/test_temp_protocol_events.py`. + +**Interfaces:** Produces `EventType.TEMPERATURE_SETPOINT_CHANGED`, `EventType.TEMP_PROTOCOL_STARTED`, `EventType.TEMP_PROTOCOL_COMPLETED`, each mapped to a timeline subtype (`setpoint_changed`, `temp_protocol_started`, `temp_protocol_completed`). + +- [ ] **Step 1: failing test** +```python +# tests/test_temp_protocol_events.py +from gently.core.event_bus import EventType + +def test_new_event_types_exist(): + for n in ("TEMPERATURE_SETPOINT_CHANGED", "TEMP_PROTOCOL_STARTED", "TEMP_PROTOCOL_COMPLETED"): + assert getattr(EventType, n).name == n + +def test_timeline_maps_the_subtypes(): + from gently.harness.session import timeline as tl + src = open(tl.__file__, encoding="utf-8").read() + for sub in ("temp_protocol_started", "temp_protocol_completed", "setpoint_changed"): + assert sub in src +``` +- [ ] **Step 2: run, expect FAIL** — `pytest tests/test_temp_protocol_events.py -v` +- [ ] **Step 3: implement** — in `event_bus.py`, alongside the burst events: +```python + TEMPERATURE_SETPOINT_CHANGED = auto() # discrete setpoint change (timeline) + TEMP_PROTOCOL_STARTED = auto() # temp-change burst protocol began + TEMP_PROTOCOL_COMPLETED = auto() # protocol ended +``` +In `timeline.py`'s map (mirror the `EventType.BURST_START: {...}` entries), add: +```python + EventType.TEMPERATURE_SETPOINT_CHANGED: {"category": "temperature", "event_subtype": "setpoint_changed"}, + EventType.TEMP_PROTOCOL_STARTED: {"category": "tactic", "event_subtype": "temp_protocol_started"}, + EventType.TEMP_PROTOCOL_COMPLETED: {"category": "tactic", "event_subtype": "temp_protocol_completed"}, +``` +> Confirm the real map structure (keys/value shape) from the existing `BURST_START` entry and match it exactly. +- [ ] **Step 4: run, expect PASS**; `pytest -q` no new failures. +- [ ] **Step 5: commit** — `git add gently/core/event_bus.py gently/harness/session/timeline.py tests/test_temp_protocol_events.py && git commit -m "feat(tactic): timeline event types for temp-change burst protocol"` + +--- + +### Task 2: Brightfield burst — thread `laser_config` + +**Files:** Modify `gently/app/orchestration/exclusive.py` (`BurstAcquisition.__init__` ~line 95 add param; its `client.acquire_burst(...)` call ~line 159-168 pass it); Modify `gently/app/orchestration/timelapse.py` (`queue_burst` ~line 2058 add `laser_config=None`, pass to `BurstAcquisition`). Test: `tests/test_burst_laser_config.py`. + +**Interfaces:** Consumes `client.acquire_burst(..., laser_config=...)` (already accepts it, `client.py:642`). Produces `BurstAcquisition(..., laser_config=None)` and `queue_burst(..., laser_config=None)`. + +- [ ] **Step 1: failing test** +```python +# tests/test_burst_laser_config.py +import asyncio +from gently.app.orchestration.exclusive import BurstAcquisition + +class FakeClient: + def __init__(self): self.calls = [] + async def acquire_burst(self, **kw): self.calls.append(kw); return {"success": True, "request_id": "b1", "frames": []} + +async def test_burst_passes_laser_config(monkeypatch): + b = BurstAcquisition("emb1", frames=3, mode="1hz", num_slices=1, laser_config="ALL OFF") + assert b._laser_config == "ALL OFF" + # the run() path forwards _laser_config into client.acquire_burst kwargs: + # (unit-level: assert the attribute + that run threads it — see note) +``` +> NOTE: `BurstAcquisition.run` needs an orchestrator with a client + embryo lookup + persistence; a full run is heavy. Assert at minimum that `__init__` stores `_laser_config` and that the `acquire_burst` call site in `run` includes `laser_config=self._laser_config` (verify by reading; optionally add a focused test that monkeypatches the embryo/persistence to capture the `acquire_burst` kwargs). Keep the test honest — if a full run is impractical, assert the attribute and add an inline source check that `laser_config=self._laser_config` appears in the `acquire_burst(...)` call. +- [ ] **Step 2: run, expect FAIL** +- [ ] **Step 3: implement** — add `laser_config: str | None = None` to `__init__`, store `self._laser_config = laser_config`; in the `client.acquire_burst(...)` call add `laser_config=self._laser_config`. In `queue_burst`, add `laser_config: str | None = None` and pass `laser_config=laser_config` into the `BurstAcquisition(...)` construction. +- [ ] **Step 4: run, expect PASS**; `pytest -q` clean. +- [ ] **Step 5: commit** — `feat(tactic): thread laser_config through BurstAcquisition + queue_burst (brightfield bursts)` + +--- + +### Task 3: `wait_for_temperature_lock` helper + +**Files:** Create `gently/app/orchestration/temperature_protocol.py` (module for the helper + later the driver). Test: `tests/test_wait_for_lock.py`. + +**Interfaces:** Produces `async def wait_for_temperature_lock(client, timeout_s, poll_s=2.0) -> bool`. + +- [ ] **Step 1: failing test** +```python +# tests/test_wait_for_lock.py +from gently.app.orchestration.temperature_protocol import wait_for_temperature_lock + +class FakeClient: + def __init__(self, states): self.states = list(states); self.calls = 0 + async def get_temperature(self): + i = min(self.calls, len(self.states) - 1); self.calls += 1 + return {"state": self.states[i]} + +async def test_returns_true_when_locked(): + c = FakeClient(["[ IDLE ]", "[ HEATING ]", "[ SYSTEM LOCKED ]"]) + assert await wait_for_temperature_lock(c, timeout_s=5.0, poll_s=0.001) is True + +async def test_returns_false_on_timeout(): + c = FakeClient(["[ HEATING ]"]) + assert await wait_for_temperature_lock(c, timeout_s=0.02, poll_s=0.001) is False +``` +- [ ] **Step 2: run, expect FAIL** +- [ ] **Step 3: implement** +```python +# gently/app/orchestration/temperature_protocol.py +import asyncio, logging +logger = logging.getLogger(__name__) + +async def wait_for_temperature_lock(client, timeout_s: float, poll_s: float = 2.0) -> bool: + """Poll the controller until it reports a locked state, or timeout. Substring 'LOCKED'.""" + loop = asyncio.get_event_loop() + t0 = loop.time() + while True: + try: + resp = await client.get_temperature() + except Exception as exc: + logger.warning("wait_for_temperature_lock poll failed: %s", exc); resp = {} + if "LOCKED" in str(resp.get("state", "")): + return True + if loop.time() - t0 >= timeout_s: + return False + await asyncio.sleep(poll_s) +``` +- [ ] **Step 4: run, expect PASS** +- [ ] **Step 5: commit** — `feat(tactic): wait_for_temperature_lock poll helper` + +--- + +### Task 4: The protocol driver + +**Files:** Modify `gently/app/orchestration/temperature_protocol.py` (add the driver fn that takes the orchestrator). Test: `tests/test_temp_protocol_driver.py`. + +**Interfaces:** Produces `async def run_temp_change_burst_protocol(orchestrator, embryo_id, target_setpoint_c, *, frames=60, mode="1hz", num_slices=1, bursts_before=1, bursts_after=1, lock_timeout_s=600.0, poll_s=2.0, burst_runner=None) -> dict`. `burst_runner` is an injectable `async (BurstAcquisition)->None` for tests (defaults to `lambda b: b.run(orchestrator)`). + +- [ ] **Step 1: failing test** +```python +# tests/test_temp_protocol_driver.py +from gently.app.orchestration.temperature_protocol import run_temp_change_burst_protocol +from gently.core.event_bus import EventType + +class FakeClient: + def __init__(self): self.laser=None; self.led=None; self.setpoint=None; self._poll=0 + async def set_laser_config(self, c): self.laser=c + async def set_led(self, s): self.led=s + async def set_temperature(self, t): self.setpoint=t + async def get_temperature(self): + self._poll += 1 + return {"state": "[ SYSTEM LOCKED ]" if self._poll >= 2 else "[ HEATING ]"} + +class FakeOrch: + def __init__(self, client): self._client=client; self._temperature_provider=lambda: None; self.events=[] + @property + def client(self): return self._client + def _emit_event(self, et, data): self.events.append((et, data)) + +async def test_phase_order_and_brightfield(monkeypatch): + client = FakeClient(); orch = FakeOrch(client) + bursts = [] + async def runner(b): bursts.append({"phase": getattr(b, "_phase", None), "laser": b._laser_config}) + res = await run_temp_change_burst_protocol( + orch, "emb1", 25.0, frames=3, bursts_before=1, bursts_after=1, + lock_timeout_s=5.0, poll_s=0.001, burst_runner=runner) + assert client.laser == "ALL OFF" and client.led == "Open" + assert client.setpoint == 25.0 + assert all(b["laser"] == "ALL OFF" for b in bursts) # every burst brightfield + assert len(bursts) >= 3 # before + >=1 during + after + ets = [e[0] for e in orch.events] + assert EventType.TEMP_PROTOCOL_STARTED in ets + assert EventType.TEMPERATURE_SETPOINT_CHANGED in ets + assert EventType.TEMP_PROTOCOL_COMPLETED in ets + assert res["locked"] is True +``` +- [ ] **Step 2: run, expect FAIL** +- [ ] **Step 3: implement** — append to `temperature_protocol.py`: +```python +from gently.app.orchestration.exclusive import BurstAcquisition +from gently.core.event_bus import EventType + +async def run_temp_change_burst_protocol(orchestrator, embryo_id, target_setpoint_c, *, + frames=60, mode="1hz", num_slices=1, bursts_before=1, bursts_after=1, + lock_timeout_s=600.0, poll_s=2.0, burst_runner=None): + client = orchestrator.client + if burst_runner is None: + async def burst_runner(b): await b.run(orchestrator) + + async def one_burst(phase): + b = BurstAcquisition(embryo_id, frames=frames, mode=mode, num_slices=num_slices, + temperature_provider=getattr(orchestrator, "_temperature_provider", None), + laser_config="ALL OFF") + b._phase = phase + await burst_runner(b) + + locked = False; error = None; cancelled = False + try: + await client.set_laser_config("ALL OFF") + await client.set_led("Open") + orchestrator._emit_event(EventType.TEMP_PROTOCOL_STARTED, + {"embryo_id": embryo_id, "target_setpoint_c": target_setpoint_c, + "frames": frames, "bursts_before": bursts_before, "bursts_after": bursts_after}) + for _ in range(bursts_before): + await one_burst("before") + await client.set_temperature(target_setpoint_c) + orchestrator._emit_event(EventType.TEMPERATURE_SETPOINT_CHANGED, + {"embryo_id": embryo_id, "to": target_setpoint_c}) + loop = asyncio.get_event_loop(); t0 = loop.time() + while True: + await one_burst("during") + try: + st = str((await client.get_temperature()).get("state", "")) + except Exception: + st = "" + if "LOCKED" in st: + locked = True; break + if loop.time() - t0 >= lock_timeout_s: + break + for _ in range(bursts_after): + await one_burst("after") + except asyncio.CancelledError: + cancelled = True; raise + except Exception as exc: + error = str(exc); logger.exception("temp-change burst protocol failed") + finally: + orchestrator._emit_event(EventType.TEMP_PROTOCOL_COMPLETED, + {"embryo_id": embryo_id, "locked": locked, "cancelled": cancelled, "error": error}) + return {"locked": locked, "cancelled": cancelled, "error": error} +``` +- [ ] **Step 4: run, expect PASS**; `pytest -q` clean. +- [ ] **Step 5: commit** — `feat(tactic): temp-change burst protocol driver (brightfield before/during/after)` + +--- + +### Task 5: Agent tool + +**Files:** Create `gently/app/tools/temperature_protocol_tools.py` (or add to an existing tools module — follow the `@tool` pattern). Test: `tests/test_temp_protocol_tool.py`. + +**Interfaces:** Produces a `run_temp_change_burst_protocol` agent tool that resolves orchestrator+client from `context`, launches the driver via `asyncio.create_task`, returns a started message; validates embryo/client presence. + +- [ ] **Step 1: failing test** — assert the tool, given a context with a fake orchestrator/client, creates a task and returns a "started" string; given no client, returns an error without creating a task. +> Confirm the real `@tool` decorator + context helpers (`ctx_get(context,"client")`, `require_agent`, `require_timelapse_orchestrator`) from an existing tool (e.g. `gently/app/tools/temperature_tools.py`); mirror them. Write the test to the real registration shape. +- [ ] **Step 2: run, expect FAIL** +- [ ] **Step 3: implement** — mirror an existing tool: resolve `orchestrator` + `client`, guard None (return error dict/string), `asyncio.create_task(run_temp_change_burst_protocol(orchestrator, embryo_id, target_setpoint_c, frames=frames, bursts_before=bursts_before, bursts_after=bursts_after))`, return `f"Temp-change burst protocol started for {embryo_id} → {target_setpoint_c} C"`. +- [ ] **Step 4: run, expect PASS** +- [ ] **Step 5: commit** — `feat(tactic): run_temp_change_burst_protocol agent tool` + +--- + +### Task 6: Observability in strategy_snapshot + +**Files:** Modify `gently/ui/web/strategy_snapshot.py` (`_replay_timeline` ~line 627; mirror the burst-phase handling at ~762/777). Test: `tests/test_temp_protocol_snapshot.py`. + +**Interfaces:** Consumes the timeline subtypes (`temp_protocol_started/completed`, `setpoint_changed`). Produces, in the snapshot: a `temp_protocol` band (open on started, close on completed) and a `setpoint_changes` list. + +- [ ] **Step 1: failing test** — feed `_replay_timeline` (or `build_strategy_snapshot` over a temp `timeline.jsonl`) a sequence: `temp_protocol_started`, `setpoint_changed(to=25)`, `burst_started/completed`, `temp_protocol_completed`; assert the snapshot exposes a temp_protocol span and a setpoint change of 25. +> Confirm the real `_replay_timeline` input/output shape from the existing burst handling; match the snapshot dict structure it already produces. +- [ ] **Step 2: run, expect FAIL** +- [ ] **Step 3: implement** — in `_replay_timeline`, handle the three subtypes: on `temp_protocol_started` open a band (record start t + params); on `temp_protocol_completed` close it; on `setpoint_changed` append `{t, to}` to a `setpoint_changes` list in the snapshot. Surface them in the returned snapshot dict next to the existing phases. +- [ ] **Step 4: run, expect PASS**; `pytest -q` clean. +- [ ] **Step 5: commit** — `feat(tactic): surface temp-protocol band + setpoint changes in strategy snapshot` + +--- + +## Self-Review +- §2.1 brightfield burst → Task 2; §2.2 wait-for-lock → Task 3; §2.3 driver → Task 4; §2.4 events → Tasks 1 & 6; §2.5 tool → Task 5. ✓ +- Open confirmations (explicit): the timeline map value-shape (Task 1), the burst `run` acquire_burst call site (Task 2), the `@tool` + context helpers (Task 5), the `_replay_timeline` shape (Task 6). Each names a fallback. +- Type consistency: `laser_config="ALL OFF"` everywhere; event names `TEMPERATURE_SETPOINT_CHANGED`/`TEMP_PROTOCOL_STARTED`/`TEMP_PROTOCOL_COMPLETED` across Tasks 1/4/6; driver returns `{locked,cancelled,error}`. diff --git a/docs/superpowers/plans/2026-06-29-embryo-roles-observability.md b/docs/superpowers/plans/2026-06-29-embryo-roles-observability.md new file mode 100644 index 00000000..cdbe76dc --- /dev/null +++ b/docs/superpowers/plans/2026-06-29-embryo-roles-observability.md @@ -0,0 +1,43 @@ +# Embryo roles/strain + multi-embryo Operations observability (D2) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development. Steps use `- [ ]`. + +**Goal:** Add a per-embryo `strain` field, refine roles to *use* (add lineaging + a subject/reference `class`), and build the Operations roster lens that reads existing roles + strain. Detector stays on role this pass (full detector→strain separation is tracked future work — spec §4). + +**Architecture:** Reuse gently's existing `role` (`roles.REGISTRY`, `EmbryoState.role`, `embryo.yaml`). Add `strain` alongside role; extend the registry; expose roles to the frontend; render a role+strain roster lens above the operation spine. + +## Global Constraints +- REUSE the existing role concept — do NOT invent a new taxonomy. Extend `gently/harness/roles.py:REGISTRY` (`EmbryoRole` dataclass) and read role via the existing accessors (`EmbryoState.role`, `EmbryoInfo["role"]`, `get_role`/`REGISTRY.get`, `/api/embryos/positions`). +- Strain is a FREE-FORM string per embryo (no registry this pass). Coexists with plan-level strain/genotype overrides. +- Roles render with their REAL `REGISTRY` `ui_color`/`ui_icon` (magenta test, cyan calibration, lineaging's own) — never invented colors. +- `class` ('subject'|'reference') is an attribute ON `EmbryoRole`, derived from role; test/unassigned→subject, calibration/lineaging→reference. +- Backward compatible: embryos without strain → None; plans without role-scope render as D today. +- Detector stays wired to role THIS PASS (spec §4 documents the full separation as future work — do not implement it here). +- Git hygiene: stage only your files by explicit path; never `git add -A`. + +--- + +### Task 1: Per-embryo `strain` field +**Files:** Modify `gently/core/store_types.py` (`EmbryoInfo` — add `strain: str | None`); `gently/harness/state.py` (`EmbryoState` — add `strain: str | None = None`); `gently/core/file_store.py` (`register_embryo` — accept `strain=None`, write/coalesce it in embryo.yaml like `role`); `gently/ui/web/routes/data.py` (`/api/embryos/positions` ~:689 — add `"strain": emb.get("strain")`). Test: `tests/test_embryo_strain.py`. +- [ ] Confirm how `role` is threaded through `register_embryo` (`file_store.py:507-580`, write at :576, coalesce at :564) and EmbryoState (`state.py:138`); mirror it for `strain`. Confirm the positions endpoint shape (`data.py:661-696`). +- [ ] TDD: register an embryo with `strain="pan-nuclear GFP"` → get_embryo returns it; update coalesces; absent → None; positions endpoint includes strain. `pytest tests/test_embryo_strain.py -v`; `pytest -q` clean. Commit `feat(d2): per-embryo strain field`. + +### Task 2: Roles refined — lineaging + subject/reference class +**Files:** Modify `gently/harness/roles.py` (add `class_: str = "subject"` — or `klass`/`role_class` to avoid the `class` keyword — to `EmbryoRole`; add a `lineaging` entry to `REGISTRY`; set `class_` on each role: test/unassigned→subject, calibration/lineaging→reference; give lineaging its own ui_color/ui_icon/default_cadence/detector kept None or nuclear like calibration). Test: `tests/test_roles_registry.py` (extend if exists). +- [ ] Add the `class_` field (default "subject") to `EmbryoRole`; set it on all REGISTRY entries; add `lineaging` (reference, distinct ui_color e.g. a teal/green, ui_icon, cadence). Keep `detector_name` as-is on each role (staged). Confirm nothing else constructs EmbryoRole positionally in a way the new field breaks. +- [ ] TDD: `REGISTRY["lineaging"].class_=="reference"`; `REGISTRY["test"].class_=="subject"`; `REGISTRY["calibration"].class_=="reference"`; `get_role("lineaging")` works; existing roles/fields unchanged. `pytest tests/test_roles_registry.py -v`; `pytest -q` clean. Commit `feat(d2): roles-as-use — add lineaging + subject/reference class`. + +### Task 3: `/api/roles` route + role-scoped tactic scope +**Files:** Create `gently/ui/web/routes/roles.py` (`GET /api/roles` → `{roles:[{name,description,class_,ui_color,ui_icon,default_cadence_seconds}]}` from `list_roles()`/REGISTRY; register in `routes/__init__.py`). Modify `gently/app/tools/operation_plan_tools.py` (allow `scope.mode=='role'` + `scope.role` in validation — accept a REGISTRY key); add a pure resolver `resolve_scope_embryos(scope, roster_or_embryos) -> list[str]` (in a small module or operation_plan_tools) mapping mode=role→embryo_ids by role. Test: `tests/test_roles_route.py`, `tests/test_role_scope.py`. +- [ ] `/api/roles` mirrors `routes/tactic_library.py` (simple list route, graceful). The resolver maps `{mode:'role',role:'test'}` against a list of embryos-with-roles → the matching ids; global→all, embryos→explicit. Validation accepts mode=role with a valid role key. +- [ ] TDD: route returns the registry incl. lineaging + class_; resolver resolves role→ids, global→all, embryos→explicit, unknown role→[]. `pytest tests/test_roles_route.py tests/test_role_scope.py -v`; `pytest -q` clean. Commit `feat(d2): /api/roles route + role-scoped tactic scope resolver`. + +### Task 4: Operations roster lens (frontend) +**Files:** Modify `gently/ui/web/static/js/experiment-overview.js` (add a roster lens above the operation spine: fetch `/api/embryos/positions` + `/api/roles`, group embryos by role `class_` (Subjects foregrounded, References compact) then by role, each row `id · role chip (REGISTRY ui_color/ui_icon) · strain · cadence-phase chip · current tactic (from the plan's role-scoped tactics) · state`; render tactic-node scope by role using the resolver/role labels); `gently/ui/web/static/css/experiment.css` (the `.ops-roster*` classes, using the role colors from the API, not hardcoded). Reference: the validated prototype `scratchpad/d2proto/index.html` (regrounded to real role colors). +- [ ] Build the roster lens reading the real endpoints + role metadata (colors from `/api/roles`, not invented); class split → role groups → strain; spine nodes show role-scope ("→ test · E01.."). Backward compat: no embryos/roles → omit the lens, spine renders as D. `node --check`; build/refresh the opsv3 (or d2) Chrome harness with the real files for the controller to audit. Commit `feat(d2): Operations roster lens — embryos by role + strain`. + +## Self-Review +- Strain→T1; roles/class→T2; roles route + role-scope→T3; roster lens→T4. ✓ +- Open confirmations: register_embryo/EmbryoState role threading (T1), EmbryoRole construction sites (T2), the route/resolver pattern (T3), the embryos+roles endpoints + plan cross-reference for current-tactic (T4). +- Type consistency: `strain` str|None across model/store/endpoint; `class_` on EmbryoRole + in /api/roles + read by the renderer; role keys consistent across REGISTRY, scope.role, resolver, renderer. +- Staged: detector stays on role (spec §4 future work referenced, not implemented). diff --git a/docs/superpowers/plans/2026-06-29-manual-mode-dual-camera.md b/docs/superpowers/plans/2026-06-29-manual-mode-dual-camera.md new file mode 100644 index 00000000..96e4c141 --- /dev/null +++ b/docs/superpowers/plans/2026-06-29-manual-mode-dual-camera.md @@ -0,0 +1,38 @@ +# Manual mode B2 — dual-camera + laser-preset browser + timelapse form Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development. Steps use `- [ ]`. + +**Goal:** Extend B1's single-camera manual mode with a laser-preset browser, dual-camera (side A/B) config, and a manual timelapse-config form — building the headless parts now (live dual-view + real acquisition are rig-deferred). + +**Architecture:** New `require_control` proxy routes wrapping existing client/device-layer + agent-tool paths; device_factory registers a second camera defensively; UI additions to `#devices-view-manual` / `devices.js`. + +## Global Constraints +- HEADLESS-buildable parts only; mark RIG-DEFERRED parts (live dual view, real acquisition/timelapse) as noted in the spec — don't fake hardware. +- Backward compatible: single-camera rigs must still start (defensive HamCam2 registration); the manual-view-entry laser-off safety (B1 I3) stays intact. +- Laser preset list already exists: `GET /api/devices/laser/configs` (data.py:526). Reuse it; add only the set proxy + UI. +- Proxy routes mirror the existing `routes/data.py` `require_control` pattern (e.g. `/api/devices/laser/off` :508). Device-layer/client calls mirror existing ones. +- UI extends `#devices-view-manual` (index.html ~:720-835) + `DevicesManager` in `devices.js`. +- Git hygiene: stage only your files by explicit path; never `git add -A`. + +--- + +### Task 1: Laser-preset browser +**Files:** Modify `gently/ui/web/routes/data.py` (add `POST /api/devices/laser/config` `require_control` → `client.set_laser_config(name)`, mirror `/laser/off` :508); `gently/ui/web/templates/index.html` (the Illumination group ~:800 — replace the static `#devices-ls-laser-status` indicator with a `` populated from `GET /api/devices/laser/configs`; on change POST the + chosen preset. Keep "ALL OFF" the safe default + the existing manual-view-entry laser-off safety (don't + remove the I3 guard — selecting a preset is an explicit user action). +- **Rig-deferred:** the actual laser firing (the preset just calls `setConfig` on the rig). + +## 2. Dual-camera config +- **Backend (headless):** register a second `DiSPIMCamera("HamCam2")` as `devices["camera_b"]` in + `device_factory.py` — DEFENSIVELY (only if the camera is in the core's loaded devices; skip + log + otherwise, so single-camera rigs still start). Add a `side` field ('A'|'B') to `_ls_params` + + `handle_lightsheet_params`; `_ensure_lightsheet_sequence_sync` picks `camera` vs `camera_b` by side and + restarts the sequence on side change (reuse the exposure-change restart path). New `GET /api/devices/cameras` + endpoint listing available camera roles (A always; B if registered). +- **UI (headless):** a "Side A / B" selector in the manual rail → carries `side` on the live/params POST. +- **Rig-deferred:** live DUAL view via the "Multi Camera" fusion device (live-only) + dual-side acquisition + (two parallel `startSequenceAcquisition` + tag demux). v1 = single live stream, switchable side. + +## 3. Timelapse config form +- **Backend (headless):** new `POST /api/devices/timelapse/start` (`require_control`) proxy wrapping the + agent path `start_adaptive_timelapse(embryo_ids, stop_condition, interval_seconds, condition_value, + monitoring_mode)` (validate params; resolve the orchestrator like the agent tool does). The volume + geometry (num_slices/exposure/galvo±/piezo±/laser_config/power) is captured in the form + passed through + / persisted to per-embryo calibration where applicable. +- **UI (headless):** a collapsible "Timelapse" panel in the manual rail gathering cadence/stop/embryos/ + monitoring_mode + the volume geometry, reading `GET /api/devices/scan_geometry` + `/api/devices/laser/configs` + for defaults. A "Start timelapse" submit → the new proxy. +- **Rig-deferred:** the actual timelapse run + galvo/piezo motion. + +## 4. Out of scope / deferred +- Live Multi-Camera dual view + dual-side acquisition demux (rig). +- Saving timelapse configs as reusable presets (could reuse the tactic-library/plan-template later). +- Per-line laser power UI beyond the preset (the clamps in `optical.py` still apply). + +## 5. Testing +- Laser-preset: the POST proxy (TestClient + mock client asserts `set_laser_config(name)`); `node --check` + + Chrome audit of the dropdown populated from a stubbed configs endpoint. +- Dual-camera: device_factory registers camera_b against a FAKE core that has HamCam2 (and skips when + absent); the `side` param threads into `_ls_params` + selects the camera; `/api/devices/cameras` lists + roles; `node --check` + Chrome audit of the side selector. +- Timelapse: the start proxy validates + calls the orchestrator path (mock); the form gathers + posts the + params; `node --check` + Chrome audit of the form. +- All three: backward compatible (single-camera rig unaffected; the laser-off safety intact). diff --git a/docs/superpowers/specs/2026-06-29-session-plan-linking-design.md b/docs/superpowers/specs/2026-06-29-session-plan-linking-design.md new file mode 100644 index 00000000..4b3be8f6 --- /dev/null +++ b/docs/superpowers/specs/2026-06-29-session-plan-linking-design.md @@ -0,0 +1,64 @@ +# Design: Session ↔ plans link/delink (sub-project F) + +Status: design 2026-06-29 (after recon + user steering). Branch `feature/session-plan-linking` (off D2). +Lets a session link to MULTIPLE plan items, with link/delink from BOTH the Plans tab and the session +view. "Repo/base plans" is DEFERRED (user has ideas — separate follow-on). + +## 0. What exists (recon) +- Session↔**campaign**: many-to-many (`SessionIntent.campaign_ids` list; `link/unlink_session_campaign`). +- Session↔**plan-item**: `PlanItem.session_ids` is a LIST (a session can already appear under multiple + items in storage) — but a session's *own* notion of "its plans" is a single `active_plan_item_id` + pointer, and `SessionIntent` stores no plan-item ids. +- `attach_session_to_plan` appends to `item.session_ids` (via `link_plan_item_session`) but overwrites + the single active pointer; `detach_session_from_plan` only clears the pointer (NOT a data delink). +- Plans tab (`campaigns.js:789-802`) shows a per-item read-only Sessions list ("No linked sessions"). +- NO link/delink endpoint or UI anywhere; NO `unlink_plan_item_session`; session endpoint returns no linkage. + +## 1. The model — source of truth = `PlanItem.session_ids` +A session's linked plan items = the reverse query over plan items whose `session_ids` includes the +session. No new field on SessionIntent (avoids dual source of truth). Multi-plan falls out naturally +(a session can be in many items' `session_ids`). The campaign edge stays on `SessionIntent.campaign_ids`. + +- **Link** session↔plan-item: `link_plan_item_session(item_id, session_id)` (exists, appends) + + `link_session_campaign(session_id, item.campaign_id)` (exists). +- **Delink**: NEW `unlink_plan_item_session(item_id, session_id)` — remove the session from + `item.session_ids` (+ clear the back-compat `session_id` if it pointed there); fire `_notify_plan_change`. + Campaign edge: leave it unless no other item of that campaign links the session (refinement — for v1, + delink only touches the plan-item edge; campaign delink stays the existing separate control). +- **Session's plans**: NEW `get_plan_items_for_session(session_id) -> list[PlanItem]` (reverse query + across `get_active_campaigns` → `get_plan_items` → filter `session_id in item.session_ids`). + +## 2. Endpoints (mirror `routes/campaigns.py`) +- `POST /api/campaigns/{cid}/items/{item_id}/sessions` body `{session_id}` → link (link_plan_item_session + + link_session_campaign). Returns the updated item sessions. +- `DELETE /api/campaigns/{cid}/items/{item_id}/sessions/{session_id}` → delink (unlink_plan_item_session). +- `GET /api/sessions/{id}/plans` → the session's linked plan items (id, title, campaign_id, status) via + `get_plan_items_for_session`. (A new sub-route; leaves the existing session payload untouched.) + +## 3. UI — both surfaces +### 3.1 Plans tab item-detail (`campaigns.js` ~:789-802) +The existing per-item Sessions list gains: a **[+ link session]** control (a picker of recent sessions +from `/api/sessions`) and a **[delink]** button per listed session. Calls the POST/DELETE endpoints, +re-renders the item detail. Empty state keeps "No linked sessions" + the link control. + +### 3.2 Session / Operations view — "Linked plans" panel +A panel (in the Operations/experiment view header or a session detail strip) listing the session's +linked plan items (from `/api/sessions/{id}/plans`): each row `plan item title · campaign · status · +[delink]`, plus **[+ link to a plan]** (a picker of plan items from the active campaigns). Symmetric +with 3.1 — link/delink from either side; both hit the same endpoints + refresh. + +## 4. Out of scope (deferred) +- **Repo/base plans** — the user has ideas; a separate follow-on (a repo plans library / seed). Noted, + not built here. +- Campaign-edge auto-cleanup on plan delink (v1 leaves the campaign link; refine later). +- Reworking `attach_session_to_plan`/`detach` agent tools beyond what's needed — the data-layer + delink (`unlink_plan_item_session`) is added; wiring a `detach` that calls it is a small optional add. + +## 5. Testing +- Data layer: `unlink_plan_item_session` removes the session (+ back-compat session_id); idempotent on + absent; `get_plan_items_for_session` reverse-query returns the right items across campaigns; multi-plan + (a session under 2 items) round-trips. +- Endpoints: POST links (item.session_ids gains it + campaign linked); DELETE delinks; GET returns the + session's plans; mirror `tests/test_*route*` with a mock store. +- UI: link/delink controls on both surfaces (node --check + Chrome audit of the Plans-tab item detail + + the session "Linked plans" panel); link from one side shows on the other after refresh. diff --git a/docs/superpowers/specs/2026-06-30-bottom-cam-operator-surface-design.md b/docs/superpowers/specs/2026-06-30-bottom-cam-operator-surface-design.md new file mode 100644 index 00000000..4228e84b --- /dev/null +++ b/docs/superpowers/specs/2026-06-30-bottom-cam-operator-surface-design.md @@ -0,0 +1,123 @@ +# Bottom-cam → SPIM Operator Surface ("Operate" view) — Design + +Date: 2026-06-30 +Status: Approved (proceed to implementation) +Branch: feature/temperature-operations-all + +## Purpose + +A single, professional, guided operator surface for the manual bottom-camera → +SPIM acquisition workflow, replacing the scattered current UX (bottom cam on the +Map view, SPIM controls on the Manual view, embryo marking on the Embryos page). +Mirrors how the operator physically works the rig: + +1. Focus the bottom objective on the embryos. +2. Mark **all** embryos in one pass (a single FOV holds them). +3. Per embryo: center → lower the SPIM head → focus the SPIM (LED on) → acquire. + +This is sub-project **A** (the spine). Two data flywheels hang off it and are +specified separately: **B** marking→localization labels (retire SAM), **C** +manual-focus→autofocus-validation. A exposes the hook points (confirm, focus +score) they will tap, but does not implement them. + +## Settled decisions + +- **Home:** a new `Operate` view in the device tab, alongside Map/Details/3D/Manual. + One purpose per view: Map stays a passive spatial monitor; Manual stays raw + knobs. The Detect/Center/enlarge controls already added to Map migrate to Operate; + Map keeps only read-only embryo dots. +- **Focus control:** software **nudge** buttons (± fixed steps), hard-fenced to + the axis limits (F-drive floor 30 µm). **No autofocus** (objective-crash risk). + Live focus-score readout to assist. +- **Marking:** batch — mark all embryos on one frozen full-res frame. **Positions + only, no roles.** Roles are a separate, later, optional concern. +- **SPIM focus step:** inline in Operate (lightsheet live + galvo/piezo/LED nudges), + not a handoff to Manual. +- **Single source of truth:** the canonical `experiment.embryos` list (already wired + via EMBRYOS_UPDATE / /api/embryos/current). Detect feeds it through a + human-confirm step, not a side list. +- **UI quality:** treat as a design pass (frontend-design), not a port of the + amateur marking canvas. + +## Architecture + +New device-tab view `operate` (devices.js view list becomes +`['operate','map','details','optical3d','manual']`), three vertical zones: + +1. **Survey** — enlarged bottom-cam live; bottom-Z focus nudge (fenced) + live + focus score; Detect (SAM → candidates) or click-to-mark on a frozen frame; + Confirm. +2. **Embryos** — the one canonical list, each row with a state chip and select. +3. **Acquire** (selected embryo) — Center → Lower SPIM head (F-drive, fenced) → + inline lightsheet live + galvo/piezo/LED nudges + focus score → Acquire volume. + +Per-embryo state machine (client-side, keyed by embryo id; persistence deferred): + +``` +marked ──Center──▶ centered ──Lower SPIM + focus──▶ focused ──Acquire──▶ imaged +``` + +### Component reuse + +| Need | Reuse | New | +|---|---|---| +| Bottom-cam live + enlarge | camera panel (built) | move into Operate | +| Mark-all on frozen frame | MarkingManager interaction logic | re-homed canvas, redesigned, positions-only | +| Embryo list + Center | SSOT list + stage/move (built) | per-embryo state chips | +| SPIM focus | Manual galvo/piezo/LED/lightsheet-live endpoints | inline placement | +| Acquire | /api/devices/acquire/volume | — | +| Bottom-Z + F-drive nudge | DiSPIMZstage / DiSPIMFDrive device classes | device-factory wiring, polling, fenced endpoints | +| Focus score | analysis/core.calculate_focus_score | inject into camera stream payloads | +| Register marks (agent-free) | experiment.add_embryo | register-on-confirm endpoint | + +## New backend endpoints (web routes proxy → device layer) + +- `GET /api/devices/stage/bottom_z` · `POST /api/devices/stage/bottom_z/nudge {delta}` + — read + fenced nudge of the bottom-camera focus Z (DiSPIMZstage). +- `GET /api/devices/spim/fdrive` · `POST /api/devices/spim/fdrive/nudge {delta}` + — read + fenced nudge of the SPIM-head F-drive (floor 30 µm; report distance-to-floor). +- `POST /api/devices/detect_embryos` (revised) — return SAM candidates + `{embryos:[{pixel_x,pixel_y,stage_x_um,stage_y_um,confidence}], stage_position}`; + no auto-register. +- `POST /api/devices/embryos/confirm {markers:[{pixel_x,pixel_y}], stage_position, + pixel_size_um, objective_mag}` — pixel→stage, register each into experiment.embryos + (role 'unassigned'), fire EMBRYOS_UPDATE. Agent-free. +- Focus score injected into existing bottom-cam + lightsheet SSE payloads + (`focus_score` field), computed server-side on the full frame. + +Device-layer additions: instantiate DiSPIMZstage in the device factory when +present; add bottom-Z and F-drive to the slow position poller; fenced nudge +handlers (clamp/reject out-of-range). + +## Safety / error handling + +- All Z moves fenced server-side; device classes hard-enforce limits; out-of-range + → 4xx, surfaced in UI. Nudges are bounded single steps — no autonomous/repeated + moves. +- Device layer down → 503; controls disabled with clear state. +- Frozen-frame capture failure → error toast, stay in Survey. Confirm with zero + markers → disabled. +- F-drive: never below floor; show distance-to-floor. + +## Testing + +- Backend (TDD): pixel→stage in register-on-confirm (reuse coordinate tests); + fenced Z endpoints reject out-of-range; focus-score payload shape. +- Frontend: launch with the gently_perception shim; Chrome MCP drives + detect→mark→confirm→EMBRYOS_UPDATE, per-embryo state transitions, out-of-range + nudge blocked; screenshots + UI audit against the professional bar. +- Rig-only: real Z moves, real SPIM focus, SAM on a live frame. + +## Out of scope (separate sub-projects) + +- B: persist (frame + pixel markers + roles) as localization labels; benchmark + classical/trained detectors vs SAM. +- C: poll missing Z axes for passive focus-trace logging + offline validator. + (A wires the Z axes for read/move, which C extends to logging.) + +## MVP boundary + +Ship the Operate view end-to-end with maximal reuse: Survey (live + fenced bottom-Z ++ Detect/mark-all + Confirm), the SSOT list with state chips + Center, Acquire zone +(F-drive nudge + inline SPIM focus controls + Acquire). Per-embryo state client-side. +Defer label/focus-trace persistence (B/C). diff --git a/docs/superpowers/specs/2026-06-30-operate-tactics-integration-design.md b/docs/superpowers/specs/2026-06-30-operate-tactics-integration-design.md new file mode 100644 index 00000000..0b950d34 --- /dev/null +++ b/docs/superpowers/specs/2026-06-30-operate-tactics-integration-design.md @@ -0,0 +1,130 @@ +# Operate → Tactics/Timelapse Integration ("Phase C: Run") — Design + +Date: 2026-06-30 +Status: Approved (build all 3 phases) +Branch: feature/temperature-operations-all +Source: Opus expert workflow (tactics + timelapse + agent/resolution + UI study → 3 candidates → synthesis) + +## Problem + +The Operate view dead-ends: `confirmMarks()` registers positions-only embryos +(role `unassigned`) into `experiment.embryos` (the SSOT), and `onEmbryosUpdate` +auto-dives into the manual per-embryo loop. There is no path from "embryos +marked" to the agentic timelapse or to a tactic/plan. + +Underneath, all four imaging surfaces (Operate manual loop, Manual-view timelapse +form, agent tools, Operations spine) already share **one engine** +(`TimelapseOrchestrator`) and **one language** (the per-session *Operation Plan* +of **tactics**). Operate is wired to none of them. `resolve_scope_embryos` +(role_scope.py) — the scope→embryos resolver — has zero callers; it was built for +exactly this hand-off. + +## Decisions (user) + +- **Build all three phases** (not just the first slice). +- **Adaptive timelapse default monitoring mode = `idle`** (pure fixed-cadence; + operator opts into expression/pre-terminal monitoring explicitly). +- **Live run is monitored in-Operate** (the rail flips to a compact read-only + run-spine), with a deep-link to the Operations tab. + +## Design — Phase C "Run" on the Operator Spine + +Stepper gains a third node: **① Focus → ② Mark → ③ Run.** After Confirm, instead +of auto-diving into the manual loop, the stepper advances to ③ Run and the right +rail shows a **Run chooser**. The **tactic** is the single unifying object: every +run mode emits exactly one tactic scoped to the marked set +(`scope.mode='embryos', embryo_ids=[marked]`). + +| Mode | Behavior | Tactic kind | +|---|---|---| +| **A — Manual one-by-one** | the existing Phase-B b1–b5 loop, now reached from the chooser | `oneshot` (cosmetic; keeps the spine coherent) | +| **B — Adaptive timelapse** | inline form: interval, stop condition, monitoring_mode (default **idle**); reuses `/api/devices/timelapse/start` with explicit `embryo_ids=[marked]` | `standing_timelapse` (+ optional `reactive_monitor` layered on) | +| **C1 — From library** | apply a saved tactic, scope re-pointed to the marked set | template's kind, via `apply_tactic` | +| **C2 — Continue a plan** | resume_plan candidate → `execute_plan_item(item_ref, embryo_ids=[marked])` + `seed_operation_plan_from_plan_item` | tactics seeded from `ImagingSpec.tactics` | +| **C3 — Hand to agent** | open AgentChat preloaded with the roster; agent authors + starts the plan | agent-authored via `declare_operation_plan` | + +**Running:** the rail flips to a compact read-only **run-spine** (reuse +`experiment-overview.js` `_renderOpsTactic`/`_renderOperationSpine`): state-colored +tactic cards + live readouts + **Pause/Stop** (`pause_timelapse`/`stop_timelapse`) ++ **Open in Operations** deep-link. Optional `set_autonomy('ask'|'auto')`. + +**Roles wrinkle (load-bearing):** marking stays positions-only, but +`expression_monitoring` rules scope to `role=='test'` (subject) — so role-scoped +monitoring matches zero just-marked embryos unless roles are set. The Run chooser +shows a **role chip strip** (all marked default to **Subject**, flip any to +**Reference**); choosing a non-manual mode applies roles via a new thin +`POST /api/embryos/roles`. Roles are assigned **at Run, not at marking** — +consistent with the "marking is positions-only" rule. + +## Keystone new component: the Tactic Executor + +`gently/app/orchestration/tactic_executor.py` — +`execute_tactic(session, tactic)`: `resolve_scope_embryos(scope, roster)` → +dispatch by `kind` to `orchestrator.start` / `enable_monitoring_mode` / +`queue_burst` (later `acquire_volume` / temp protocols), threading `tactic_id` → +`transition_tactic('active')` + merge live binds. This makes the tactics language +*executable* (not just descriptive) and is `resolve_scope_embryos`'s first caller. +It centralizes the `kind`→tool mapping the agent also uses (no duplication). + +## Integration points (verified code seams) + +- `operate.js` `onEmbryosUpdate` — stop auto-selecting embryo 1; on first confirm + advance stepper to ③ Run + render the chooser. +- `operate.js` `renderStep` single-driver — host Phase C chooser + live run-spine + as new render branches (`data-active='c0'` / running) without disturbing a1/b*. +- `POST /api/devices/embryos/confirm` (`data.py`) — unchanged (positions-only SSOT). +- `POST /api/devices/timelapse/start` (`data.py`) — Mode B reuses verbatim with + `embryo_ids=[marked]`; Phase 2 ADDS tactic seeding (closes the `data.py:~1004` + plan-auto-link TODO). `volume_geometry` stays NOT forwarded (RIG-DEFERRED). +- `TimelapseOrchestrator.start/enable_monitoring_mode/queue_burst` — the engine + the executor dispatches into; holds marked `EmbryoState` refs (zero copy). +- `resolve_scope_embryos` (role_scope.py) — Tactic Executor is its first caller. +- `start_adaptive_timelapse` (timelapse_tools.py) — add `tactic_id` for lifecycle + symmetry (the other start/stop tools already have it). +- `OperationPlanUpdater` — already maps BURST_COMPLETE→done, + EMBRYO_CADENCE_CHANGED/TRIGGER_FIRED→bind; drives the run-spine once tactics + carry `tactic_id`. +- resolution dispatch (`bridge._dispatch_resolution_pick`) + `execute_plan_item` + + `seed_operation_plan_from_plan_item` — Modes C2/C3 + session guard. +- `experiment-overview.js` `_renderOperationSpine`/`_renderOpsTactic` — reused for + the in-Operate run-spine. +- `AgentChat.togglePanel`/`runCommand` — Mode C3. + +## New backend + +1. **Tactic Executor** (`gently/app/orchestration/tactic_executor.py`) — the keystone (above) + unit tests. +2. **`POST /api/embryos/roles`** (thin) — reuse `assign_embryo_roles` internals → set `EmbryoState.role` + fire EMBRYOS_UPDATE. Default marked→subject mandatory. +3. **Tactic seeding on `/api/devices/timelapse/start`** — declare+seed `standing_timelapse` (+ `reactive_monitor`) and transition active (closes the TODO). Additive, minimal, idempotent. +4. **`tactic_id` on `start_adaptive_timelapse`** — lifecycle symmetry. +5. **Tactic structure schema extension** (`operation_plan_tools._validate_tactics`) — add `stop_condition`/`condition_value`/`monitoring_mode`/`interval` to `standing_timelapse`/`reactive_monitor` so a tactic is self-describing for the executor. +6. **Session guard** — ensure a live session/orchestrator before Phase C runs (orchestrator is None without one); reuse `should_enter_resolution`/bootstrap. + +## Phasing + +- **Phase 1 (cheap slice):** Operate Phase C scaffold (③ Run node, stop auto-dive, + Run chooser) + **Mode B** (reuses `/timelapse/start`, no new backend) + thin + `POST /api/embryos/roles` + role chip strip + in-Operate run-spine. Working + marking→adaptive-timelapse hand-off. +- **Phase 2 (tactics integrity):** tactic seeding on `/timelapse/start`; + `tactic_id` on `start_adaptive_timelapse`; tactic structure schema extension; + Mode A `oneshot`. +- **Phase 3 (keystone + breadth):** Tactic Executor (+ tests) powering Mode C1 + (library); Mode C2 (continue a plan); Mode C3 (hand to agent); `set_autonomy` + in the run-spine. + +## Testing + +- Backend (TDD): Tactic Executor (scope resolution + kind dispatch, mocked + orchestrator); roles route; schema validator extension. +- Frontend: shim + Chrome MCP — drive mark→Confirm→Run chooser→role assign→Mode B + start→run-spine; verify stepper/chooser/run-spine; UI audit. +- Adversarial code-review workflow over the full diff before merge. + +## Rig-only / honesty flags + +Real stage motion + acquisition stay RIG-DEFERRED (orchestrator calls +`client.acquire_volume` directly; "Bluesky" framing is aspirational). +`volume_geometry` not forwarded by `/timelapse/start`. The `oneshot` manual tactic +is cosmetic (no orchestrator mechanism backs it). Timelapse start needs a live +session/orchestrator — unavailable in the hardware-free shim, so Mode B's actual +start is rig/session-verified; the UI flow + tactic emission are shim-verifiable. diff --git a/docs/superpowers/specs/2026-07-01-settings-panel-thermalizer-config-design.md b/docs/superpowers/specs/2026-07-01-settings-panel-thermalizer-config-design.md new file mode 100644 index 00000000..8527455b --- /dev/null +++ b/docs/superpowers/specs/2026-07-01-settings-panel-thermalizer-config-design.md @@ -0,0 +1,60 @@ +# Settings Panel — editable ACUITYnano thermalizer + config visibility — Design + +Date: 2026-07-01 +Status: Approved (build all phases) +Branch: feature/temperature-operations-all (→ #72) +Source: Opus audit workflow + two implementation-reference passes. + +## Problem / audit + +The gently "Settings" panel is **100% client-side display preferences** — every control writes browser `localStorage` (`gently-dashboard-config` + `gently-theme`); `settings.js` makes **zero** backend calls. The **ACUITYnano thermalizer connection** (serial COM / MQTT-HiveMQ / mock) is in **no GUI** — it lives in `config/config.yml` `temperature:`, read once at device-layer boot; changing transport/port/creds = edit YAML + restart. Naming trap: the Vitals "Temperature model (20/25 °C)" radio is a *developmental-timing reference curve*, not the hardware setpoint. + +Two-process: viz (FastAPI :8080) proxies to the device layer (aiohttp :60610) via `DiSPIMClient`; the controller lives only in the device-layer process. + +## Decisions (user) + +- Build **all phases**. +- Apply mode: **try live hot-swap, fall back to restart-required**. +- **Mock-SIM: dev/debug only** — not selectable in the production panel. + +## Design + +New server-backed **"Hardware / Thermalizer"** section (separate from the localStorage panel; explicit "machine-wide, saved on the server" note). Fields grounded in `temperature.py`: +- Backend radio **Serial | MQTT (HiveMQ)** (Mock hidden unless a dev flag). Serial: `com_port` (required), `baud_rate` (115200). MQTT: `broker`, `port` (8883), `user`, `password` (write-only, `••••`, never echoed; blank = embedded HiveMQ SIM). Common: `stabilize_timeout` (600), `feedback_peltier`. +- **Test connection** (non-committing): build transient backend → `read()`/`get_system_state` → `close()` → report; never swaps the live device. +- **Apply**: live hot-swap — build the NEW controller first (`create_temperature_controller`), keep the old on failure, swap `self.devices["temperature"]`, `old.close()`. Guard: **409 if `self.RE.state != "idle"`** or a `set()` worker holds the controller lock (mid-ramp/mid-plan swap corrupts a live `bps.mv(temperature,…)`). If teardown/rebuild can't apply live, persist + "restart the device layer to apply" banner. +- **Persistence**: sidecar `config/config.local.yml` (`temperature:` block) merged over `config.yml` at device-layer boot — preserves `config.yml`'s comments; password written only when a new non-redacted value is submitted. + +**Effective-config viewer** (Phase 2, read-only, secrets redacted): ports/hosts, model IDs, storage base_path + derived dirs, mmconfig/mmdirectory, organism/hardware, switchbot name, coverslip, XY safety envelope (from the live device-state stream), mesh instance-id + cert fingerprint, timeouts, ML params, `ux_v2`. **Never expose** (redact/omit): `ANTHROPIC_API_KEY`, `GENTLY_CONTROL_TOKEN`, `mesh_key.pem`, MQTT creds. + +## Call chain (per new capability) +Browser `fetch('/api/devices/temperature/config…')` → FastAPI `routes/data.py` (`require_control` on mutations) → `_resolve_client().()` → `client.py` `_api_*` → device-layer `handle_*` → `self.devices["temperature"]`. + +## Phasing / changes + +**Phase 0 (visibility + test):** +- device-layer: `GET /api/temperature/config` (current `temperature` block, password redacted, + live backend + `read()` state); `POST /api/temperature/config/test` (transient backend probe). Register in `on_start` (~:3394). +- client: `get_temperature_config`, `test_temperature_config`. +- viz: `GET /api/devices/temperature/config` (read-only), `POST /api/devices/temperature/config/test` (`require_control`). +- UI: read-only Hardware/Thermalizer section + Test button (a separate `ThermalizerSettings` JS object, isolated from `SettingsManager`); relabel the Vitals "Temperature model" field. + +**Phase 1 (editable + live reconnect):** +- device-layer: `POST /api/temperature/config` (validate; 409 guard; build-new-before-swap; sidecar persist; return live state); boot-merge sidecar over `config.yml` `temperature` (after `yaml.safe_load` ~:227). +- client: `set_temperature_config`. viz: `POST /api/devices/temperature/config` (`require_control`). +- UI: editable Serial/MQTT form (Mock dev-only), Apply (no auto-save), applied-live vs restart-required banner. + +**Phase 2 (visibility + prefs):** +- viz `GET /api/config/effective` (read-only, redacted) + a read-only "Effective config" viewer in the panel. +- Server-side dashboard-pref **defaults** + reset/export/import (viz route storing rig defaults in a file; `settings.js` layers over localStorage). +- Restart-required editors for SAFE `settings.py` knobs (timeouts, mesh timing, ML, `ux_v2`, NCBI) via a `config/settings.local.yml`/env override read at launch, with an explicit "restart required" path (never mutate the frozen `settings` singleton live). If the launcher-override mechanism proves out-of-scope, ship the viewer + pref-defaults and defer the editors. + +## Safety +- Never echo the MQTT password (redact on GET; persist only on new value). Redact all secrets in the effective-config viewer. +- `require_control` on every config write/test proxy. +- 409 reconfigure guard while RE running / lock held; build-new-before-swap so a bad config never leaves the rig with no thermalizer. +- Keep the 0.0–99.9 °C clamp in both layers; GUI can't widen it. +- Sidecar persistence (not `config.yml` rewrite); restrict perms on any file holding the plaintext MQTT password. +- Restart-required for frozen `settings.py` values — write override + prompt restart, never live-mutate. + +## Rig-only / honesty +The vendor SDK (`acuitynano_precision_thermalizer_*`) isn't on PyPI and isn't installed in the hardware-free shim, so **serial/MQTT construction, Test, and live-swap are rig-verified**; the shim path exercises routes + validation + the mock backend + UI flow. Live hot-swap's clean teardown per transport needs on-rig confirmation (fallback = restart banner). diff --git a/docs/ux-v2-flow-audit.md b/docs/ux-v2-flow-audit.md new file mode 100644 index 00000000..00bba3e7 --- /dev/null +++ b/docs/ux-v2-flow-audit.md @@ -0,0 +1,106 @@ +# UX v2 — interaction-flow / IA audit + +**Branch:** `feature/ux-v2` (now includes the 3D optical-space view). +**Scope:** the *flow* of the agent-first UI — clicks, how each step renders, how the +workspace is unveiled, moving back/forth between views, resume — **not** the visual look +(the look is fine). Plus where the 3D optical-space view belongs in the new workspace IA. + +**Method:** live click-audit driven through a real browser as a *dev biologist* would use +it, with the agent **live** (Opus 4.8, `--offline` hardware, `GENTLY_NO_AUTH=1` single +controller), cross-checked against the code. Screenshots from the run are in `screenshots/audit-*.png`. + +> Correction to an earlier automated pass: the plan-wizard helpers +> (`buildAskCard`/`answerChoice`/`togglePanel`) are **not** missing — `agent-chat.js` +> exports them and the module loads; the plan wizard works. The real issues are below. + +--- + +## What works (keep it) + +- **The forward path is good.** Entry → one calm choice (Plan / Quick look / "just tell me") + → overlay dismisses to reveal the workspace → grouped rail (NOW / LIBRARY / SYSTEM) drives + everything through one chokepoint (`app.js switchTab`). The welcome→workspace unveil is genuinely nice. +- **The agent-driven plan wizard is strong.** Live, it asked a well-framed scientific question + ("What's the core scientific question this run should capture?") with real C. elegans options, + ran a `query_lab_history` tool with visible provenance, and **assembled THE PLAN panel as each + answer landed** (strain → wavelengths, etc.). The "plan builds as you answer" feel is excellent. +- **The dual-render** (ask shows in the plan stage *and* the chat transcript) is implemented. + +--- + +## Findings (prioritized) + +| # | Pri | Symptom (felt) | Root cause / evidence | Fix | +|---|-----|----------------|------------------------|-----| +| 1 | **P0** | First plan step sat on "working through the next step…" for **~90s** with a static spinner — feels hung. | The wait is the model *thinking*. The streaming call requests **no thinking config** and the stream loop reads only text deltas. `conversation.py:272-275` (only `output_config.effort`), `conversation.py:654-657` (only `event.delta.text`). | Set `thinking={"type":"adaptive","display":"summarized"}` on the stream (`conversation.py:552`); handle `thinking_delta` in the loop (`:654`) and emit as a `thinking` activity; render it live + add an elapsed timer. See §1. | +| 2 | **P1** | Agent's first line renders as **"'d love to help…"** — leading "I" dropped. | Plan-feed streaming path drops the first character of the turn's first text block; the chat transcript renders it correctly (`12_41` vs `12_3` in the run). Plan feed: `landing.js applyActivity` `'text'` case (`:269`). | Most likely the first `AGENT_ACTIVITY`/`text` delta is missed by `landing.js`'s listener (subscribed after the first delta) or coalesced wrong. Confirm with a 1-line repro; the transcript path is the reference. | +| 3 | **P1** | Clicked the primary "Plan an experiment" → plan stage spun forever; the *real* blocker ("Viewing only — control is held by another client / sign in to control") was **hidden in the chat panel**. | Control/auth state isn't surfaced on the landing/plan surface — only in the chat dock. A viewer can enter the plan flow and dead-end. | Surface control/sign-in state on the landing **before** the primary CTA; gate or relabel "Plan an experiment" when `!hasControl`; show the wall on the plan stage, not just chat. | +| 4 | **P1** | (Structural) The same ask renders in **two** stage mounts plus the transcript. | `#v2-plan-ask` **and** `#ask-stage` both render the ask (the overlay covers the workspace copy, so only cosmetic/perf today). Two live regions seen in the run (`12_10` + `12_24`). | One stage mount at a time — suppress `#ask-stage` while the landing overlay owns the ask. | +| 5 | **P1** | Cross-surface clear can desync. | `ASK_CLEARED` is **listened for but emitted nowhere** (`landing.js:624`, `ask-stage.js:43` listen; no emit in repo). Answering works locally because `renderAsk.onPick` clears directly, but stage↔transcript sync relies on the missing signal. | Emit `ASK_CLEARED` the instant a `choice_response` is sent (per the migration plan's Phase-1 blocker), plus on cancel/control-loss/socket-close. | +| 6 | **P1** | **No way back.** Once the landing dismisses, there's no path back to welcome / "start a new plan" from the workspace — must reload. | `dismiss()` is one-way (`landing.js:42-54`); `V2Landing.show()` exists but is never called from the workspace. | Add a "New plan" / "Talk to Gently" entry in the rail or header that re-summons the welcome/plan surface. | +| 7 | **P2** | Browser **Back / refresh don't mean anything**; refresh mid-plan loses state and may re-show the landing. | Entry hash is consumed (`app.js` → `replaceState('/')`, ~`:650-662`); no deliberate URL/state sync; in-memory plan state (`planKickedOff`, feed pages) resets on reload. | Real routing: sync screen/tab to URL/History so Back/forward/refresh resolve; persist or re-hydrate plan progress. | +| 8 | **P2** | **Resume = full page reload** — jarring, re-shows landing, drops chat position. | `session_changed` → `window.location.href='/'` (`websocket.js:147`; `review.js resumeSession ~:101-116`). Flagged in the migration plan. | In-place re-hydration on `session_changed` instead of a hard reload. | +| 9 | **P1 (IA)** | The **3D optical-space view is buried**: SYSTEM → Devices → (Map / Details / **3D**) — a sub-sub-toggle. | It was integrated into the *legacy* Devices tab structure; the ux-v2 grouped rail doesn't surface it. | Promote "the scope in space" to a first-class run-time surface (NOW tier), reconciled with the grouped rail. See §2. | +| 10 | **P2** | Offline / agent-silent dead-ends the wizard at "working…". | `startPlan` campaign fetch falls through silently if offline (`landing.js ~:502-508`); no error path. | Timeout + inline error/retry on the plan stage. | + +--- + +## §1 — Make the loading state legible (P0, the one the user wants first) + +The 90s "working…" is the agent reasoning. The Claude streaming API exposes this on three +channels; gently currently surfaces none of the reasoning: + +- **Thinking** — `content_block_delta` → `thinking_delta`. **Opus 4.8 defaults to + `display:"omitted"` (empty thinking text)**, and gently doesn't set the thinking config at + all on the stream, so there's nothing to show. Unlock: `thinking={"type":"adaptive","display":"summarized"}`. +- **Tool activity** — `input_json_delta` + tool start/stop. **Already flowing** — the plan feed + renders tool cards (saw the `query_lab_history` card with input/result). +- **Text** — `text_delta`. Already flowing (this is the path with the bug #2 truncation). + +**Backend (`gently/harness/conversation.py`):** +1. `:552` `self.claude.messages.stream(...)` — add `thinking={"type":"adaptive","display":"summarized"}` + (keep `output_config.effort`). +2. `:654` event loop — currently only `if hasattr(event.delta, "text")`. Add a branch for + `event.delta.type == "thinking_delta"` → `yield {"type":"thinking","text": event.delta.thinking}`. + +**Frontend (`gently/ui/web/static/js/landing.js`):** `applyActivity` already has a `thinking` +case (`:266`) that only sets a static label — render the streamed thinking text instead, and add +an elapsed timer to `#v2-plan-thinking` so a long think reads as progress, not a hang. + +Net: the reasoning summary + current tool + a timer fill the wait. Only the backend `display` +flag is a new capability; the rest is surfacing data gently already receives. + +--- + +## §2 — Workspace organization & where the 3D view belongs (P1, IA) + +The ux-v2 workspace is organized differently from the old flat tab bar: a **grouped rail** +(NOW: Home/Experiment/Embryos · LIBRARY: Plans/Sessions · SYSTEM: Devices/Calibration/Logs), +a **session-context strip**, and the **AGENT'S VIEW** surface. The 3D optical-space view, +however, lives in the *legacy* Devices structure (`devices.js switchView`, VIEWS = +`['map','details','optical3d']`; `index.html` devices-content Map/Details/3D switcher). + +During an actual run, "where the scope is in space" + the live experiment + the agent's view are +**NOW-tier** concerns, not a System utility three clicks deep. Proposal (to design next): +- Promote the 3D optical-space + live experiment to a first-class run-time surface in the rail + (or make it the default workspace view while a run is active). +- Keep the Devices Map/Details as the System-tier hardware utility; the 3D "scope in space" + graduates out of that toggle. + +--- + +## Recommended sequencing + +1. **P0 loading state** (§1) — highest felt value, mostly surfacing existing data. +2. **P1 quick correctness**: #2 truncation, #3 control-wall surfacing, #4 single ask mount, #5 `ASK_CLEARED` emit. +3. **P1 reachability**: #6 "new plan"/back entry; then #9 the workspace-IA / 3D-placement redesign (its own design pass). +4. **P2 navigation**: #7 real routing, #8 resume re-hydration, #10 offline error path. + +--- + +## Notes / housekeeping + +- Findings 1–5, 10 verified live with the agent on; 6–9 verified from code + the live rail. +- `screenshots/audit-*.png` (live run) and `screenshots/uxv2-*.png` are local evidence (untracked). +- The earlier visual-design exploration (`docs/superpowers/mockups/`, `screenshots/dir-*.png`) is + superseded — the look is staying as-is — and can be deleted. diff --git a/gently/analysis/focus_validation.py b/gently/analysis/focus_validation.py new file mode 100644 index 00000000..3c44d3d9 --- /dev/null +++ b/gently/analysis/focus_validation.py @@ -0,0 +1,176 @@ +"""Offline validation of focus algorithms against passively-logged focus traces. + +The Operate view logs ``(z, focus_score)`` samples while the operator *manually* +focuses (bottom-cam or SPIM head); the human's resting Z is ground-truth best +focus. This module replays those traces — captured passively, with no autonomous +motion — to answer two safety questions before any autofocus is trusted near the +objective: + +1. **Agreement** — would the focus metric's argmax have matched the human's + chosen Z, and by how much (µm error)? +2. **Peak quality** — is the focus curve peaked at an interior point with enough + contrast to hill-climb at all? A flat or edge-rising curve means *no* metric + is safe to drive Z, regardless of agreement on a lucky sweep. + +Nothing here moves hardware; it only reads ``focus_traces.jsonl`` (written by the +device layer) and reports. See sub-project C of the bottom-cam operator surface. +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass, field +from pathlib import Path + + +@dataclass +class FocusSample: + """One passively-logged focus reading.""" + + z: float + score: float + t: float | None = None + source: str | None = None + sweep_id: str | None = None + + +@dataclass +class SweepResult: + n: int + human_z: float + predicted_z: float + error_um: float + interior_peak: bool # argmax strictly inside the sweep (not an endpoint) + score_contrast: float # (max-min)/max — low ⇒ unreliable peak + + +@dataclass +class ValidationReport: + n_sweeps: int + median_error_um: float + p95_error_um: float + within_margin_frac: float # fraction with error <= margin_um + interior_peak_frac: float # fraction whose metric peak is interior + sweeps: list[SweepResult] = field(default_factory=list) + + +def load_focus_traces(path: str | Path) -> list[FocusSample]: + """Read a ``focus_traces.jsonl`` file into FocusSample rows. + + Each line is a JSON object with ``z`` and ``focus_score`` (or ``score``); + ``t``, ``source`` and ``sweep_id`` are optional. Malformed lines are skipped. + """ + samples: list[FocusSample] = [] + p = Path(path) + if not p.exists(): + return samples + for line in p.read_text().splitlines(): + line = line.strip() + if not line: + continue + try: + row = json.loads(line) + z = row.get("z") + score = row.get("focus_score", row.get("score")) + if z is None or score is None: + continue + samples.append( + FocusSample( + z=float(z), + score=float(score), + t=row.get("t"), + source=row.get("source"), + sweep_id=row.get("sweep_id"), + ) + ) + except (ValueError, TypeError, json.JSONDecodeError): + continue + return samples + + +def segment_sweeps(samples: list[FocusSample]) -> list[list[FocusSample]]: + """Group samples into sweeps by ``sweep_id`` when present, else one sweep.""" + if not samples: + return [] + if any(s.sweep_id is not None for s in samples): + groups: dict[str, list[FocusSample]] = {} + for s in samples: + groups.setdefault(s.sweep_id or "_", []).append(s) + return list(groups.values()) + return [samples] + + +def evaluate_sweep(samples: list[FocusSample], human_z: float | None = None) -> SweepResult: + """Score one sweep: metric argmax vs the human's chosen (resting) Z. + + ``human_z`` defaults to the last sample's Z — where the operator settled. + """ + if not samples: + raise ValueError("empty sweep") + if human_z is None: + human_z = samples[-1].z + best = max(samples, key=lambda s: s.score) + predicted_z = best.z + scores = [s.score for s in samples] + smax, smin = max(scores), min(scores) + contrast = (smax - smin) / smax if smax > 0 else 0.0 + # interior peak: the argmax is not the first/last sample by Z order + by_z = sorted(samples, key=lambda s: s.z) + idx = by_z.index(best) + interior = 0 < idx < len(by_z) - 1 + return SweepResult( + n=len(samples), + human_z=float(human_z), + predicted_z=float(predicted_z), + error_um=abs(float(predicted_z) - float(human_z)), + interior_peak=interior, + score_contrast=float(contrast), + ) + + +def _percentile(values: list[float], pct: float) -> float: + if not values: + return 0.0 + s = sorted(values) + if len(s) == 1: + return s[0] + k = (len(s) - 1) * pct + lo = int(k) + hi = min(lo + 1, len(s) - 1) + return s[lo] + (s[hi] - s[lo]) * (k - lo) + + +def validate( + sweeps: list[list[FocusSample]], + margin_um: float = 5.0, + human_zs: list[float] | None = None, +) -> ValidationReport: + """Aggregate per-sweep agreement into a validation report. + + ``margin_um`` is the acceptable error for the algorithm to be considered safe + on a sweep; ``human_zs`` optionally overrides the per-sweep resting-Z default. + """ + results: list[SweepResult] = [] + for i, sw in enumerate(sweeps): + if not sw: + continue + hz = human_zs[i] if (human_zs is not None and i < len(human_zs)) else None + results.append(evaluate_sweep(sw, hz)) + if not results: + return ValidationReport(0, 0.0, 0.0, 0.0, 0.0, []) + errors = [r.error_um for r in results] + within = sum(1 for e in errors if e <= margin_um) / len(results) + interior = sum(1 for r in results if r.interior_peak) / len(results) + return ValidationReport( + n_sweeps=len(results), + median_error_um=_percentile(errors, 0.5), + p95_error_um=_percentile(errors, 0.95), + within_margin_frac=within, + interior_peak_frac=interior, + sweeps=results, + ) + + +def validate_file(path: str | Path, margin_um: float = 5.0) -> ValidationReport: + """Convenience: load a focus_traces.jsonl and validate it end-to-end.""" + return validate(segment_sweeps(load_focus_traces(path)), margin_um=margin_um) diff --git a/gently/app/agent.py b/gently/app/agent.py index f4b6d0c2..62602481 100644 --- a/gently/app/agent.py +++ b/gently/app/agent.py @@ -29,16 +29,16 @@ from ..ui.web.server import VisualizationServer from .bottom_camera_monitor import BottomCameraStreamMonitor from .device_state_monitor import DeviceStateMonitor + from .lightsheet_monitor import LightSheetStreamMonitor + from .operation_plan_updater import OperationPlanUpdater + from .temperature_sampler import TemperatureSampler + from gently_perception import Perceiver from ..core import EventType, emit, get_event_bus from ..core.file_store import FileStore from ..harness.conversation import ConversationManager -from ..harness.orchestration.plan_synthesis import ( - PlanLibrary, - PlanSynthesizer, - PlanValidator, -) +from ..harness.orchestration.plan_synthesis import PlanLibrary, PlanSynthesizer, PlanValidator from ..harness.prompts.manager import PromptManager from ..harness.session.interaction_logger import InteractionLogger from ..harness.session.manager import SessionManager @@ -114,12 +114,13 @@ def __init__( # the message entry points refuse to call Claude. self.api_enabled = not no_api - # API client with interleaved thinking support + # Shared API client. No interleaved-thinking beta header: it's GA on the + # 4.6+ models and obsolete on Fable 5 (always-on thinking); the header is + # dropped so it can't conflict with the new model family. self.claude = anthropic.Anthropic( api_key=api_key or os.getenv("ANTHROPIC_API_KEY") or ("no-api-mode" if no_api else None), - default_headers={"anthropic-beta": "interleaved-thinking-2025-05-14"}, ) self.model = model @@ -214,9 +215,15 @@ def __init__( # Device-state monitor (bridges device-layer SSE → EventBus) self.device_state_monitor: DeviceStateMonitor | None = None + # Session-scoped temperature sampler — polls device layer, persists readings. + self.temperature_sampler: TemperatureSampler | None = None + # Bus-subscriber that transitions plan tactics when execution events fire. + self.operation_plan_updater: OperationPlanUpdater | None = None # Opt-in bottom-camera stream bridge — created when viz starts, but # left unstarted until the operator clicks "Start camera" in the UI. self.bottom_camera_monitor: BottomCameraStreamMonitor | None = None + # Opt-in lightsheet stream bridge — same lifecycle as bottom_camera_monitor. + self.lightsheet_monitor: LightSheetStreamMonitor | None = None # ===== Create delegate managers ===== @@ -382,11 +389,7 @@ def enter_plan_mode(self) -> str: import gently.harness.plan_mode.tools # noqa: F401 self._update_system_prompt() - emit( - EventType.STATUS_CHANGED, - {"field": "agent_mode", "value": "plan"}, - source="agent", - ) + emit(EventType.STATUS_CHANGED, {"field": "agent_mode", "value": "plan"}, source="agent") logger.info("Entered plan mode") return "Switched to plan mode. I'm now your experimental design collaborator." @@ -469,6 +472,18 @@ def exit_plan_mode(self) -> str: ) except Exception: pass + + # Seed the Operation Plan from the plan item's tactics outline + # (idempotent: no-op if plan already has active/done tactics). + try: + from gently.app.tools.operation_plan_seed import ( + seed_operation_plan_from_plan_item, + ) + + if self.session_id is not None: + seed_operation_plan_from_plan_item(self.context_store, self.session_id) + except Exception: + logger.exception("operation-plan seeding failed") elif candidates: titles = [c[0].title for c in candidates] listing = ", ".join(titles[:5]) @@ -482,11 +497,7 @@ def exit_plan_mode(self) -> str: self.prompts.invalidate_context_cache() self._update_system_prompt() - emit( - EventType.STATUS_CHANGED, - {"field": "agent_mode", "value": "run"}, - source="agent", - ) + emit(EventType.STATUS_CHANGED, {"field": "agent_mode", "value": "run"}, source="agent") logger.info("Exited plan mode") return result @@ -645,6 +656,9 @@ def _init_timelapse_orchestrator(self): session_id=self.session_id, store=self.store, claude_client=self.claude, + temperature_provider=lambda: ( + self.temperature_sampler.latest if self.temperature_sampler else None + ), ) except Exception as e: logging.getLogger(__name__).warning(f"Failed to init timelapse orchestrator: {e}") @@ -762,10 +776,7 @@ def on_perception(event): self.invalidate_context_cache() self._auto_save() logger.info( - "Perception: %s -> stage %s (t%s)", - embryo_id, - stage, - data.get("timepoint"), + "Perception: %s -> stage %s (t%s)", embryo_id, stage, data.get("timepoint") ) except Exception as e: logger.warning(f"Error handling perception event: {e}") @@ -841,6 +852,32 @@ async def start_viz_server( logger.warning(f"Failed to start device-state monitor: {e}") self.device_state_monitor = None + if self.microscope is not None and self.temperature_sampler is None: + try: + from .temperature_sampler import TemperatureSampler + + self.temperature_sampler = TemperatureSampler( + self.microscope, self.store, lambda: self.session_id + ) + await self.temperature_sampler.start() + logger.info("Temperature sampler started") + except Exception as e: + logger.warning(f"Failed to start temperature sampler: {e}") + self.temperature_sampler = None + + if self.operation_plan_updater is None and self.context_store is not None: + try: + from .operation_plan_updater import OperationPlanUpdater + + self.operation_plan_updater = OperationPlanUpdater( + self.context_store, lambda: self.session_id + ) + await self.operation_plan_updater.start() + logger.info("Operation-plan updater started") + except Exception as e: + logger.warning(f"Failed to start operation-plan updater: {e}") + self.operation_plan_updater = None + # Construct the bottom-camera monitor — but don't start it. Streaming # is opt-in and waits for an explicit operator action via the # /api/devices/bottom_camera/stream/start route. @@ -854,6 +891,16 @@ async def start_viz_server( logger.warning(f"Failed to construct bottom-camera monitor: {e}") self.bottom_camera_monitor = None + if self.microscope is not None and self.lightsheet_monitor is None: + try: + from .lightsheet_monitor import LightSheetStreamMonitor + + self.lightsheet_monitor = LightSheetStreamMonitor(self.microscope) + logger.info("Lightsheet monitor ready (not started)") + except Exception as e: + logger.warning(f"Failed to construct lightsheet monitor: {e}") + self.lightsheet_monitor = None + async def stop_viz_server(self): """Stop the visualization server if running.""" if self.bottom_camera_monitor is not None: @@ -862,12 +909,30 @@ async def stop_viz_server(self): except Exception: logger.exception("Failed to stop bottom-camera monitor") self.bottom_camera_monitor = None + if self.lightsheet_monitor is not None: + try: + await self.lightsheet_monitor.stop() + except Exception: + logger.exception("Failed to stop lightsheet monitor") + self.lightsheet_monitor = None if self.device_state_monitor is not None: try: await self.device_state_monitor.stop() except Exception: logger.exception("Failed to stop device-state monitor") self.device_state_monitor = None + if self.temperature_sampler is not None: + try: + await self.temperature_sampler.stop() + except Exception: + logger.exception("Failed to stop temperature sampler") + self.temperature_sampler = None + if self.operation_plan_updater is not None: + try: + await self.operation_plan_updater.stop() + except Exception: + logger.exception("Failed to stop operation-plan updater") + self.operation_plan_updater = None if self.viz_server is not None: await self.viz_server.stop() self.viz_server = None @@ -1620,8 +1685,8 @@ async def check_blank_image( img.save(buffer, format="PNG") b64_image = base64.b64encode(buffer.getvalue()).decode() - prompt = """Look at this microscopy image. Is this a VALID microscopy image or a -BLANK/CORRUPTED image? + prompt = """\ +Look at this microscopy image. Is this a VALID microscopy image or a BLANK/CORRUPTED image? A BLANK or CORRUPTED image shows: - Mostly uniform gray/black with no structure diff --git a/gently/app/detectors/dopaminergic_signal.py b/gently/app/detectors/dopaminergic_signal.py index 7fb09408..9e9edbd9 100644 --- a/gently/app/detectors/dopaminergic_signal.py +++ b/gently/app/detectors/dopaminergic_signal.py @@ -273,7 +273,9 @@ async def _call_perceiver( } ], ) - raw = response.content[0].text if response.content else "" + if response.stop_reason == "refusal" or not response.content: + return "(perception model declined the request)", "" + raw = response.content[0].text return raw.strip(), raw async def _call_classifier( @@ -290,7 +292,9 @@ async def _call_classifier( max_tokens=300, messages=[{"role": "user", "content": prompt}], ) - raw = response.content[0].text if response.content else "" + if response.stop_reason == "refusal" or not response.content: + return dict(_DEFAULT_FINDINGS), "", "Safety refusal" + raw = response.content[0].text findings, parse_err = _parse_response(raw) return findings, raw, parse_err diff --git a/gently/app/detectors/hatching.py b/gently/app/detectors/hatching.py index 62dd31cb..6dc0705d 100644 --- a/gently/app/detectors/hatching.py +++ b/gently/app/detectors/hatching.py @@ -5,6 +5,10 @@ pipeline trains on. The dopaminergic-signal detector already returns ``has_hatched`` as part of its richer schema; this is a lighter-weight yes/no for use cases where structure / intensity assessment isn't needed. + +The verdict comes back as a forced tool call (``tool_choice`` pins the model +to ``record_hatching``), so the structured fields arrive already parsed as +``block.input`` — no JSON-from-prose scraping, no silent-default parse layer. """ import asyncio @@ -20,8 +24,9 @@ logger = logging.getLogger(__name__) -_HATCHING_PROMPT = """You are observing a C. elegans embryo on a microscope. Decide whether -the embryo has HATCHED. +_HATCHING_PROMPT = """\ +You are observing a C. elegans embryo on a microscope. Decide whether the embryo has HATCHED, +then record your decision with the record_hatching tool. A HATCHED embryo: - Has visibly broken out of the eggshell @@ -32,20 +37,37 @@ - Is still contained within an intact eggshell - May be at any pre-hatching stage (bean, comma, 1.5-fold, 2-fold, pretzel) -Respond with ONLY a JSON object exactly matching this schema: +Default to has_hatched=false unless you are confident. Don't over-call hatching. +""" -{ - "has_hatched": true|false, - "confidence": "LOW|MEDIUM|HIGH", - "reasoning": "..." -} -Default to false unless you are confident. Don't over-call hatching. -""" +# Forced tool schema — the model is pinned to this via tool_choice, so the +# fields come back as a validated dict on the tool_use block. The conservative +# "default to false" guidance lives in the prompt. We deliberately do NOT ask +# the model to self-rate confidence — that's a heuristics-era artifact; the +# has_hatched judgment is the signal. +_HATCHING_TOOL = { + "name": "record_hatching", + "description": "Record whether the C. elegans embryo has hatched, with brief reasoning.", + "input_schema": { + "type": "object", + "properties": { + "has_hatched": { + "type": "boolean", + "description": "True only if the embryo has visibly broken out of the eggshell.", + }, + "reasoning": { + "type": "string", + "description": "One short sentence citing the visual evidence for the call.", + }, + }, + "required": ["has_hatched", "reasoning"], + }, +} class HatchingDetector(Detector): - """Claude-vision hatching yes/no, with confidence.""" + """Claude-vision hatching yes/no.""" name = "hatching" @@ -59,7 +81,6 @@ async def run( context: dict[str, Any], ) -> DetectorResult: import json - import re import anthropic @@ -84,7 +105,7 @@ async def run( detector_name=self.name, embryo_id=embryo_id, timepoint=timepoint, - findings={"has_hatched": False, "confidence": "LOW"}, + findings={"has_hatched": False}, reasoning="Empty / unreadable volume", elapsed_ms=(time.time() - start) * 1000, ) @@ -93,7 +114,9 @@ async def run( response = await asyncio.to_thread( claude.messages.create, model=self._model or settings.models.fast, - max_tokens=200, + max_tokens=256, + tools=[_HATCHING_TOOL], + tool_choice={"type": "tool", "name": _HATCHING_TOOL["name"]}, messages=[ { "role": "user", @@ -111,23 +134,24 @@ async def run( } ], ) - raw = response.content[0].text if response.content else "" - findings = {"has_hatched": False, "confidence": "LOW"} + # Forced tool_choice guarantees a tool_use block; read its parsed + # input directly. No regex, no JSON-from-prose fallback. + tool_input = next( + (b.input for b in response.content if getattr(b, "type", None) == "tool_use"), + None, + ) + + findings = {"has_hatched": False} reasoning = None err = None - try: - m = re.search(r"\{.*?\}", raw, re.DOTALL) - blob = m.group(0) if m else raw.strip() - parsed = json.loads(blob) - findings["has_hatched"] = bool(parsed.get("has_hatched", False)) - confidence = str(parsed.get("confidence", "LOW")).upper() - if confidence not in {"LOW", "MEDIUM", "HIGH"}: - confidence = "LOW" - findings["confidence"] = confidence - reasoning = parsed.get("reasoning") - except (json.JSONDecodeError, AttributeError) as e: - err = f"parse error: {e}" + if isinstance(tool_input, dict): + findings["has_hatched"] = bool(tool_input.get("has_hatched", False)) + reasoning = tool_input.get("reasoning") + else: + # Shouldn't happen with forced tool_choice — keep the + # conservative default and record why. + err = "no tool_use block in response" return DetectorResult( detector_name=self.name, @@ -135,7 +159,7 @@ async def run( timepoint=timepoint, findings=findings, reasoning=reasoning, - raw_response=raw, + raw_response=json.dumps(tool_input) if isinstance(tool_input, dict) else None, elapsed_ms=(time.time() - start) * 1000, error=err, ) diff --git a/gently/app/lightsheet_monitor.py b/gently/app/lightsheet_monitor.py new file mode 100644 index 00000000..78b7ca39 --- /dev/null +++ b/gently/app/lightsheet_monitor.py @@ -0,0 +1,98 @@ +""" +LightSheetStreamMonitor — bridges the device-layer lightsheet SSE stream +onto the EventBus as ``LIGHTSHEET_FRAME`` events. + +Modelled on :class:`gently.app.bottom_camera_monitor.BottomCameraStreamMonitor`, +with the same opt-in semantics: streaming is not started on agent boot — only +when the operator enables it explicitly from the UI. The agent's start/stop +methods are the only path that connects/disconnects this monitor. +""" + +from __future__ import annotations + +import asyncio +import logging +from typing import TYPE_CHECKING + +from gently.core.event_bus import EventType, get_event_bus +from gently.core.service import Service + +if TYPE_CHECKING: + from gently.hardware.dispim.client import DiSPIMMicroscope + +logger = logging.getLogger(__name__) + + +class LightSheetStreamMonitor(Service): + """Consumes the lightsheet SSE stream and republishes frames on the bus. + + The browser receives frames via the viz server's wildcard subscription; + no additional plumbing is needed at the agent layer beyond starting the + bridge when the operator asks for it. + """ + + def __init__( + self, + microscope: DiSPIMMicroscope, + reconnect_delay_sec: float = 2.0, + ): + super().__init__(name="lightsheet-monitor", service_type="bridge") + self.microscope = microscope + self.reconnect_delay_sec = reconnect_delay_sec + self._task: asyncio.Task | None = None + self._stop_requested = False + self._last_frame_ts: float | None = None + + @property + def running(self) -> bool: + return self._task is not None and not self._task.done() + + async def on_start(self): + if self.running: + return + self._stop_requested = False + self._task = asyncio.create_task(self._run(), name="lightsheet-monitor") + logger.info("LightSheetStreamMonitor: started") + + async def on_stop(self): + self._stop_requested = True + if self._task and not self._task.done(): + self._task.cancel() + try: + await self._task + except asyncio.CancelledError: + pass + self._task = None + logger.info("LightSheetStreamMonitor: stopped") + + async def _run(self): + bus = get_event_bus() + while not self._stop_requested: + try: + logger.info("LightSheetStreamMonitor: opening stream") + async for payload in self.microscope.stream_lightsheet(): + if self._stop_requested: + break + self._last_frame_ts = payload.get("t") + try: + bus.publish( + event_type=EventType.LIGHTSHEET_FRAME, + data=payload, + source="lightsheet-monitor", + ) + except Exception: + logger.exception("Failed to publish frame") + except asyncio.CancelledError: + raise + except Exception as exc: + logger.warning( + "LightSheetStreamMonitor: stream ended (%s) — reconnecting in %.1fs", + exc, + self.reconnect_delay_sec, + ) + if self._stop_requested: + break + try: + await asyncio.sleep(self.reconnect_delay_sec) + except asyncio.CancelledError: + raise diff --git a/gently/app/operation_plan_updater.py b/gently/app/operation_plan_updater.py new file mode 100644 index 00000000..738fe10c --- /dev/null +++ b/gently/app/operation_plan_updater.py @@ -0,0 +1,142 @@ +"""Operation-plan updater — subscribes to execution lifecycle events and +transitions plan tactics to their completed state. + +Modeled on `gently/app/temperature_sampler.py` (Service lifecycle) and +`gently/harness/session/timeline.py` TimelineManager.start (bus-subscribe +pattern). + +When a tactic's execution completes — burst captured, temperature protocol +finished, trigger fired — this service reads `tactic_id` from the event +payload and calls `context_store.transition_tactic(session_id, tactic_id, +state, **bind)` to stamp live values and advance the tactic state. Missing +`tactic_id` or session → skip. Handler exceptions are caught so a bad event +never crashes the bus. +""" + +from __future__ import annotations + +import logging +from collections.abc import Callable + +from gently.core.event_bus import EventType, get_event_bus +from gently.core.service import Service + +logger = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# Event → (state, bind-keys) mapping +# --------------------------------------------------------------------------- +# state=None means bind-only (no state transition). +_EVENT_ACTIONS: dict[EventType, tuple[str | None, list[str]]] = { + EventType.BURST_COMPLETE: ( + "done", + ["request_id", "mp4_path", "sustained_hz", "frames_captured"], + ), + EventType.TEMP_PROTOCOL_COMPLETED: ( + "done", + ["locked", "cancelled", "error"], + ), + EventType.EMBRYO_CADENCE_CHANGED: ( + None, # bind-only; standing tactic stays active + ["embryo_id", "old_phase", "new_phase", "old_interval_s", "new_interval_s", "next_due_at"], + ), + EventType.TRIGGER_FIRED: ( + None, # bind-only; record last firing time + ["embryo_id", "rule_name", "rule_kind", "trigger_detector", "trigger_stage"], + ), +} + + +class OperationPlanUpdater(Service): + """Subscribe to execution events; transition plan tactics accordingly.""" + + def __init__(self, context_store, session_id_getter: Callable[[], str | None]): + super().__init__(name="operation-plan-updater", service_type="monitor") + self._context_store = context_store + self._session_id_getter = session_id_getter + self._unsubscribers: list[Callable[[], None]] = [] + + # ------------------------------------------------------------------ + # Service lifecycle + # ------------------------------------------------------------------ + + async def on_start(self) -> None: + bus = get_event_bus() + for event_type in _EVENT_ACTIONS: + unsub = bus.subscribe(event_type, self._on_event) + self._unsubscribers.append(unsub) + logger.info( + "OperationPlanUpdater started — subscribed to %d event types", + len(_EVENT_ACTIONS), + ) + + async def on_stop(self) -> None: + for unsub in self._unsubscribers: + try: + unsub() + except Exception as exc: + logger.warning("OperationPlanUpdater unsubscribe error: %s", exc) + self._unsubscribers.clear() + logger.info("OperationPlanUpdater stopped") + + # ------------------------------------------------------------------ + # Event handler + # ------------------------------------------------------------------ + + def _on_event(self, event) -> None: + """Handle an execution lifecycle event — guard everything; never raise.""" + try: + self._handle(event) + except Exception as exc: + logger.warning( + "OperationPlanUpdater: unhandled error in _on_event (%s): %s", + event.event_type, + exc, + ) + + def _handle(self, event) -> None: + data = event.data or {} + tactic_id = data.get("tactic_id") + session_id = self._session_id_getter() + + action = _EVENT_ACTIONS.get(event.event_type) + if action is None: + return # unknown event type — ignore + + state, bind_keys = action + + # For bind-only events with no tactic_id there is nothing to attach to. + if not tactic_id: + logger.debug( + "OperationPlanUpdater: no tactic_id in %s payload — skip", + event.event_type, + ) + return + + if not session_id: + logger.debug( + "OperationPlanUpdater: no active session for %s — skip", + event.event_type, + ) + return + + bind = {k: data[k] for k in bind_keys if k in data} + + # Add a last_fired marker for TRIGGER_FIRED so operators can see it. + if event.event_type is EventType.TRIGGER_FIRED: + bind["last_fired"] = event.timestamp + + ok = self._context_store.transition_tactic(session_id, tactic_id, state, **bind) + if ok: + logger.info( + "OperationPlanUpdater: tactic %s → %s (event=%s)", + tactic_id, + state or "bind-only", + event.event_type, + ) + else: + logger.debug( + "OperationPlanUpdater: transition_tactic no-op for tactic=%s session=%s", + tactic_id, + session_id, + ) diff --git a/gently/app/orchestration/exclusive.py b/gently/app/orchestration/exclusive.py index db9804bd..7dc3583f 100644 --- a/gently/app/orchestration/exclusive.py +++ b/gently/app/orchestration/exclusive.py @@ -23,6 +23,8 @@ import numpy as np +from gently.app.temperature_sampler import temperature_stamp + logger = logging.getLogger(__name__) @@ -98,11 +100,17 @@ def __init__( mode: str = "1hz", num_slices: int = 1, request_id: str | None = None, + temperature_provider=None, + laser_config: str | None = None, + tactic_id: str | None = None, ): super().__init__(target_embryo_id=target_embryo_id, request_id=request_id) self.frames = frames self.mode = mode if mode in ("1hz", "asap") else "1hz" self.num_slices = num_slices + self._temperature_provider = temperature_provider + self._laser_config = laser_config + self._tactic_id = tactic_id async def run(self, orchestrator) -> ExclusiveResult: from gently.core import EventType @@ -125,6 +133,8 @@ async def run(self, orchestrator) -> ExclusiveResult: "request_id": self.request_id, "frames": self.frames, "mode": self.mode, + "phase": getattr(self, "_phase", None), + "tactic_id": self._tactic_id, }, ) @@ -162,6 +172,7 @@ async def run(self, orchestrator) -> ExclusiveResult: piezo_amplitude=piezo_amplitude, piezo_center=piezo_center, laser_power_488_pct=getattr(embryo, "laser_power_488_pct", None), + laser_config=self._laser_config, ) except Exception as e: logger.error("Burst failed for %s: %s", self.target_embryo_id, e) @@ -204,6 +215,7 @@ async def run(self, orchestrator) -> ExclusiveResult: piezo_amplitude=piezo_amplitude, piezo_center=piezo_center, laser_power_488_pct=getattr(embryo, "laser_power_488_pct", None), + temperature_provider=self._temperature_provider, ) # Generate MP4 (derivative artifact; safe to fail). @@ -225,6 +237,7 @@ async def run(self, orchestrator) -> ExclusiveResult: "duration_s": duration_s, "sustained_hz": sustained_hz, "mp4_path": mp4_path, + "tactic_id": self._tactic_id, }, ) @@ -318,6 +331,7 @@ def _persist_burst_to_disk( piezo_amplitude: float, piezo_center: float, laser_power_488_pct: float | None, + temperature_provider=None, ) -> Path | None: """Save per-frame TIFFs + meta + projections + a burst.yaml manifest. @@ -344,6 +358,11 @@ def _persist_burst_to_disk( proj_dir = burst_dir / "projections" proj_dir.mkdir(exist_ok=True) + # Compute temperature stamp once for the whole burst (all frames share the + # same reading — the sampler captures at ~1 Hz so per-frame variation is + # sub-resolution anyway). + _temp = temperature_stamp(temperature_provider() if temperature_provider else None) + # Position recorded for the manifest. pos = getattr(embryo, "stage_position", {}) or {} sid = getattr(orchestrator, "_session_id", None) @@ -403,6 +422,7 @@ def _persist_burst_to_disk( "burst_mode": mode, "laser_power_488_pct": laser_power_488_pct, "role": "burst", + "temperature": _temp, }, } if _yaml is not None: @@ -432,6 +452,7 @@ def _persist_burst_to_disk( "sustained_hz": sustained_hz, "embryo_position": {"x": pos.get("x"), "y": pos.get("y")}, "laser_power_488_pct": laser_power_488_pct, + "temperature": _temp, "scan": { "galvo_amplitude": galvo_amplitude, "galvo_center": galvo_center, diff --git a/gently/app/orchestration/role_scope.py b/gently/app/orchestration/role_scope.py new file mode 100644 index 00000000..66c9b9d5 --- /dev/null +++ b/gently/app/orchestration/role_scope.py @@ -0,0 +1,58 @@ +"""Role-scoped tactic target resolver. + +``resolve_scope_embryos`` maps a tactic ``scope`` dict + a roster of embryo +dicts to a list of embryo IDs that the tactic should operate on. + +Scope modes +----------- +``global`` + All embryo IDs in the roster. +``embryos`` + Explicit list from ``scope['embryo_ids']``. +``role`` + IDs of embryos whose ``role`` field matches ``scope['role']``. +missing/unknown + Empty list (safe default — never errors). + +Embryo dict shape +----------------- +The resolver expects the shape produced by ``/api/embryos/positions``: +each dict must have an ``'embryo_id'`` key (not just ``'id'``). +""" + + +def resolve_scope_embryos(scope: dict | None, embryos: list[dict]) -> list[str]: + """Return the embryo IDs that match *scope* from *embryos*. + + Parameters + ---------- + scope: + Tactic scope dict, e.g. ``{"mode": "role", "role": "test"}``. + ``None`` is treated as an unknown scope → returns ``[]``. + embryos: + List of embryo dicts, each with at minimum ``embryo_id`` and ``role`` + keys. Any dict lacking ``embryo_id`` is silently skipped. + + Returns + ------- + list[str] + Matched embryo IDs. Never raises. + """ + if not scope: + return [] + + mode = scope.get("mode") + + if mode == "global": + return [e["embryo_id"] for e in embryos if "embryo_id" in e] + + if mode == "embryos": + return list(scope.get("embryo_ids") or []) + + if mode == "role": + target_role = scope.get("role") + return [ + e["embryo_id"] for e in embryos if "embryo_id" in e and e.get("role") == target_role + ] + + return [] diff --git a/gently/app/orchestration/tactic_executor.py b/gently/app/orchestration/tactic_executor.py new file mode 100644 index 00000000..e62a6abb --- /dev/null +++ b/gently/app/orchestration/tactic_executor.py @@ -0,0 +1,161 @@ +"""Tactic Executor — turns a declarative tactic into orchestrator actions. + +A *tactic* (a dict in the session Operation Plan) describes HOW to image a scoped +set of embryos: ``{kind, scope, structure, ...}``. This module is the single +place that makes that language *executable* — it resolves the tactic's scope to +concrete embryo ids and dispatches by ``kind`` to the TimelapseOrchestrator, then +marks the tactic active. It is the first (and only) caller of +``resolve_scope_embryos``; both the Operate "Run" surface and the agent reach +imaging through this one path, so the kind→action mapping lives here, not +duplicated across call sites. + +Deterministic and side-effecting only through the orchestrator + context store — +no LLM. Real acquisition/motion remain the orchestrator's concern (RIG-DEFERRED). +""" + +from __future__ import annotations + +import logging + +from gently.app.orchestration.role_scope import resolve_scope_embryos + +logger = logging.getLogger(__name__) + + +def _roster(agent) -> list[dict]: + """Build the [{embryo_id, role}] roster resolve_scope_embryos expects.""" + exp = getattr(agent, "experiment", None) + embryos = getattr(exp, "embryos", {}) if exp is not None else {} + roster = [] + for eid, emb in embryos.items(): + roster.append({"embryo_id": eid, "role": getattr(emb, "role", "unassigned")}) + return roster + + +def _num(v, default=None): + try: + return float(v) + except (TypeError, ValueError): + return default + + +async def execute_tactic(agent, tactic: dict) -> dict: + """Execute one tactic against the agent's orchestrator. + + Returns ``{ok, kind, embryo_ids, message}``. Never raises for an unknown + kind or empty scope — it reports them in the result so callers can surface a + clear message. Marks the tactic ``active`` in the Operation Plan on success. + """ + kind = (tactic or {}).get("kind") + scope = (tactic or {}).get("scope") + structure = (tactic or {}).get("structure") or {} + tactic_id = (tactic or {}).get("id") + + orchestrator = getattr(agent, "timelapse_orchestrator", None) + if orchestrator is None: + return {"ok": False, "kind": kind, "embryo_ids": [], "message": "no orchestrator"} + + embryo_ids = resolve_scope_embryos(scope, _roster(agent)) + if not embryo_ids and kind not in ("oneshot", "custom", "scripted_protocol"): + return { + "ok": False, + "kind": kind, + "embryo_ids": [], + "message": "scope resolved to no embryos", + } + + message = "" + try: + if kind == "standing_timelapse": + interval = _num(structure.get("cadence_s"), _num(structure.get("interval"), 120.0)) + message = await orchestrator.start( + embryo_ids=embryo_ids, + stop_condition=str(structure.get("stop_condition", "manual")), + base_interval_seconds=interval, + condition_value=structure.get("condition_value"), + ) + mode = structure.get("monitoring_mode") + if mode and mode != "idle": + try: + mres = orchestrator.enable_monitoring_mode(mode, embryo_ids=embryo_ids) + message += " | " + str(mres) + except Exception as exc: # monitoring is best-effort + message += f" | monitoring '{mode}' failed: {exc}" + + elif kind == "reactive_monitor": + mode = structure.get("monitoring_mode") or "expression_monitoring" + message = orchestrator.enable_monitoring_mode(mode, embryo_ids=embryo_ids) + + elif kind == "exclusive_burst": + frames = int(_num(structure.get("frames"), 60)) + results = [] + for eid in embryo_ids: + results.append( + orchestrator.queue_burst( + eid, + frames=frames, + mode=str(structure.get("mode", "1hz")), + num_slices=int(_num(structure.get("num_slices"), 1)), + tactic_id=tactic_id, + ) + ) + message = "; ".join(results) + + elif kind in ("oneshot", "scripted_protocol", "custom"): + # No standing orchestrator mechanism backs these here — the tactic is + # recorded (and, for oneshot, driven by the manual per-embryo loop). + message = f"{kind} recorded (no orchestrator mechanism)" + + else: + return { + "ok": False, + "kind": kind, + "embryo_ids": embryo_ids, + "message": f"unknown tactic kind '{kind}'", + } + except Exception as exc: + logger.exception("tactic execution failed (kind=%s)", kind) + return {"ok": False, "kind": kind, "embryo_ids": embryo_ids, "message": str(exc)} + + # Mark the tactic active in the Operation Plan (best-effort). + cs = getattr(agent, "context_store", None) + sid = getattr(agent, "session_id", None) + if cs is not None and sid and tactic_id: + try: + cs.transition_tactic(sid, tactic_id, "active") + except Exception: + logger.debug("transition_tactic failed for %s", tactic_id, exc_info=True) + + return {"ok": True, "kind": kind, "embryo_ids": embryo_ids, "message": message} + + +def append_tactic_to_plan(agent, tactic: dict) -> dict | None: + """Append a (validated) tactic to the session Operation Plan and return it. + + Creates a minimal plan if none exists. Returns the stored tactic dict (with a + generated id if absent), or None if there is no session/context store. + """ + import uuid + + from gently.app.tools.operation_plan_tools import _validate_tactics + + cs = getattr(agent, "context_store", None) + sid = getattr(agent, "session_id", None) + if cs is None or not sid: + return None + t = dict(tactic) + t.setdefault("id", f"op_{uuid.uuid4().hex[:8]}") + t.setdefault("kind", "custom") + t.setdefault("state", "planned") + t.setdefault("name", t.get("kind", "tactic")) + (validated,) = _validate_tactics([t]) + plan = cs.get_operation_plan(sid) or { + "session_id": sid, + "title": "Operate session", + "goal": "", + "tactics": [], + } + plan.setdefault("tactics", []).append(validated) + plan["updated_reason"] = "operate tactic appended" + cs.set_operation_plan(sid, plan) + return validated diff --git a/gently/app/orchestration/temperature_protocol.py b/gently/app/orchestration/temperature_protocol.py new file mode 100644 index 00000000..e25ee148 --- /dev/null +++ b/gently/app/orchestration/temperature_protocol.py @@ -0,0 +1,117 @@ +import asyncio +import logging + +from gently.app.orchestration.exclusive import BurstAcquisition +from gently.core.event_bus import EventType + +logger = logging.getLogger(__name__) + + +async def wait_for_temperature_lock(client, timeout_s: float, poll_s: float = 2.0) -> bool: + """Poll the controller until it reports a locked state, or timeout. Substring 'LOCKED'.""" + loop = asyncio.get_event_loop() + t0 = loop.time() + while True: + try: + resp = await client.get_temperature() + except Exception as exc: + logger.warning("wait_for_temperature_lock poll failed: %s", exc) + resp = {} + if "LOCKED" in str(resp.get("state", "")): + return True + if loop.time() - t0 >= timeout_s: + return False + await asyncio.sleep(poll_s) + + +async def run_temp_change_burst_protocol( + orchestrator, + embryo_id, + target_setpoint_c, + *, + frames=60, + mode="1hz", + num_slices=1, + bursts_before=1, + bursts_after=1, + lock_timeout_s=600.0, + poll_s=2.0, + burst_runner=None, + tactic_id=None, +): + """Scripted temp-change burst protocol: brightfield before/during/after a setpoint change.""" + client = orchestrator.client + if burst_runner is None: + + async def burst_runner(b): + await b.run(orchestrator) + + async def one_burst(phase): + b = BurstAcquisition( + embryo_id, + frames=frames, + mode=mode, + num_slices=num_slices, + temperature_provider=getattr(orchestrator, "_temperature_provider", None), + laser_config="ALL OFF", + ) + b._phase = phase + await burst_runner(b) + + locked = False + error = None + cancelled = False + try: + await client.set_laser_config("ALL OFF") + await client.set_led("Open") + orchestrator._emit_event( + EventType.TEMP_PROTOCOL_STARTED, + { + "embryo_id": embryo_id, + "target_setpoint_c": target_setpoint_c, + "frames": frames, + "bursts_before": bursts_before, + "bursts_after": bursts_after, + "tactic_id": tactic_id, + }, + ) + for _ in range(bursts_before): + await one_burst("before") + await client.set_temperature(target_setpoint_c) + orchestrator._emit_event( + EventType.TEMPERATURE_SETPOINT_CHANGED, + {"embryo_id": embryo_id, "to": target_setpoint_c}, + ) + loop = asyncio.get_event_loop() + t0 = loop.time() + while True: + await one_burst("during") + try: + st = str((await client.get_temperature()).get("state", "")) + except Exception: + st = "" + if "LOCKED" in st: + locked = True + break + if loop.time() - t0 >= lock_timeout_s: + break + for _ in range(bursts_after): + await one_burst("after") + except asyncio.CancelledError: + cancelled = True + raise + except Exception as exc: + error = str(exc) + logger.exception("temp-change burst protocol failed") + finally: + orchestrator._emit_event( + EventType.TEMP_PROTOCOL_COMPLETED, + { + "embryo_id": embryo_id, + "locked": locked, + "cancelled": cancelled, + "error": error, + "tactic_id": tactic_id, + }, + ) + return {"locked": locked, "cancelled": cancelled, "error": error} diff --git a/gently/app/orchestration/timelapse.py b/gently/app/orchestration/timelapse.py index b3d78bb1..7762fac6 100644 --- a/gently/app/orchestration/timelapse.py +++ b/gently/app/orchestration/timelapse.py @@ -74,6 +74,7 @@ def __init__( session_id: str | None = None, store: Optional["FileStore"] = None, claude_client=None, + temperature_provider=None, ): """ Parameters @@ -102,6 +103,11 @@ def __init__( self.on_volume_callback = on_volume_callback self.claude_client = claude_client + # Zero-arg callable returning the latest temperature sample dict (or None). + # Threaded from the agent's TemperatureSampler so burst frames carry a + # temperature block in their metadata. + self._temperature_provider = temperature_provider + # Trace file storage (writes JSON files to disk) self._session_id = session_id self._trace_dir: Path | None = None @@ -231,6 +237,13 @@ async def start( # Parse stop condition stop_cond = self._parse_stop_condition(stop_condition, condition_value) + # Tolerate a comma-separated string: some agent tool calls pass + # embryo_ids as "embryo_1,embryo_2" rather than a JSON list. Without + # this, the membership check below iterates the string character by + # character and reports every letter as a missing embryo. + if isinstance(embryo_ids, str): + embryo_ids = [e.strip() for e in embryo_ids.split(",") if e.strip()] + # Get embryo list if not embryo_ids: embryo_ids = [e.id for e in self.experiment.embryos.values() if not e.should_skip] @@ -1971,6 +1984,7 @@ def _parse_dt(s): mode=op_doc.get("mode", "1hz"), num_slices=int(op_doc.get("num_slices", 1)), request_id=op_doc.get("request_id"), + temperature_provider=self._temperature_provider, ) ) @@ -2051,6 +2065,8 @@ def queue_burst( mode: str = "1hz", num_slices: int = 1, force: bool = False, + laser_config: str | None = None, + tactic_id: str | None = None, ) -> str: """Queue a burst acquisition for ``embryo_id``. @@ -2089,6 +2105,9 @@ def queue_burst( frames=frames, mode=mode, num_slices=num_slices, + temperature_provider=self._temperature_provider, + laser_config=laser_config, + tactic_id=tactic_id, ) self._exclusive_queue.append(op) logger.info( diff --git a/gently/app/temperature_sampler.py b/gently/app/temperature_sampler.py new file mode 100644 index 00000000..3e02ba8c --- /dev/null +++ b/gently/app/temperature_sampler.py @@ -0,0 +1,110 @@ +"""Session-scoped temperature sampler — polls the device layer, persists, emits. + +Modeled on gently/app/device_state_monitor.py. While a session is active it polls +the microscope's temperature at a fixed cadence, appends each reading to the +session's temperature.jsonl, holds the latest reading (for acquisition stamping), +and publishes TEMPERATURE_UPDATE for the live graph. A failed poll is a gap, not a +crash; with no active session the loop idles. +""" + +from __future__ import annotations + +import asyncio +import logging +from datetime import datetime, timezone + +from gently.core.event_bus import EventType, get_event_bus +from gently.core.service import Service + +logger = logging.getLogger(__name__) + + +def _now_iso() -> str: + return datetime.now(timezone.utc).isoformat() + + +def temperature_stamp(latest: dict | None) -> dict | None: + """Build a temperature meta block from a latest sample, or None if unavailable.""" + if not latest: + return None + return { + "water_c": latest.get("water_c"), + "setpoint_c": latest.get("setpoint_c"), + "state": latest.get("state"), + "sampled_at": latest.get("t"), + } + + +class TemperatureSampler(Service): + def __init__(self, microscope, store, session_id_getter, interval_sec: float = 1.0): + super().__init__(name="temperature-sampler", service_type="monitor") + self._microscope = microscope + self._store = store + self._session_id_getter = session_id_getter + self._interval = interval_sec + self._task: asyncio.Task | None = None + self.latest: dict | None = None + + async def on_start(self) -> None: + self._task = asyncio.create_task(self._run(), name="temperature-sampler-loop") + + async def on_stop(self) -> None: + if self._task is not None: + self._task.cancel() + try: + await self._task + except asyncio.CancelledError: + pass + self._task = None + + async def _run(self) -> None: + bus = get_event_bus() + fail_streak = 0 + while True: + try: + await self._tick(bus) + if fail_streak: + logger.info( + "temperature sampler recovered after %d quiet failure(s)", fail_streak + ) + fail_streak = 0 + except asyncio.CancelledError: + raise + except Exception as exc: # a gap, never a crash + fail_streak += 1 + if fail_streak == 1: + # Log once, then stay quiet (e.g. device layer not connected yet). + logger.warning( + "temperature sampler paused — %s (retrying quietly until connected)", exc + ) + # Back off while failing so we neither spam logs nor hammer a disconnected server. + await asyncio.sleep( + self._interval if fail_streak == 0 else min(self._interval * 10, 30.0) + ) + + async def _tick(self, bus) -> None: + session_id = self._session_id_getter() + if not session_id: + self.latest = None + return + resp = await self._microscope.get_temperature() + if not resp or not resp.get("success", True): + self.latest = None + return + water = resp.get("temperature_c") + if water is None: + self.latest = None + return + sample = { + "t": _now_iso(), + "water_c": water, + "setpoint_c": resp.get("setpoint_c"), + "state": resp.get("state"), + } + self._store.append_temperature_sample(session_id, sample) + self.latest = sample + bus.publish( + event_type=EventType.TEMPERATURE_UPDATE, + data={"session_id": session_id, "sample": sample}, + source="temperature-sampler", + ) diff --git a/gently/app/tools/__init__.py b/gently/app/tools/__init__.py index d8ab70db..02db9d7c 100644 --- a/gently/app/tools/__init__.py +++ b/gently/app/tools/__init__.py @@ -38,10 +38,13 @@ led_tools, light_source_tools, memory_tools, + operation_plan_tools, plan_execution_tools, resolution_tools, session_tools, stage_tools, + tactic_library_tools, + temperature_protocol_tools, temperature_tools, timelapse_tools, volume_tools, @@ -84,10 +87,13 @@ def register_all_tools(): "led_tools", "light_source_tools", "memory_tools", + "operation_plan_tools", "plan_execution_tools", "resolution_tools", + "tactic_library_tools", "session_tools", "stage_tools", + "temperature_protocol_tools", "temperature_tools", "timelapse_tools", "volume_tools", diff --git a/gently/app/tools/acquisition_tools.py b/gently/app/tools/acquisition_tools.py index 8efcef79..bfc892ea 100644 --- a/gently/app/tools/acquisition_tools.py +++ b/gently/app/tools/acquisition_tools.py @@ -6,6 +6,8 @@ import asyncio import logging +import time +from typing import Any import numpy as np @@ -15,6 +17,62 @@ logger = logging.getLogger(__name__) +def _publish_scan_geometry( + agent: Any, + *, + embryo_id: str, + stage_position: dict | None, + num_slices: int, + exposure_ms: float, + galvo_amplitude: float, + galvo_center: float, + piezo_amplitude: float, + piezo_center: float, +) -> None: + """Emit SCAN_GEOMETRY_UPDATE describing the cuboid being acquired. + + Drives the 3D optical-space view (the addressable volume + the scan cuboid + and light-sheet mode). Telemetry only — callers guard against exceptions so + this never interferes with an acquisition. The payload is also stashed on + the agent for REST bootstrap (``/api/devices/scan_geometry``). + """ + from gently.core import EventType, get_event_bus + + z_extent_um = 2.0 * piezo_amplitude + slice_spacing_um = z_extent_um / (num_slices - 1) if num_slices > 1 else 0.0 + sx = stage_position.get("x") if stage_position else None + sy = stage_position.get("y") if stage_position else None + + payload: dict[str, Any] = { + "embryo_id": embryo_id, + "stage_position_um": {"x": sx, "y": sy}, + "scan": { + "num_slices": num_slices, + "exposure_ms": exposure_ms, + "galvo_amplitude_deg": galvo_amplitude, + "galvo_center_deg": galvo_center, + "piezo_amplitude_um": piezo_amplitude, + "piezo_center_um": piezo_center, + }, + "derived": { + "z_extent_um": z_extent_um, + "slice_spacing_um": slice_spacing_um, + "z_min_um": piezo_center - piezo_amplitude, + "z_max_um": piezo_center + piezo_amplitude, + }, + # diSPIM here is scanned-light-sheet only; a future pencil/beam tool + # would emit "pencil". See the 3D optical-space view notes. + "mode": "sheet", + "ts": time.time(), + } + agent.last_scan_geometry = payload + get_event_bus().publish( + event_type=EventType.SCAN_GEOMETRY_UPDATE, + data=payload, + source="acquisition-tools", + ) + + @tool( name="acquire_volume", description="""Acquire a single 3D lightsheet volume for a specific embryo. Moves to embryo @@ -88,6 +146,23 @@ async def acquire_volume( piezo_amplitude = piezo_amplitude + (additional_buffer_um * abs(slope) / 100.0) z_buffer_applied = z_buffer_um + # Publish the resolved scan geometry for the 3D optical-space view. + # Telemetry only — must never break the acquisition. + try: + _publish_scan_geometry( + agent, + embryo_id=embryo_id, + stage_position=pos, + num_slices=num_slices, + exposure_ms=exposure_ms, + galvo_amplitude=galvo_amplitude, + galvo_center=galvo_center, + piezo_amplitude=piezo_amplitude, + piezo_center=piezo_center, + ) + except Exception: + logger.debug("SCAN_GEOMETRY_UPDATE publish failed", exc_info=True) + result = await client.acquire_volume( num_slices=num_slices, exposure_ms=exposure_ms, @@ -137,6 +212,13 @@ async def acquire_volume( "piezo_center": piezo_center, }, } + from gently.app.temperature_sampler import temperature_stamp as _ts + + _temp_stamp = _ts( + getattr(getattr(agent, "temperature_sampler", None), "latest", None) + ) + if _temp_stamp is not None: + acq_metadata["temperature"] = _temp_stamp volume_path_ref = result.get("volume_path") if volume_path_ref is not None: saved_path = agent.store.register_volume( diff --git a/gently/app/tools/memory_tools.py b/gently/app/tools/memory_tools.py index d58d3e5d..401d0bec 100644 --- a/gently/app/tools/memory_tools.py +++ b/gently/app/tools/memory_tools.py @@ -129,3 +129,57 @@ async def recall_context( if not memory: return "No memory available (context store not connected)" return memory.recall_full_context(campaign_id=campaign_id) + + +@tool( + name="record_note", + description=( + "Record a note from the researcher into the shared lab notebook. Use this when the " + "user says 'note that…', 'add a note…', 'remember that…'. First tidy the phrasing for " + "clarity — keep their meaning and any specifics (numbers, strains, stages) — then save. " + "The note is attributed to the human and tagged to the current session." + ), + category=ToolCategory.UTILITY, + examples=[ + ToolExample( + user_query="Note that the 4-embryo test ran clean — 329 timepoints, system nominal.", + tool_input={ + "text": "Test run on 4 calibration embryos was clean: 329 timepoints, " + "system behaved as expected." + }, + ), + ], +) +async def record_note( + text: str, + embryos: list[str] | None = None, + strains: list[str] | None = None, + context: dict | None = None, +) -> str: + """Write a human-authored note into the notebook, tagged to the current session.""" + agent = context.get("agent") if context else None + cs = getattr(agent, "context_store", None) if agent else None + if cs is None: + return "No notebook available (context store not connected)" + from gently.harness.memory.notebook import Author, Note, NoteKind + + session_id = getattr(agent, "session_id", None) + note = Note( + id="", + kind=NoteKind.OBSERVATION, + body=text, + author=Author.HUMAN, + sessions=[session_id] if session_id else [], + embryos=embryos or [], + strains=strains or [], + ) + note_id = cs.notebook.write_note(note) + # Refresh the Notebook tab + Agent's-view live edge (both ride CONTEXT_UPDATED). + try: + from gently.core.event_bus import EventType, emit + + emit(EventType.CONTEXT_UPDATED, {"kind": "note"}, source="record_note") + except Exception: + pass + scope = "this session" if session_id else "the notebook (no active session)" + return f"Noted (id {note_id}) — saved to {scope}, attributed to you." diff --git a/gently/app/tools/operation_plan_seed.py b/gently/app/tools/operation_plan_seed.py new file mode 100644 index 00000000..3bf0caa1 --- /dev/null +++ b/gently/app/tools/operation_plan_seed.py @@ -0,0 +1,180 @@ +""" +Seed an Operation Plan from the plan item linked to a session. + +Called at session start (or lazily on first declare/render) to pre-populate +the plan's goal + planned tactics from the ImagingSpec.tactics outline of +the linked plan item. + +Hook point +---------- +gently/app/agent.py ~455-463, after ``attach_session_to_plan`` has linked +the session to its campaign. At that point call:: + + from gently.app.tools.operation_plan_seed import seed_operation_plan_from_plan_item + seed_operation_plan_from_plan_item(agent.context_store, agent.session_id) + +Resolution path (multi-hop) +---------------------------- +1. ``context_store.get_campaign_ids_for_session(session_id)`` + → list of campaign IDs from the session_intent YAML +2. For each campaign_id → ``context_store.get_plan_items(campaign_id=cid)`` + → PlanItem list +3. Find the first PlanItem where ``session_id`` is in ``plan_item.session_ids`` + (or ``plan_item.session_id == session_id``) AND + ``plan_item.imaging_spec.tactics`` is non-empty. +4. ``context_store.get_campaign(plan_item.campaign_id)`` for the goal text. +""" + +import logging +import uuid +from datetime import datetime, timezone + +logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# Internal helpers +# --------------------------------------------------------------------------- + + +def _resolve_plan_item_with_tactics(context_store, session_id: str): + """Locate the first plan item linked to *session_id* that has a tactics outline. + + Returns ``(plan_item, campaign)`` or ``(None, None)`` if nothing matches. + """ + campaign_ids = context_store.get_campaign_ids_for_session(session_id) + if not campaign_ids: + return None, None + + for cid in campaign_ids: + items = context_store.get_plan_items(campaign_id=cid) + for item in items: + # Match items that list this session + session_ids = item.session_ids or ([item.session_id] if item.session_id else []) + if session_id not in session_ids and item.session_id != session_id: + continue + # Must carry an imaging_spec with at least one tactic outline entry + if not item.imaging_spec or not item.imaging_spec.tactics: + continue + campaign = context_store.get_campaign(cid) + return item, campaign + + return None, None + + +def _outline_entry_to_tactic(entry: dict, idx: int) -> dict: + """Convert a lightweight ImagingSpec.tactics outline entry to a full planned tactic.""" + kind = entry.get("kind") or "custom" + name = entry.get("name") or f"Tactic {idx + 1}" + tactic_id = f"seed_{uuid.uuid4().hex[:8]}" + return { + "id": tactic_id, + "name": name, + "kind": kind, + "state": "planned", + "target": entry.get("target"), + "scope": entry.get("scope"), + "structure": entry.get("structure"), + "rationale": None, + "live_bind": [], + "relations": {}, + } + + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + + +def seed_operation_plan_from_plan_item(context_store, session_id: str) -> dict | None: + """Seed the Operation Plan for *session_id* from its linked plan item's outline. + + Idempotency rules + ----------------- + * **No existing plan** — seed fully; write to store; return plan. + * **Plan exists with ≥1 active or done tactic** — do NOT overwrite; return ``None``. + * **Plan exists with only planned tactics** — add any outline entries whose + *name* is not already present; if nothing new, return ``None``; otherwise + write the merged plan and return it. + + Parameters + ---------- + context_store: + A ``FileContextStore`` instance. + session_id: + The active session ID. + + Returns + ------- + dict | None + The seeded (or merged) plan dict if something was written, else ``None``. + """ + item, campaign = _resolve_plan_item_with_tactics(context_store, session_id) + if item is None: + return None + + outline: list[dict] = item.imaging_spec.tactics # type: ignore[union-attr] + if not outline: + return None + + # --- Idempotency: check existing plan --- + existing = context_store.get_operation_plan(session_id) + if existing: + existing_tactics: list[dict] = existing.get("tactics") or [] + # Guard: at least one active/done tactic — do not clobber + has_live = any(t.get("state") in ("active", "done") for t in existing_tactics) + if has_live: + logger.debug( + "seed_operation_plan: session %s already has live tactics — skipping", + session_id, + ) + return None + + # Plan exists with only planned tactics — add missing outline entries by name + existing_names = {t.get("name") for t in existing_tactics} + new_tactics = list(existing_tactics) + added = False + for i, entry in enumerate(outline): + if entry.get("name") not in existing_names: + new_tactics.append(_outline_entry_to_tactic(entry, i)) + added = True + if not added: + return None + + plan = dict(existing) + plan["tactics"] = new_tactics + plan["updated_at"] = datetime.now(timezone.utc).isoformat() + plan["updated_reason"] = "seeded: added planned tactics from plan-item outline" + context_store.set_operation_plan(session_id, plan) + logger.info( + "seed_operation_plan: added %d planned tactic(s) to existing plan for session %s", + sum(1 for t in new_tactics if t not in existing_tactics), + session_id, + ) + return plan + + # --- No existing plan: seed from scratch --- + goal = "" + if campaign: + goal = campaign.target or campaign.description or "" + + tactics = [_outline_entry_to_tactic(entry, i) for i, entry in enumerate(outline)] + + plan = { + "session_id": session_id, + "title": item.title, + "goal": goal, + "plan_item_id": item.id, + "campaign_id": item.campaign_id, + "tactics": tactics, + "updated_at": datetime.now(timezone.utc).isoformat(), + "updated_reason": "seeded from plan-item tactical outline", + } + context_store.set_operation_plan(session_id, plan) + logger.info( + "seed_operation_plan: seeded %d planned tactic(s) for session %s from plan item %s", + len(tactics), + session_id, + item.id, + ) + return plan diff --git a/gently/app/tools/operation_plan_tools.py b/gently/app/tools/operation_plan_tools.py new file mode 100644 index 00000000..b23664b6 --- /dev/null +++ b/gently/app/tools/operation_plan_tools.py @@ -0,0 +1,285 @@ +""" +Operation Plan tool — lets the agent declare/update its typed Operation Plan. + +The agent calls this at experiment planning time to register the tactics it +intends to run, and calls it again on each tactic transition (planned → +active → done). The plan is stored in FileContextStore and fires +CONTEXT_UPDATED so the Operations UI refreshes live. +""" + +import logging +from datetime import datetime, timezone + +from gently.harness.tools.helpers import require_agent +from gently.harness.tools.registry import ToolCategory, ToolExample, tool + +logger = logging.getLogger(__name__) + +# Valid kind / state values per §1 of the spec +_VALID_KINDS = frozenset( + { + "standing_timelapse", + "reactive_monitor", + "scripted_protocol", + "exclusive_burst", + "oneshot", + "custom", + } +) +_VALID_STATES = frozenset({"planned", "active", "done", "paused"}) + +# Synonym maps — normalize model variation to canonical values +_KIND_SYNONYMS: dict[str, str] = { + "monitor": "reactive_monitor", + "reactive": "reactive_monitor", + "timelapse": "standing_timelapse", + "standing": "standing_timelapse", + "protocol": "scripted_protocol", + "scripted": "scripted_protocol", + "burst": "exclusive_burst", + "reference": "standing_timelapse", +} + +_STATE_SYNONYMS: dict[str, str] = { + "in_progress": "active", + "running": "active", + "pending": "planned", + "queued": "planned", + "complete": "done", + "completed": "done", +} + +_PHASE_STATE_SYNONYMS: dict[str, str] = { + "pending": "todo", + "queued": "todo", + "todo": "todo", + "running": "active", + "in_progress": "active", + "active": "active", + "complete": "done", + "completed": "done", + "done": "done", +} + + +def _normalize_scope(scope: object) -> object: + """Normalize scope to canonical {mode, ...} object form.""" + if isinstance(scope, list): + return {"mode": "embryos", "embryo_ids": scope} + if isinstance(scope, str): + if scope in ("global", "all"): + return {"mode": "global"} + # unknown string — wrap as global fallback + return {"mode": "global"} + if isinstance(scope, dict): + if "mode" not in scope: + scope = dict(scope) + if "role" in scope: + scope["mode"] = "role" + elif "embryo_ids" in scope: + scope["mode"] = "embryos" + return scope + # unexpected type — pass through unchanged so we don't lose data + return scope + + +def _normalize_phases(phases: object) -> object: + """Normalize phase state values inside a phases list.""" + if not isinstance(phases, list): + return phases + result = [] + for phase in phases: + if isinstance(phase, dict) and "state" in phase: + raw = phase["state"] + phase = dict(phase) + phase["state"] = _PHASE_STATE_SYNONYMS.get(raw, raw) + result.append(phase) + return result + + +def _validate_tactics(tactics: list) -> list[dict]: + """Validate and normalise a tactics list. + + Each tactic must have id, name, kind, state. + Kind synonyms are mapped to canonical values; unknown kinds are clamped to + 'custom'. State synonyms are mapped to canonical values; unknown states + raise ValueError. Scope is normalized to {mode, ...} object form. + Phase states inside live.phases are normalized to todo/active/done. + """ + validated = [] + for i, t in enumerate(tactics): + if not isinstance(t, dict): + raise ValueError(f"Tactic at index {i} must be a dict, got {type(t).__name__}") + for required_field in ("id", "name", "kind", "state"): + if not t.get(required_field): + raise ValueError(f"Tactic at index {i} missing required field '{required_field}'") + tactic = dict(t) + + # --- normalize kind --- + kind = tactic["kind"] + kind = _KIND_SYNONYMS.get(kind, kind) + if kind not in _VALID_KINDS: + logger.warning( + "Tactic '%s' has unknown kind '%s' — clamped to 'custom'", + tactic.get("id"), + kind, + ) + kind = "custom" + tactic["kind"] = kind + + # --- normalize tactic state --- + state = tactic["state"] + state = _STATE_SYNONYMS.get(state, state) + if state not in _VALID_STATES: + raise ValueError( + f"Tactic '{tactic.get('id')}' has invalid state '{state}'. " + f"Must be one of: {sorted(_VALID_STATES)}" + ) + tactic["state"] = state + + # --- normalize scope --- + if "scope" in tactic: + tactic["scope"] = _normalize_scope(tactic["scope"]) + + # --- normalize phase states inside live.phases --- + live = tactic.get("live") + if isinstance(live, dict) and "phases" in live: + live = dict(live) + live["phases"] = _normalize_phases(live["phases"]) + tactic["live"] = live + + validated.append(tactic) + return validated + + +@tool( + name="declare_operation_plan", + description=( + "Declare or update the Operation Plan for the current experiment session. " + "Call this at experiment planning time to register the tactics you intend to " + "run, and call it again whenever a tactic's state changes (planned → active → " + "done) or a new tactic is added. " + "kind ∈ {standing_timelapse, reactive_monitor, scripted_protocol, " + "exclusive_burst, oneshot, custom}. " + "state ∈ {planned, active, done}. " + "Unknown kinds are clamped to 'custom'." + ), + category=ToolCategory.EXPERIMENT, + examples=[ + ToolExample( + user_query="Declare my experiment plan: baseline timelapse + onset monitor", + tool_input={ + "title": "Expression-onset survey", + "goal": "Catch GFP onset under 25 C ramp", + "tactics": [ + { + "id": "t1", + "name": "Baseline timelapse", + "kind": "standing_timelapse", + "state": "active", + "scope": {"mode": "global"}, + "rationale": "Continuous pre-ramp imaging", + "structure": {"cadence_s": 120, "per_embryo": []}, + "live_bind": ["cadence"], + "relations": {}, + } + ], + "updated_reason": "experiment started", + }, + ), + ToolExample( + user_query="Update the plan: baseline is done, onset monitor is now active", + tool_input={ + "title": "Expression-onset survey", + "goal": "Catch GFP onset under 25 C ramp", + "tactics": [ + { + "id": "t1", + "name": "Baseline timelapse", + "kind": "standing_timelapse", + "state": "done", + "scope": {"mode": "global"}, + "rationale": "Continuous pre-ramp imaging", + "structure": {"cadence_s": 120, "per_embryo": []}, + "live_bind": ["cadence"], + "relations": {}, + }, + { + "id": "t2", + "name": "Onset monitor", + "kind": "reactive_monitor", + "state": "active", + "scope": {"mode": "global"}, + "rationale": "Watch for GFP signal crossing threshold", + "structure": { + "watch": "gfp_signal > 0.3", + "reaction": "burst_capture", + "status": "armed", + }, + "live_bind": ["signal", "current_burst"], + "relations": {"after": ["t1"]}, + }, + ], + "updated_reason": "t1 done, t2 now active", + }, + ), + ], +) +async def declare_operation_plan( + title: str, + goal: str, + tactics: list, + updated_reason: str = "", + context: dict | None = None, +) -> str: + """Declare or update the typed Operation Plan for this session. + + Parameters + ---------- + title : str + Short title for this experiment's plan. + goal : str + The agent's framing of what this run is trying to achieve. + tactics : list + List of tactic dicts. Each must have id, name, kind, state. + updated_reason : str + Why the plan is being updated (e.g. 't2 transitioned to active'). + context : dict + Injected by the tool runtime — do not pass manually. + """ + agent, err = require_agent(context) + if err: + return err + + cs = getattr(agent, "context_store", None) + if cs is None: + return "Error: Context store not available — cannot persist Operation Plan" + + session_id = getattr(agent, "session_id", None) + if not session_id: + return "Error: No active session — cannot persist Operation Plan" + + try: + validated_tactics = _validate_tactics(tactics) + except ValueError as e: + return f"Error: {e}" + + plan = { + "session_id": session_id, + "title": title, + "goal": goal, + "tactics": validated_tactics, + "updated_at": datetime.now(timezone.utc).isoformat(), + "updated_reason": updated_reason, + } + + cs.set_operation_plan(session_id, plan) + + n = len(validated_tactics) + counts = {s: sum(1 for t in validated_tactics if t["state"] == s) for s in _VALID_STATES} + parts = [f"{s}:{c}" for s, c in counts.items() if c] + states_summary = ", ".join(parts) + return ( + f"Operation Plan stored for session {session_id}: " + f"'{title}' — {n} tactic{'s' if n != 1 else ''} ({states_summary})" + ) diff --git a/gently/app/tools/plan_execution_tools.py b/gently/app/tools/plan_execution_tools.py index f07e8af0..6fba52e1 100644 --- a/gently/app/tools/plan_execution_tools.py +++ b/gently/app/tools/plan_execution_tools.py @@ -107,24 +107,9 @@ async def execute_plan_item( except Exception as e: actions.append(f"detector '{det_name}' failed: {e}") - # 6. Link session to campaign - session_id = getattr(agent, "session_id", None) - if session_id: - try: - cs.link_session_campaign(session_id, item.campaign_id) - actions.append("session linked to campaign") - except Exception: - pass - - # 7. Update plan item status - cs.update_plan_item( - item_id=item.id, - status=PlanItemStatus.IN_PROGRESS, - session_id=session_id, - ) - actions.append("plan item status → in_progress") - - # 8. Start timelapse via orchestrator + # 6. Start timelapse via orchestrator — this is what activates the session, so the + # plan↔session link must happen AFTER it (step 7), not before (the old bug: + # agent.session_id was still None here, so the link silently dropped). orchestrator = getattr(agent, "timelapse_orchestrator", None) if orchestrator: try: @@ -158,7 +143,22 @@ async def execute_plan_item( except Exception as e: actions.append(f"timelapse start error: {e}") - # 10. Summary + # 7. Link this run to the plan item + campaign — AFTER start, so the session exists. + # Appends (an item may run several sessions). Surface failures, don't swallow. + session_id = getattr(agent, "session_id", None) + if session_id: + try: + cs.link_session_campaign(session_id, item.campaign_id) + cs.link_plan_item_session(item.id, session_id) + actions.append( + f"linked session {session_id[:8]} → plan item + campaign (status → in_progress)" + ) + except Exception as e: + actions.append(f"⚠ link failed: {e}") + else: + actions.append("⚠ no active session — could not link this run to the plan item") + + # Summary lines = [f"Executing plan item: {item.title}"] if spec.strain: lines.append(f" Strain: {spec.strain}") diff --git a/gently/app/tools/resolution_tools.py b/gently/app/tools/resolution_tools.py index c3a152da..3ae8b4cc 100644 --- a/gently/app/tools/resolution_tools.py +++ b/gently/app/tools/resolution_tools.py @@ -112,15 +112,17 @@ async def attach_session_to_plan( # Two sources of truth for the active plan item _set_active_plan_item(agent, item.id) - # Link the session into the campaign's intent record + # Link the session into the campaign's intent record AND onto the plan item + # itself (item-level, appends — an item may have several sessions). session_id = getattr(agent, "session_id", None) linked = False if session_id: try: cs.link_session_campaign(session_id, item.campaign_id) + cs.link_plan_item_session(item.id, session_id) linked = True except Exception as e: - logger.warning(f"link_session_campaign failed: {e}") + logger.warning(f"session↔plan link failed: {e}") # Invalidate prompt cache so the next system prompt picks up the # active item (the memory awareness layer injects its spec). diff --git a/gently/app/tools/tactic_library_tools.py b/gently/app/tools/tactic_library_tools.py new file mode 100644 index 00000000..78b141d5 --- /dev/null +++ b/gently/app/tools/tactic_library_tools.py @@ -0,0 +1,200 @@ +""" +Tactic Library tools — lets the agent save, list, and apply reusable tactics. + +save_tactic — persist a tactic dict from the current Operation Plan as a + reusable template in FileContextStore's tactic_library. +list_tactics — return a readable summary of all saved templates. +apply_tactic — instantiate a template into a fresh planned tactic and append + it to the current session's Operation Plan. + +Store + session resolution mirrors declare_operation_plan in operation_plan_tools.py. +""" + +import logging +from datetime import datetime, timezone + +from gently.harness.tools.helpers import require_agent +from gently.harness.tools.registry import ToolCategory, ToolExample, tool + +logger = logging.getLogger(__name__) + + +@tool( + name="save_tactic", + description=( + "Save a tactic from the current Operation Plan into the Tactic Library " + "as a reusable template. Pass the tactic dict (e.g. lifted directly from " + "your Operation Plan) and a human-readable name. Runtime state (live, state, " + "original id) is stripped; the template receives a new id. " + "Returns a confirmation with the new template id." + ), + category=ToolCategory.EXPERIMENT, + examples=[ + ToolExample( + user_query="Save my baseline timelapse tactic to the library", + tool_input={ + "name": "Baseline timelapse", + "tactic": { + "id": "t1", + "name": "Baseline timelapse", + "kind": "standing_timelapse", + "state": "active", + "scope": {"mode": "global"}, + "rationale": "Continuous pre-ramp imaging", + "structure": {"cadence_s": 120, "per_embryo": []}, + "live_bind": ["cadence"], + "relations": {}, + }, + "description": "Standard 2-minute cadence baseline timelapse", + }, + ) + ], +) +async def save_tactic( + name: str, + tactic: dict, + description: str = "", + context: dict | None = None, +) -> str: + """Save a tactic as a reusable template in the Tactic Library. + + Parameters + ---------- + name : str + Human-readable name for the template. + tactic : dict + The tactic dict to save (e.g. from the current Operation Plan). + description : str + Optional description; if provided, overrides tactic's rationale field. + context : dict + Injected by the tool runtime — do not pass manually. + """ + agent, err = require_agent(context) + if err: + return err + + cs = getattr(agent, "context_store", None) + if cs is None: + return "Error: Context store not available — cannot save tactic" + + # Merge description into tactic so save_tactic picks it up via tactic.get("description") + tactic_to_save = dict(tactic) + if description: + tactic_to_save["description"] = description + + tid = cs.save_tactic(tactic_to_save, name=name) + return f"Tactic '{name}' saved to library with id {tid}" + + +@tool( + name="list_tactics", + description=( + "List all tactics saved in the Tactic Library. " + "Returns a summary of each template: id, name, kind." + ), + category=ToolCategory.EXPERIMENT, + examples=[ + ToolExample( + user_query="Show me the tactics in the library", + tool_input={}, + ) + ], +) +async def list_tactics( + context: dict | None = None, +) -> str: + """List all saved tactic templates. + + Parameters + ---------- + context : dict + Injected by the tool runtime — do not pass manually. + """ + agent, err = require_agent(context) + if err: + return err + + cs = getattr(agent, "context_store", None) + if cs is None: + return "Error: Context store not available — cannot list tactics" + + tactics = cs.list_tactics() + if not tactics: + return "Tactic Library is empty — no saved templates yet." + + lines = [f"Tactic Library ({len(tactics)} template{'s' if len(tactics) != 1 else ''}):"] + for t in tactics: + lines.append( + f" [{t.get('id', '?')}] {t.get('name', '(unnamed)')} — kind: {t.get('kind', '?')}" + ) + return "\n".join(lines) + + +@tool( + name="apply_tactic", + description=( + "Instantiate a saved tactic template and append it to the current session's " + "Operation Plan. The template is looked up by id or name, a fresh planned tactic " + "is created (new id, state='planned', no runtime state), and it is queued in the " + "plan. If no plan exists for this session, a minimal one is created. " + "Returns an error if the tactic is not found." + ), + category=ToolCategory.EXPERIMENT, + examples=[ + ToolExample( + user_query="Apply the baseline timelapse tactic to the current session", + tool_input={"id_or_name": "Baseline timelapse"}, + ) + ], +) +async def apply_tactic( + id_or_name: str, + context: dict | None = None, +) -> str: + """Instantiate a tactic template and append it to the current Operation Plan. + + Parameters + ---------- + id_or_name : str + The template id or name to look up. + context : dict + Injected by the tool runtime — do not pass manually. + """ + agent, err = require_agent(context) + if err: + return err + + cs = getattr(agent, "context_store", None) + if cs is None: + return "Error: Context store not available — cannot apply tactic" + + session_id = getattr(agent, "session_id", None) + if not session_id: + return "Error: No active session — cannot apply tactic to Operation Plan" + + fresh = cs.apply_tactic(id_or_name) + if fresh is None: + return f"Error: No tactic found with id or name '{id_or_name}'" + + # Fetch or create the plan for this session + plan = cs.get_operation_plan(session_id) + if plan is None: + plan = { + "session_id": session_id, + "title": "", + "goal": "", + "tactics": [], + } + + plan.setdefault("tactics", []) + plan["tactics"].append(fresh) + plan["updated_at"] = datetime.now(timezone.utc).isoformat() + plan["updated_reason"] = f"tactic '{fresh.get('name', id_or_name)}' applied from library" + + cs.set_operation_plan(session_id, plan) + + tactic_name = fresh.get("name", id_or_name) + return ( + f"Tactic '{tactic_name}' (id {fresh['id']}) instantiated from library and " + f"queued as 'planned' in the Operation Plan for session {session_id}" + ) diff --git a/gently/app/tools/temperature_protocol_tools.py b/gently/app/tools/temperature_protocol_tools.py new file mode 100644 index 00000000..80d6166a --- /dev/null +++ b/gently/app/tools/temperature_protocol_tools.py @@ -0,0 +1,120 @@ +""" +Temperature Protocol Tools + +Agent tool for the scripted temp-change burst protocol. The thermal ramp takes +several minutes so the driver runs as a background asyncio task — the agent turn +returns immediately with a "started" confirmation. +""" + +import asyncio + +from gently.harness.tools.helpers import ( + require_agent, + require_microscope, + require_timelapse_orchestrator, +) +from gently.harness.tools.registry import ToolCategory, ToolExample, tool + + +@tool( + name="run_temp_change_burst_protocol", + description=( + "Start a scripted temperature-change burst protocol in the background. " + "Acquires brightfield burst(s) before the setpoint change, then polls the " + "thermal controller — imaging continuously during the ramp — and acquires " + "burst(s) after lock is reached. " + "The thermal ramp typically takes several minutes; this tool returns " + "immediately and the protocol runs as a background task. " + "Use when you want time-resolved brightfield data spanning a temperature " + "transition (e.g. cold-shock → recovery, 15 C → 25 C developmental-rate shift)." + ), + category=ToolCategory.EXPERIMENT, + requires_microscope=True, + examples=[ + ToolExample( + "Capture a temperature shift from 15 C to 25 C for embryo_1", + {"embryo_id": "embryo_1", "target_setpoint_c": 25.0}, + ), + ToolExample( + "Cold-shock embryo_2 to 15 C with 2 bursts before and after", + { + "embryo_id": "embryo_2", + "target_setpoint_c": 15.0, + "bursts_before": 2, + "bursts_after": 2, + }, + ), + ], +) +async def run_temp_change_burst_protocol_tool( + embryo_id: str, + target_setpoint_c: float, + frames: int = 60, + bursts_before: int = 1, + bursts_after: int = 1, + tactic_id: str | None = None, + context: dict | None = None, +) -> str: + """Launch the temp-change burst driver as a background asyncio task. + + Parameters + ---------- + embryo_id : str + ID of the embryo to image during the temperature transition. + target_setpoint_c : float + Target temperature setpoint in degrees Celsius. + frames : int + Number of frames per burst acquisition (default 60). + bursts_before : int + Number of brightfield bursts to capture before the setpoint change (default 1). + bursts_after : int + Number of brightfield bursts to capture after the controller locks (default 1). + """ + agent, err = require_agent(context) + if err: + return err + + orchestrator, err = require_timelapse_orchestrator(agent) + if err: + return err + + client, err = require_microscope(context) + if err: + return err + + from gently.app.orchestration.temperature_protocol import ( + run_temp_change_burst_protocol as _driver, + ) + from gently.app.orchestration.timelapse_models import TimelapseStatus + + if getattr(orchestrator, "_status", None) == TimelapseStatus.RUNNING: + return ( + "Refusing to start temp-change protocol: a timelapse is currently running " + "(would contend for the RunEngine). Stop the timelapse first." + ) + + # Flip the matching plan tactic to active before launching the background driver + # (guarded no-op when tactic_id or context store are absent). + if tactic_id: + cs = getattr(agent, "context_store", None) + session = getattr(agent, "session_id", None) + if cs and session: + cs.transition_tactic(session, tactic_id, "active") + + asyncio.create_task( + _driver( + orchestrator, + embryo_id, + target_setpoint_c, + frames=frames, + bursts_before=bursts_before, + bursts_after=bursts_after, + tactic_id=tactic_id, + ) + ) + + return ( + f"Temp-change burst protocol started for {embryo_id} → {target_setpoint_c} C " + f"({bursts_before} burst(s) before, {bursts_after} burst(s) after lock). " + f"Running in background — use get_temperature to monitor ramp progress." + ) diff --git a/gently/app/tools/timelapse_tools.py b/gently/app/tools/timelapse_tools.py index d5ec3d76..e9b1427f 100644 --- a/gently/app/tools/timelapse_tools.py +++ b/gently/app/tools/timelapse_tools.py @@ -64,9 +64,15 @@ async def start_adaptive_timelapse( interval_seconds: float = 120.0, condition_value: int | None = None, monitoring_mode: str | None = None, + tactic_id: str | None = None, context: dict | None = None, ) -> str: - """Start adaptive timelapse in background""" + """Start adaptive timelapse in background. + + ``tactic_id`` (optional) links this start to a tactic in the session + Operation Plan: on success the tactic is transitioned to 'active', giving + lifecycle symmetry with stop/pause/enable_monitoring_mode/queue_burst. + """ agent, err = require_agent(context) if err: return err @@ -112,6 +118,16 @@ async def start_adaptive_timelapse( except Exception as e: result += f"\n(Failed to enable monitoring mode '{monitoring_mode}': {e})" + # Lifecycle symmetry: mark the linked tactic active (best-effort). + if tactic_id: + try: + cs = getattr(agent, "context_store", None) + session_id = getattr(agent, "session_id", None) + if cs and session_id: + cs.transition_tactic(session_id, tactic_id, "active") + except Exception: + pass # never block the timelapse + return result except Exception as e: return f"Error starting timelapse: {str(e)}" @@ -267,7 +283,11 @@ async def stop_timelapse_embryo( category=ToolCategory.EXPERIMENT, requires_microscope=True, ) -async def stop_timelapse(reason: str = "user_request", context: dict | None = None) -> str: +async def stop_timelapse( + reason: str = "user_request", + tactic_id: str | None = None, + context: dict | None = None, +) -> str: """Stop entire timelapse""" agent, err = require_agent(context) if err: @@ -279,6 +299,12 @@ async def stop_timelapse(reason: str = "user_request", context: dict | None = No try: result = await orchestrator.stop(reason) + # Mark the linked plan tactic done (guarded no-op when absent). + if tactic_id: + cs = getattr(agent, "context_store", None) + session = getattr(agent, "session_id", None) + if cs and session: + cs.transition_tactic(session, tactic_id, "done") return result except Exception as e: return f"Error stopping timelapse: {str(e)}" @@ -290,7 +316,10 @@ async def stop_timelapse(reason: str = "user_request", context: dict | None = No category=ToolCategory.EXPERIMENT, requires_microscope=True, ) -async def pause_timelapse(context: dict | None = None) -> str: +async def pause_timelapse( + tactic_id: str | None = None, + context: dict | None = None, +) -> str: """Pause timelapse""" agent, err = require_agent(context) if err: @@ -302,6 +331,12 @@ async def pause_timelapse(context: dict | None = None) -> str: try: result = await orchestrator.pause() + # Mark the linked plan tactic paused (guarded no-op when absent). + if tactic_id: + cs = getattr(agent, "context_store", None) + session = getattr(agent, "session_id", None) + if cs and session: + cs.transition_tactic(session, tactic_id, "paused") return result except Exception as e: return f"Error pausing timelapse: {str(e)}" @@ -1024,6 +1059,7 @@ def get_photodose_status(context: dict | None = None) -> str: ) def enable_monitoring_mode( mode_name: str, + tactic_id: str | None = None, context: dict | None = None, ) -> str: """Install a named reactive monitoring mode on the orchestrator.""" @@ -1036,7 +1072,14 @@ def enable_monitoring_mode( return err try: - return orchestrator.enable_monitoring_mode(mode_name) + result = orchestrator.enable_monitoring_mode(mode_name) + # Flip the matching plan tactic to active (guarded no-op when absent). + if tactic_id: + cs = getattr(agent, "context_store", None) + session = getattr(agent, "session_id", None) + if cs and session: + cs.transition_tactic(session, tactic_id, "active") + return result except Exception as e: return f"Error enabling monitoring mode '{mode_name}': {str(e)}" @@ -1153,6 +1196,7 @@ def queue_burst( mode: str = "1hz", num_slices: int = 1, force: bool = False, + tactic_id: str | None = None, context: dict | None = None, ) -> str: """Queue an exclusive burst acquisition for one embryo.""" @@ -1165,12 +1209,24 @@ def queue_burst( return err try: - return orchestrator.queue_burst( + result = orchestrator.queue_burst( embryo_id=embryo_id, frames=frames, mode=mode, num_slices=num_slices, force=force, + tactic_id=tactic_id, ) + # Flip the matching plan tactic to active only when the burst was actually queued. + # orchestrator.queue_burst returns "Burst queued for ..." on success and a + # human-readable rejection sentence on soft-reject (embryo absent, already had + # a burst, already has a queued burst). Gate on the success prefix so a + # soft-reject does NOT phantom-flip the tactic to active. + if tactic_id and isinstance(result, str) and result.startswith("Burst queued for "): + cs = getattr(agent, "context_store", None) + session = getattr(agent, "session_id", None) + if cs and session: + cs.transition_tactic(session, tactic_id, "active") + return result except Exception as e: return f"Error queueing burst for {embryo_id}: {str(e)}" diff --git a/gently/core/event_bus.py b/gently/core/event_bus.py index 8a55c99f..31cbaf76 100644 --- a/gently/core/event_bus.py +++ b/gently/core/event_bus.py @@ -85,14 +85,25 @@ class EventType(Enum): FOCUS_CHANGED = auto() LASER_CHANGED = auto() DEVICE_STATE_UPDATE = auto() # Periodic device-state snapshot from device layer + TEMPERATURE_UPDATE = auto() # Temperature reading from device layer BOTTOM_CAMERA_FRAME = auto() # Live JPEG frame from the bottom camera stream + LIGHTSHEET_FRAME = auto() # Live JPEG frame from the SPIM lightsheet live stream EMBRYOS_UPDATE = auto() # Full embryo list snapshot from agent.experiment + SCAN_GEOMETRY_UPDATE = auto() # Scan cuboid + light-sheet mode for the 3D optical-space view # Python logging.LogRecord republished onto the bus so the Events page # surfaces what would otherwise only land in the terminal. See # gently/core/log_bridge.py — opt-in handler. LOG_RECORD = auto() + # Agent context/mind updates (expectations / watchpoints / questions) — + # drives the shared-visibility surface in the v2 UI. + CONTEXT_UPDATED = auto() + + # Plan/campaign mutated (item status, session link, new item, progress) — + # drives live refresh of the Plans UI. + PLAN_UPDATED = auto() + # Operator-action events. Distinct from EMBRYOS_UPDATE because they # carry intent ("a human did this") rather than just state delta. # Candidate orchestrators can subscribe and reason about what the @@ -117,6 +128,13 @@ class EventType(Enum): BURST_FRAME = auto() # {embryo_id, request_id, frame_idx, total_frames} BURST_COMPLETE = auto() # {embryo_id, request_id, mp4_path, sustained_hz, frames_captured} + # Temperature protocol events (Phase X / 10) — temp-change burst tactic + TEMPERATURE_SETPOINT_CHANGED = auto() # {embryo_id, to} + TEMP_PROTOCOL_STARTED = ( + auto() + ) # {embryo_id, target_setpoint_c, frames, bursts_before, bursts_after} + TEMP_PROTOCOL_COMPLETED = auto() # {embryo_id, locked, cancelled, error} + # Reactive control telemetry (Phase 5 / 10) POWER_RAMP_STEP = auto() # {embryo_id, rule, wavelength, old_pct, new_pct, direction} @@ -177,7 +195,9 @@ class EventType(Enum): _NO_HISTORY_TYPES = frozenset( { EventType.DEVICE_STATE_UPDATE, + EventType.TEMPERATURE_UPDATE, # High-volume telemetry from temperature controller EventType.BOTTOM_CAMERA_FRAME, # ~2 Hz JPEG frames — would crowd history out + EventType.LIGHTSHEET_FRAME, # High-volume live frames — keep out of history EventType.LOG_RECORD, # log lines can hit hundreds/min during # calibration; durable copy is in the # gently_*.log file already diff --git a/gently/core/file_store.py b/gently/core/file_store.py index d10f9efe..4db796e8 100644 --- a/gently/core/file_store.py +++ b/gently/core/file_store.py @@ -215,6 +215,37 @@ def _read_jsonl(path: Path) -> list[dict]: return records +def _last_jsonl_record(path: Path) -> dict | None: + """Return the last parseable JSON record in a JSONL file, reading only the tail. + + Keeps appends O(1): instead of loading + parsing the whole file (which made + per-prediction writes O(n) and quadratic over a long timelapse), we read a + bounded window from the end and walk backwards to the last complete line, + skipping a possible trailing partial line from an interrupted write. + """ + if not path.exists(): + return None + try: + size = path.stat().st_size + except OSError: + return None + if size == 0: + return None + window = min(size, 65536) + with open(path, "rb") as f: + f.seek(size - window) + data = f.read(window) + for line in reversed(data.split(b"\n")): + line = line.strip() + if not line: + continue + try: + return json.loads(line) + except (ValueError, UnicodeDecodeError): + continue + return None + + def _now() -> str: return datetime.now().isoformat() @@ -445,6 +476,38 @@ def load_session_snapshot(self, session_id: str) -> dict | None: with open(path, encoding="utf-8") as f: return json.load(f) + def append_temperature_sample(self, session_id: str, sample: dict) -> None: + """Append one temperature reading to the session's temperature.jsonl.""" + sd = self._require_session_dir(session_id) + _append_jsonl(sd / "temperature.jsonl", sample) + + def read_temperature_log(self, session_id: str, since: str | None = None) -> list[dict]: + """Return temperature samples for a session, optionally filtered to + t >= since (ISO-UTC string). + + Reads lines tolerantly: a truncated trailing line (e.g. after a mid-append + crash) is silently skipped rather than raising a JSONDecodeError. + """ + sd = self._session_dir(session_id) + if sd is None: + return [] + path = sd / "temperature.jsonl" + if not path.exists(): + return [] + rows = [] + with open(path, encoding="utf-8") as f: + for line in f: + line = line.strip() + if not line: + continue + try: + rows.append(json.loads(line)) + except json.JSONDecodeError: + pass # truncated or corrupt line — skip + if since is not None: + rows = [r for r in rows if str(r.get("t", "")) >= since] + return rows + # ------------------------------------------------------------------ # Session lock # ------------------------------------------------------------------ @@ -486,6 +549,7 @@ def register_embryo( position_fine: dict | None = None, calibration: dict | None = None, role: str | None = None, + strain: str | None = None, ) -> None: """Register or update an embryo in a session. @@ -493,6 +557,10 @@ def register_embryo( (e.g. ``"test"``, ``"calibration"``, ``"unassigned"``). Persisted in embryo.yaml. None preserves the existing value on update. + ``strain`` is a free-form biological sample descriptor (e.g. + ``"pan-nuclear GFP"``). Orthogonal to role. None preserves the existing + value on update. + Position has two stages: coarse (bottom-camera / manual map placement) and fine (future SPIM-objective alignment). New callers should pass position_coarse / position_fine as dicts of shape {"x": float, "y": @@ -532,6 +600,7 @@ def register_embryo( if calibration is not None else existing.get("calibration"), "role": role if role is not None else existing.get("role", "test"), + "strain": strain if strain is not None else existing.get("strain"), "created_at": existing.get("created_at", _now()), } else: @@ -544,6 +613,7 @@ def register_embryo( "position_fine": position_fine, "calibration": calibration, "role": role if role is not None else "test", + "strain": strain, "created_at": _now(), } @@ -738,6 +808,14 @@ def get_volume_path(self, session_id: str, embryo_id: str, timepoint: int) -> Pa return vol_path return None + def get_volume_meta(self, session_id: str, embryo_id: str, timepoint: int) -> dict | None: + """Read the sidecar metadata YAML for a volume. Returns None if not found.""" + sd = self._session_dir(session_id) + if sd is None: + return None + meta_path = sd / "embryos" / embryo_id / "volumes" / self._volume_meta_filename(timepoint) + return _read_yaml(meta_path) + def list_volumes(self, session_id: str, embryo_id: str | None = None) -> list[VolumeInfo]: """List volume metadata by scanning sidecar YAML files on disk.""" sd = self._session_dir(session_id) @@ -1102,15 +1180,14 @@ def store_prediction( json.dump(trace_data, f, indent=2, ensure_ascii=False, default=str) trace_file = str(trace_path) - # Compute prediction_id: count existing predictions in this embryo's - # JSONL and add 1. This gives a session-global unique id as long as - # we read all embryos, but for simplicity we count per-embryo and add - # an offset based on embryo ordering. A simpler and safer approach: - # use a session-level counter stored in perception_runs.yaml. + # Per-embryo prediction_id = previous max + 1. Derived from the LAST + # record only (bounded tail read) rather than re-parsing the whole + # predictions.jsonl on every append — ids stay sequential because we + # only ever append in order. sd = self._require_session_dir(session_id) pred_path = sd / "embryos" / embryo_id / "predictions.jsonl" - existing = _read_jsonl(pred_path) - prediction_id = len(existing) + 1 + last = _last_jsonl_record(pred_path) + prediction_id = (last.get("prediction_id", 0) + 1) if last else 1 record: PredictionInfo = { "prediction_id": prediction_id, diff --git a/gently/core/store_types.py b/gently/core/store_types.py index aa3eeaff..1b7a60fe 100644 --- a/gently/core/store_types.py +++ b/gently/core/store_types.py @@ -32,6 +32,7 @@ class EmbryoInfo(TypedDict, total=False): position_y: float | None calibration: dict | None role: str | None # key into gently.harness.roles.REGISTRY + strain: str | None # free-form biological sample descriptor, e.g. "pan-nuclear GFP" created_at: str diff --git a/gently/hardware/dispim/client.py b/gently/hardware/dispim/client.py index 4aafaa8f..98807c29 100644 --- a/gently/hardware/dispim/client.py +++ b/gently/hardware/dispim/client.py @@ -381,8 +381,18 @@ async def get_stage_position(self) -> tuple[float, float]: events = docs.get("events", []) if events: data = events[0].get("data", {}) - # Look for stage coordinates - for key in ["XY:31", "xy_stage", "stage"]: + # Look for stage coordinates. Keys must match what + # read_stage_plan (bp.count on the xy_stage device) actually + # emits — the device-layer's own handle_detect_embryos uses + # "XYStage:XY:31"/"xy_stage_position", so include those here too + # (the bare "XY:31" was stale and never matched). + for key in [ + "xy_stage", + "XYStage:XY:31", + "xy_stage_position", + "XY:31", + "stage", + ]: if key in data: val = data[key] if isinstance(val, (list, tuple)) and len(val) >= 2: @@ -809,6 +819,38 @@ async def get_laser_power(self, wavelength: int) -> dict: except Exception as e: return {"success": False, "error": str(e)} + async def set_laser_config(self, config_name: str) -> dict: + """Apply a Laser config-group preset (e.g. "ALL OFF"). + + Hits ``POST /api/laser/config`` — direct, no Bluesky queue. + Use ``"ALL OFF"`` to gate every laser line off via the PLogic + OutputChannel; other presets from the MM config group are also + accepted (e.g. ``"488 only"``, ``"561 only"``). + + Parameters + ---------- + config_name : str + Exact preset name from the Laser config group. + """ + return await self._api_post("/api/laser/config", {"config": config_name}) + + async def get_laser_configs(self) -> dict: + """List available Laser config-group presets. + + Hits ``GET /api/laser/configs`` — returns ``{"configs": [...]}`` + with the preset names from the MM Laser config group. + """ + return await self._api_get("/api/laser/configs") + + async def get_cameras(self) -> dict: + """List available SPIM camera roles. + + Hits ``GET /api/cameras`` — returns ``{"cameras": ["A"]}`` on + single-camera rigs or ``{"cameras": ["A", "B"]}`` when HamCam2 is + registered as camera_b in the device layer. + """ + return await self._api_get("/api/cameras") + async def get_led_status(self) -> dict: """Get current LED status.""" return await self._api_get("/api/led/status") @@ -836,6 +878,19 @@ async def get_temperature(self) -> dict: """Get current temperature, setpoint, and lock state.""" return await self._api_get("/api/temperature/status") + async def get_temperature_config(self) -> dict: + """Get the thermalizer connection config (password redacted) + live state.""" + return await self._api_get("/api/temperature/config") + + async def set_temperature_config(self, cfg: dict) -> dict: + """Reconfigure the thermalizer (serial/mqtt/mock) — live hot-swap where + possible, else persisted for the next device-layer restart.""" + return await self._api_post("/api/temperature/config", cfg) + + async def test_temperature_config(self, cfg: dict) -> dict: + """Probe a candidate thermalizer config without committing it.""" + return await self._api_post("/api/temperature/config/test", cfg) + # ------------------------------------------------------------------ # Live device-state readout (streamed from the device layer poller) # ------------------------------------------------------------------ @@ -954,6 +1009,59 @@ async def stream_bottom_camera(self, timeout: float | None = None): except Exception as exc: logger.warning("Malformed bottom-camera SSE payload skipped: %s", exc) + async def stream_lightsheet(self, timeout: float | None = None): + """Async generator yielding JPEG frames from the lightsheet live SSE stream. + + Mirrors :meth:`stream_bottom_camera`; subscriber-gated on the device layer. + """ + self._ensure_connected() + client_timeout = aiohttp.ClientTimeout( + total=None, + sock_read=timeout, + sock_connect=10.0, + ) + url = f"{self.http_url}/api/lightsheet/stream" + assert self._session is not None + async with self._session.get(url, timeout=client_timeout) as resp: + resp.raise_for_status() + buf = b"" + async for chunk in resp.content.iter_any(): + if not chunk: + continue + buf += chunk + while b"\n\n" in buf: + event_block, buf = buf.split(b"\n\n", 1) + data_lines = [] + for line in event_block.splitlines(): + if not line or line.startswith(b":"): + continue + if line.startswith(b"data:"): + data_lines.append(line[5:].lstrip()) + if not data_lines: + continue + raw = b"\n".join(data_lines).decode("utf-8", errors="replace") + try: + import json as _json + + yield _json.loads(raw) + except Exception as exc: + logger.warning("Malformed lightsheet SSE payload skipped: %s", exc) + + async def set_lightsheet_live_params( + self, galvo=None, piezo=None, exposure=None, side=None + ) -> dict: + """POST live galvo/piezo/exposure/side to the device-layer lightsheet streamer.""" + body: dict[str, float | str] = {} + if galvo is not None: + body["galvo"] = float(galvo) + if piezo is not None: + body["piezo"] = float(piezo) + if exposure is not None: + body["exposure"] = float(exposure) + if side is not None: + body["side"] = str(side) + return await self._api_post("/api/lightsheet/live/params", body) + async def set_camera_led_mode(self, use_led: bool = False) -> dict: """Enable/disable automatic LED for bottom camera captures.""" return await self._api_post("/api/camera/led_mode", {"use_led": use_led}) @@ -966,6 +1074,28 @@ async def get_bottom_camera_exposure(self) -> dict: """Get current bottom camera exposure time.""" return await self._api_get("/api/camera/exposure") + # ------------------------------------------------------------------ + # Fenced focus axes — bottom-camera focus Z and SPIM-head F-drive. + # Read + relative nudge only (no autofocus). Out-of-range nudges come + # back as a non-success dict (the device layer returns 400). + # ------------------------------------------------------------------ + + async def get_bottom_z(self) -> dict: + """Current bottom-camera focus Z position + limits.""" + return await self._api_get("/api/stage/bottom_z") + + async def nudge_bottom_z(self, delta: float) -> dict: + """Fenced relative move of the bottom-camera focus Z by ``delta`` µm.""" + return await self._api_post("/api/stage/bottom_z/nudge", {"delta": float(delta)}) + + async def get_fdrive(self) -> dict: + """Current SPIM-head F-drive position + limits + distance to floor.""" + return await self._api_get("/api/spim/fdrive") + + async def nudge_fdrive(self, delta: float) -> dict: + """Fenced relative move of the SPIM-head F-drive by ``delta`` µm.""" + return await self._api_post("/api/spim/fdrive/nudge", {"delta": float(delta)}) + async def capture_bottom_image( self, use_led: bool = False, exposure_ms: float | None = None ) -> dict: @@ -1255,7 +1385,8 @@ async def capture_for_marking( self._ensure_connected() try: - snap = await self.capture_bottom_image(use_led=True, exposure_ms=exposure_ms) + # No LED ever — the bottom camera images under room light only. + snap = await self.capture_bottom_image(use_led=False, exposure_ms=exposure_ms) image = snap["image"] if image is None or (image.shape == (100, 100) and image.max() == 0): diff --git a/gently/hardware/dispim/device_factory.py b/gently/hardware/dispim/device_factory.py index f66dd9e9..b2f182ba 100644 --- a/gently/hardware/dispim/device_factory.py +++ b/gently/hardware/dispim/device_factory.py @@ -65,9 +65,16 @@ def create_devices_from_mmcore(core: pymmcore.CMMCore, config: dict | None = Non default_config = { "xy_stage_name": "XYStage:XY:31", "camera_name": "HamCam1", + "camera_b_name": "HamCam2", "scanner_name": "Scanner:AB:33", "piezo_name": "PiezoStage:P:34", + # Side-B optics: registered defensively (absent on single-side rigs) + "scanner_b_name": "Scanner:CD:33", + "piezo_b_name": "PiezoStage:Q:35", "fdrive_name": "ZStage:V:37", + # Bottom-camera focus Z (sample Z). ASI Tiger "ZStage:Z:32" — distinct + # from the SPIM-head F-drive (ZStage:V:37). Registered defensively below. + "z_stage_name": "ZStage:Z:32", "bottom_camera_name": "Bottom PCO", "led_name": "LED:X:31", } @@ -109,6 +116,60 @@ def create_devices_from_mmcore(core: pymmcore.CMMCore, config: dict | None = Non except Exception as e: logger.warning("Could not create camera: %s", e) + # Defensively register the second SPIM camera (side B) only when present + # in the core's loaded-device list. Single-camera rigs that lack HamCam2 + # continue to start normally; camera_b is simply absent from devices. + try: + from .devices import DiSPIMCamera as _DiSPIMCamera + + cam_b_name = cfg.get("camera_b_name", "HamCam2") + loaded_devices = list(core.getLoadedDevices()) + if cam_b_name in loaded_devices: + camera_b = _DiSPIMCamera(device_name=cam_b_name, core=core) + devices["camera_b"] = camera_b + logger.info("Created camera_b (side B): %s", cam_b_name) + else: + logger.warning( + "camera_b (%s) not in loaded devices — single-camera rig or device absent; skipping", # noqa: E501 + cam_b_name, + ) + except Exception as e: + logger.warning("Could not create camera_b: %s", e) + + # Defensively register the side-B galvo scanner (Scanner:CD:33). + # Absent on single-side rigs — skip + log, do not crash. + try: + scanner_b_name = cfg.get("scanner_b_name", "Scanner:CD:33") + loaded_devices = list(core.getLoadedDevices()) + if scanner_b_name in loaded_devices: + scanner_b = DiSPIMScanner(name=scanner_b_name, core=core) + devices["scanner_b"] = scanner_b + logger.info("Created scanner_b (side B): %s", scanner_b_name) + else: + logger.warning( + "scanner_b (%s) not in loaded devices — single-side rig or device absent; skipping", + scanner_b_name, + ) + except Exception as e: + logger.warning("Could not create scanner_b: %s", e) + + # Defensively register the side-B imaging piezo (PiezoStage:Q:35). + # Absent on single-side rigs — skip + log, do not crash. + try: + piezo_b_name = cfg.get("piezo_b_name", "PiezoStage:Q:35") + loaded_devices = list(core.getLoadedDevices()) + if piezo_b_name in loaded_devices: + piezo_b = DiSPIMPiezo(name=piezo_b_name, core=core) + devices["piezo_b"] = piezo_b + logger.info("Created piezo_b (side B): %s", piezo_b_name) + else: + logger.warning( + "piezo_b (%s) not in loaded devices — single-side rig or device absent; skipping", + piezo_b_name, + ) + except Exception as e: + logger.warning("Could not create piezo_b: %s", e) + try: from .devices import DiSPIMLightSource @@ -138,6 +199,24 @@ def create_devices_from_mmcore(core: pymmcore.CMMCore, config: dict | None = Non except Exception as e: logger.warning("Could not create F-drive (SPIM head): %s", e) + # Bottom-camera focus Z (sample Z). Registered defensively: rigs that don't + # expose the axis under z_stage_name simply skip it (focus stays manual). + try: + from .devices import DiSPIMZstage + + z_stage_name = cfg.get("z_stage_name", "ZStage:Z:32") + loaded_devices = list(core.getLoadedDevices()) + if z_stage_name in loaded_devices: + devices["z_stage"] = DiSPIMZstage(name=z_stage_name, core=core) + logger.info("Created bottom-camera focus Z (z_stage): %s", z_stage_name) + else: + logger.warning( + "z_stage (%s) not in loaded devices — bottom-cam focus Z absent; skipping", + z_stage_name, + ) + except Exception as e: + logger.warning("Could not create z_stage (bottom-cam focus Z): %s", e) + try: if cfg.get("led_name"): from .devices import DiSPIMLED diff --git a/gently/hardware/dispim/device_layer.py b/gently/hardware/dispim/device_layer.py index df31720d..3d5b23bc 100644 --- a/gently/hardware/dispim/device_layer.py +++ b/gently/hardware/dispim/device_layer.py @@ -76,7 +76,7 @@ def __init__( ): super().__init__(name="device-layer", service_type="hardware", host=host, port=port) self.config_path = config_path - self.config = None + self.config: dict | None = None # DiSPIMSystem facade — only place this process touches MMCore directly self.system: Any = None self.RE = None @@ -169,6 +169,31 @@ def __init__( self._cam_target_max_dim: int = 360 # ~360px thumbnail self._cam_jpeg_quality: int = 55 + # Lightsheet (SPIM) live stream — continuous sequence acquisition. + self._ls_subscribers: list[asyncio.Queue] = [] + self._ls_task: asyncio.Task | None = None + # Min interval between streamed frames. A floor (not 0) caps the live + # FPS so short exposures don't flood the browser at 100+ fps, which + # churns GPU texture uploads in the renderer and can TDR an + # older display driver. ~15 fps is plenty for live focus/centering; + # long exposures still dominate via max(interval, exposure) below. + self._ls_interval_sec: float = 1.0 / 15.0 # cap live stream at ~15 fps + self._ls_target_max_dim: int = 512 + self._ls_jpeg_quality: int = 70 + self._ls_params: dict = { + "galvo": 0.0, + "piezo": 0.0, + "exposure": 20.0, + "side": "A", + # "snap" → per-frame snapImage()+getImage() — hard memory ceiling, safe default. + # "continuous" → startContinuousSequenceAcquisition (opt-in, higher FPS). + "mode": "snap", + } + self._ls_seq_started: bool = False + self._ls_applied: dict = {} # last-applied galvo/piezo/exposure + self._ls_parked: dict = {} # last-parked galvo/piezo setPosition values + self._ls_spim_idle: bool = False # whether SPIM state machine was set Idle this session + # Plans that hold MMCore for long performance-critical work. # Anything in this set runs with state polling paused. self._heavy_plans = frozenset( @@ -201,6 +226,20 @@ async def initialize(self): logger.info("[1/5] Loading configuration...") with open(self.config_path) as f: self.config = yaml.safe_load(f) + # Merge operator overrides from config.local.yml over the committed + # config.yml (keeps config.yml's comments intact). Currently the + # thermalizer block, set live from the settings panel; survives restart. + try: + sidecar = Path(self.config_path).parent / "config.local.yml" + if sidecar.exists(): + with open(sidecar) as sf: + override = yaml.safe_load(sf) or {} + if isinstance(override, dict) and override.get("temperature"): + self.config = self.config or {} + self.config["temperature"] = override["temperature"] + logger.info("Applied thermalizer override from %s", sidecar) + except Exception: + logger.warning("config.local.yml merge failed", exc_info=True) logger.info("Config loaded from %s", self.config_path) cui.step_done(str(self.config_path)) @@ -602,6 +641,31 @@ def _read_slow_positions(self) -> dict[str, Any]: except Exception as exc: logger.debug("Galvo position read failed: %s", exc) + # SPIM-head F-drive — not on its own knob loop, so 1 Hz is plenty. + # Streamed so the Operate view can show head height + distance-to-floor. + fdrive = self.devices.get("fdrive") + if fdrive is not None: + try: + data = fdrive.read() + out[fdrive.name] = { + "Position": float(data[fdrive.name]["value"]), + "kind": "fdrive", + } + except Exception as exc: + logger.debug("F-drive position read failed: %s", exc) + + # Bottom-camera focus Z (sample Z) — present only on rigs that expose it. + z_stage = self.devices.get("z_stage") + if z_stage is not None: + try: + data = z_stage.read() + out[z_stage.name] = { + "Position": float(data[z_stage.name]["value"]), + "kind": "bottom_z", + } + except Exception as exc: + logger.debug("Bottom-Z position read failed: %s", exc) + return out def _read_full_state(self) -> dict[str, dict[str, str]]: @@ -846,7 +910,9 @@ def _capture_bottom_frame_sync(self) -> np.ndarray | None: logger.debug("Bottom-camera grab failed: %s", exc) return None - def _encode_frame_for_stream(self, img: np.ndarray) -> dict[str, Any] | None: + def _encode_frame_for_stream( + self, img: np.ndarray, max_dim: int | None = None, quality: int | None = None + ) -> dict[str, Any] | None: """Downsample + auto-contrast + JPEG-encode a uint16 frame for SSE. Optimised for streaming throughput: @@ -855,6 +921,10 @@ def _encode_frame_for_stream(self, img: np.ndarray) -> dict[str, Any] | None: full image — np.partition on the subsample is O(n) and avoids sorting ~120K pixels every frame * JPEG quality 55 (visually fine at thumbnail size) + + ``max_dim`` and ``quality`` default to the ``_cam_*`` instance values, + allowing callers (e.g. the lightsheet streamer) to pass different + settings without duplicating the encoder. """ if img is None or img.size == 0: return None @@ -866,9 +936,24 @@ def _encode_frame_for_stream(self, img: np.ndarray) -> dict[str, Any] | None: logger.warning("Cannot encode frame — OpenCV unavailable: %s", exc) return None + target_max_dim = max_dim if max_dim is not None else self._cam_target_max_dim + jpeg_quality = quality if quality is not None else self._cam_jpeg_quality + + # Focus score on the FULL frame (before downsampling) so the Operate + # view's focus readout reflects true sharpness, not the JPEG thumbnail. + # Best-effort: any failure just omits the field. + focus_score: float | None = None + try: + from gently.analysis.core import calculate_focus_score + + focus_score = float(calculate_focus_score(img, algorithm="volath")) + except Exception as exc: + logger.debug("Focus score computation failed: %s", exc) + focus_score = None + h, w = img.shape[:2] # Stride slicing — no interpolation, just take every Nth pixel. - factor = max(1, max(h, w) // self._cam_target_max_dim) + factor = max(1, max(h, w) // target_max_dim) small = img[::factor, ::factor] # Auto-contrast off a small random sample. Robust to hot pixels @@ -890,17 +975,39 @@ def _encode_frame_for_stream(self, img: np.ndarray) -> dict[str, Any] | None: scale = 255.0 / (hi - lo) small = np.clip((small.astype(np.float32) - lo) * scale, 0, 255).astype(np.uint8) - ok, jpeg = cv2.imencode(".jpg", small, [cv2.IMWRITE_JPEG_QUALITY, self._cam_jpeg_quality]) + ok, jpeg = cv2.imencode(".jpg", small, [cv2.IMWRITE_JPEG_QUALITY, jpeg_quality]) if not ok: return None b64 = base64.b64encode(jpeg.tobytes()).decode("ascii") - return { + payload: dict[str, Any] = { "t": time.time(), "shape": [int(small.shape[0]), int(small.shape[1])], "downsample": factor, "mime": "image/jpeg", "jpeg_b64": b64, } + if focus_score is not None: + payload["focus_score"] = focus_score + return payload + + def _current_stage_xy(self) -> list[float] | None: + """Latest XY-stage position from the state cache, as ``[x, y]`` µm. + + Sourced from the position poller (``kind == "xy_stage"``). Returns + ``None`` when no XY reading has arrived yet — callers MUST NOT fall + back to ``[0, 0]``: the operate-view marking canvas converts clicked + pixels to stage coords relative to this origin, so a bogus ``[0, 0]`` + silently places every marker relative to stage origin instead of the + true position (embryos then land hundreds of µm off, and calibration + images empty field). Absent-is-better-than-wrong. + """ + positions = self._state_latest.get("positions") or {} + for entry in positions.values(): + if isinstance(entry, dict) and entry.get("kind") == "xy_stage": + x, y = entry.get("X"), entry.get("Y") + if x is not None and y is not None: + return [float(x), float(y)] + return None async def _bottom_camera_streamer(self): """Continuous grab/encode/broadcast loop. Runs while a subscriber lives. @@ -921,6 +1028,13 @@ async def _bottom_camera_streamer(self): img = await asyncio.to_thread(self._capture_bottom_frame_sync) payload = self._encode_frame_for_stream(img) if img is not None else None if payload is not None: + # Stamp the true stage XY so the operate-view marking canvas + # converts clicked pixels to absolute stage coords (not + # offsets from origin). Omitted — never [0, 0] — when no XY + # reading has arrived yet; the frontend then blocks marking. + xy = self._current_stage_xy() + if xy is not None: + payload["stage_position"] = xy await self._broadcast_camera(payload) # Pace the loop — sleep whatever's left of the interval. elapsed = time.monotonic() - tick @@ -933,7 +1047,40 @@ async def _bottom_camera_streamer(self): finally: logger.info("Bottom-camera streamer exiting") + def _log_focus_trace(self, source: str, payload: dict[str, Any]) -> None: + """Append a passive (z, focus_score) focus-trace sample (sub-project C). + + Captures the operator's *manual* focusing as validation data — the human's + resting Z is ground-truth best focus, replayed offline by + ``gently.analysis.focus_validation``. Best-effort: only when a session + volume dir is set and the frame carries a focus score; never raises into + the broadcast path, never moves hardware. + """ + score = payload.get("focus_score") + if score is None or not self._volume_dir: + return + try: + import json as _json + + zvals: dict[str, Any] = {} + for v in (self._state_latest.get("positions") or {}).values(): + if isinstance(v, dict) and "kind" in v and "Position" in v: + zvals[v["kind"]] = v["Position"] + rec = { + "t": payload.get("t"), + "source": source, + "focus_score": float(score), + "bottom_z": zvals.get("bottom_z"), + "fdrive": zvals.get("fdrive"), + "piezo": zvals.get("piezo"), + } + with open(Path(self._volume_dir) / "focus_traces.jsonl", "a") as f: + f.write(_json.dumps(rec) + "\n") + except Exception: + logger.debug("focus-trace log skipped", exc_info=True) + async def _broadcast_camera(self, payload: dict[str, Any]): + self._log_focus_trace("bottom", payload) if not self._cam_subscribers: return dead: list[asyncio.Queue] = [] @@ -954,6 +1101,323 @@ async def _broadcast_camera(self, payload: dict[str, Any]): except ValueError: pass + # ========================================================================= + # Lightsheet (SPIM) Live Streamer — continuous sequence acquisition + # ========================================================================= + + def _park_lightsheet_sync(self) -> None: + """Park scanner galvo + imaging piezo at the current live params (static sheet). + + Side 'A' drives devices["scanner"] / devices["piezo"] (Scanner:AB:33 / + PiezoStage:P:34). Side 'B' drives devices["scanner_b"] / devices["piezo_b"] + (Scanner:CD:33 / PiezoStage:Q:35) when registered. Falls back to side A + with a warning on single-side rigs so they are never broken. + + Guards: + - set_spim_state("Idle") fires once per stream session, not per frame + (4 serial round-trips on first call → 0 on every subsequent frame). + - setPosition fires only when the commanded value differs from the + last-applied value (no-op on steady-state frames). + """ + p = self._ls_params + side = p.get("side", "A") + + # Resolve scanner for the requested side (with fallback to A). + if side == "B": + if "scanner_b" in self.devices: + scanner = self.devices["scanner_b"] + else: + logger.warning( + "Side B requested but scanner_b not registered; falling back to side A for scanner" # noqa: E501 + ) + scanner = self.devices.get("scanner") + else: + scanner = self.devices.get("scanner") + + # Resolve piezo for the requested side (with fallback to A). + if side == "B": + if "piezo_b" in self.devices: + piezo = self.devices["piezo_b"] + else: + logger.warning( + "Side B requested but piezo_b not registered; falling back to side A for piezo" + ) + piezo = self.devices.get("piezo") + else: + piezo = self.devices.get("piezo") + + # SPIM Idle state machine: drive both devices Idle once per session. + if not self._ls_spim_idle: + if scanner is not None: + try: + scanner.set_spim_state("Idle") + except Exception: + pass + if piezo is not None: + try: + piezo.set_spim_state("Idle") + except Exception: + pass + self._ls_spim_idle = True + # setPosition only when the value changed from last-applied. + if scanner is not None: + want = float(p["galvo"]) + if self._ls_parked.get("galvo") != want: + scanner.sa_offset_y.setPosition(want) + self._ls_parked["galvo"] = want + if piezo is not None: + want = float(p["piezo"]) + if self._ls_parked.get("piezo") != want: + piezo.setPosition(want) + self._ls_parked["piezo"] = want + + def _ensure_lightsheet_sequence_sync(self) -> None: + """Configure (or reconfigure on exposure/side change) the SPIM camera. + + Two modes gated by ``_ls_params["mode"]``: + + "snap" (default, safe) — per-frame snapImage()+getImage(). No circular + buffer at all; memory is bounded to one frame. Sets camera device + and exposure only when a reconfiguration is needed; never calls + startContinuousSequenceAcquisition. + + "continuous" (opt-in) — startContinuousSequenceAcquisition with a hard + footprint cap (256 MB) so the buffer can't balloon on a production + box. The grab loop drains with clearCircularBuffer() after each peek. + + Side 'A' uses devices["camera"] (HamCam1); side 'B' uses devices["camera_b"] + (HamCam2) when registered. If side B is requested but camera_b is absent, + falls back to side A and logs a warning — single-camera rigs are unaffected. + """ + core = self.system.core + p = self._ls_params + side = p.get("side", "A") + mode = p.get("mode", "snap") + + # Resolve the camera for the requested side + if side == "B" and "camera_b" in self.devices: + cam = self.devices["camera_b"] + else: + if side == "B": + logger.warning( + "Side B requested but camera_b not registered; falling back to side A" + ) + cam = self.devices.get("camera") + + if cam is None: + raise RuntimeError("No lightsheet camera configured") + + need_reconfigure = ( + not self._ls_seq_started + or self._ls_applied.get("exposure") != p["exposure"] + or self._ls_applied.get("side") != side + ) + + if mode == "snap": + # Snap mode: configure camera/exposure on change; no continuous sequence. + if need_reconfigure: + # Stop any lingering continuous sequence before snap takes over. + if core.isSequenceRunning(): + core.stopSequenceAcquisition() + try: + core.clearCircularBuffer() + except Exception: + pass + if core.getCameraDevice() != cam.name: + core.setCameraDevice(cam.name) + core.setExposure(cam.name, float(p["exposure"])) + self._ls_seq_started = True # "camera configured" flag; no sequence running + self._ls_applied["exposure"] = p["exposure"] + self._ls_applied["side"] = side + else: + # Continuous mode: cap buffer footprint then start sequence. + if need_reconfigure: + if core.isSequenceRunning(): + core.stopSequenceAcquisition() + if core.getCameraDevice() != cam.name: + core.setCameraDevice(cam.name) + core.setExposure(cam.name, float(p["exposure"])) + # Hard cap: prevent buffer balloon that crashed the production box. + try: + core.setCircularBufferMemoryFootprint(256) + except Exception: + logger.debug( + "setCircularBufferMemoryFootprint not available on this core", + exc_info=True, + ) + core.startContinuousSequenceAcquisition(self._ls_interval_sec * 1000.0) + self._ls_seq_started = True + self._ls_applied["exposure"] = p["exposure"] + self._ls_applied["side"] = side + + def _grab_lightsheet_frame_sync(self): + """Park → configure camera → grab one frame. + + Snap mode (default): snapImage()+getImage() — no circular buffer, + hard memory ceiling, safe for long live-view sessions. + + Continuous mode (opt-in): peeks via getLastImage() then drains the + buffer with clearCircularBuffer() to prevent it sitting full. + """ + try: + self._park_lightsheet_sync() + self._ensure_lightsheet_sequence_sync() + try: + from gently.hardware.dispim.devices.acquisition import _safe_obtain + except (ImportError, AttributeError): + _safe_obtain = None + core = self.system.core + mode = self._ls_params.get("mode", "snap") + if mode == "snap": + core.snapImage() + img = core.getImage() + else: + img = core.getLastImage() + try: + core.clearCircularBuffer() + except Exception: + pass + if _safe_obtain is not None: + try: + img = _safe_obtain(img) + except Exception: + pass + return np.asarray(img) + except Exception as exc: + logger.debug("Lightsheet grab failed: %s", exc) + return None + + def _stop_lightsheet_sequence_sync(self) -> None: + try: + if self.system.core.isSequenceRunning(): + self.system.core.stopSequenceAcquisition() + except Exception: + logger.debug("stop lightsheet sequence failed", exc_info=True) + # Drain the buffer on every stop path (snap mode: no-op; continuous: release memory). + try: + self.system.core.clearCircularBuffer() + except Exception: + pass + self._ls_seq_started = False + self._ls_applied = {} + # Reset park guard so the next stream session re-idles the state + # machine and re-parks the axes (hardware may have moved). + self._ls_spim_idle = False + self._ls_parked = {} + + async def _lightsheet_streamer(self): + logger.info("Lightsheet streamer started") + try: + while self._ls_subscribers: + if self._state_pause_counter > 0: + # Heavy plan owns MMCore: release the sequence and back off. + if self._ls_seq_started: + await asyncio.to_thread(self._stop_lightsheet_sequence_sync) + await asyncio.sleep(0.1) + continue + tick = time.monotonic() + img = await asyncio.to_thread(self._grab_lightsheet_frame_sync) + payload = ( + self._encode_frame_for_stream( + img, self._ls_target_max_dim, self._ls_jpeg_quality + ) + if img is not None + else None + ) + if payload is not None: + await self._broadcast_lightsheet(payload) + elapsed = time.monotonic() - tick + # Pace to at least the exposure; peek-rate caps near the camera rate. + floor = max(self._ls_interval_sec, self._ls_params["exposure"] / 1000.0) + await asyncio.sleep(max(0.0, floor - elapsed)) + except asyncio.CancelledError: + raise + except Exception: + logger.exception("Lightsheet streamer crashed") + finally: + await asyncio.to_thread(self._stop_lightsheet_sequence_sync) + logger.info("Lightsheet streamer exiting") + + async def _broadcast_lightsheet(self, payload: dict[str, Any]): + self._log_focus_trace("spim", payload) + if not self._ls_subscribers: + return + dead: list[asyncio.Queue] = [] + for q in self._ls_subscribers: + try: + q.put_nowait(payload) + except asyncio.QueueFull: + try: + _ = q.get_nowait() + q.put_nowait(payload) + except Exception: + dead.append(q) + for q in dead: + try: + self._ls_subscribers.remove(q) + except ValueError: + pass + + async def handle_lightsheet_stream(self, request): + """GET /api/lightsheet/stream — SSE of base64-JPEG frames from SPIM camera. + + The streamer task spins up on first connect and exits when the last + subscriber leaves. Uses continuous sequence acquisition for best FPS. + """ + response = web.StreamResponse( + status=200, + reason="OK", + headers={ + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Accel-Buffering": "no", + }, + ) + await response.prepare(request) + + queue: asyncio.Queue = asyncio.Queue(maxsize=4) + self._ls_subscribers.append(queue) + if len(self._ls_subscribers) == 1 and (self._ls_task is None or self._ls_task.done()): + self._ls_task = asyncio.create_task( + self._lightsheet_streamer(), name="lightsheet-streamer" + ) + try: + await response.write(b": connected\n\n") + while True: + try: + payload = await asyncio.wait_for(queue.get(), timeout=10.0) + except asyncio.TimeoutError: + await response.write(b": keepalive\n\n") + continue + if payload is None: + break + await response.write(f"data: {json.dumps(payload)}\n\n".encode()) + except (asyncio.CancelledError, ConnectionResetError, ConnectionAbortedError): + pass + except Exception: + logger.exception("Lightsheet SSE writer failed") + finally: + try: + self._ls_subscribers.remove(queue) + except ValueError: + pass + return response + + async def handle_lightsheet_params(self, request): + """POST /api/lightsheet/live/params — update live galvo/piezo/exposure/side. + + Body: {"galvo": float, "piezo": float, "exposure": float, "side": "A"|"B"} (all optional). + Galvo/piezo apply on the next grab; exposure or side change triggers sequence restart. + """ + body = await request.json() + for k in ("galvo", "piezo", "exposure"): + if k in body and body[k] is not None: + self._ls_params[k] = float(body[k]) + if "side" in body and body["side"] in ("A", "B"): + self._ls_params["side"] = body["side"] + return web.json_response({"params": self._ls_params}) + # ========================================================================= # MMCore Push Callbacks # ========================================================================= @@ -1429,6 +1893,26 @@ def _room_light_device(self): return dev return None + async def _set_room_light(self, state: str) -> bool: + """Drive the room-light SwitchBot to ``state`` ('on'/'off'/'press'). + + Blocks until the BLE command lands (or times out). Returns True on + success, False if no bot is configured or the command failed. Shared + by the HTTP handler and internal callers (e.g. detect_embryos, which + images under room light rather than the camera LED). + """ + bot = self._room_light_device() + if bot is None: + return False + import time + + status = bot.set(state) + timeout = float(getattr(bot, "timeout", 20.0)) + 5 + start = time.time() + while not status.done and (time.time() - start) < timeout: + await asyncio.sleep(0.1) + return bool(status.done and status.success) + async def handle_get_room_light_status(self, request): """GET /api/room_light/status - cached on/off state of the room light. @@ -1614,6 +2098,243 @@ async def handle_set_temperature(self, request): status=500, ) + @staticmethod + def _validate_temp_cfg(cfg) -> str | None: + """Validate a thermalizer config dict. Returns an error string or None. + + Mirrors what temperature._make_backend / create_temperature_controller + require so bad input is rejected before we build a controller. + """ + if not isinstance(cfg, dict): + return "config must be an object" + backend = str(cfg.get("backend", "serial")).lower() + if backend not in ("serial", "mqtt", "mock"): + return f"unknown backend {backend!r} (use 'serial', 'mqtt', or 'mock')" + if backend == "serial" and not str(cfg.get("com_port") or "").strip(): + return "serial backend requires com_port" + for key in ("baud_rate", "port"): + if cfg.get(key) is not None: + try: + if int(cfg[key]) <= 0: + return f"{key} must be a positive integer" + except (TypeError, ValueError): + return f"{key} must be a positive integer" + if cfg.get("stabilize_timeout") is not None: + try: + if float(cfg["stabilize_timeout"]) <= 0: + return "stabilize_timeout must be > 0" + except (TypeError, ValueError): + return "stabilize_timeout must be a number" + return None + + @staticmethod + def _redact_temp_cfg(cfg: dict) -> dict: + """Copy of a temperature config with the MQTT password removed.""" + out = dict(cfg or {}) + if "password" in out: + out["password"] = None + return out + + def _preserve_temp_password(self, cfg: dict) -> dict: + """Return a copy of cfg with the stored MQTT password filled in when the + client didn't submit a new one (the password is write-only in the UI, so + a blank field means 'keep what's stored'). Used by BOTH test + apply so + they exercise the same effective credentials.""" + eff = dict(cfg) + if not eff.get("password"): + existing_pw = ((self.config or {}).get("temperature") or {}).get("password") + if existing_pw: + eff["password"] = existing_pw + else: + eff.pop("password", None) + return eff + + def _temp_state(self, temp) -> dict | None: + if temp is None: + return None + try: + r = temp.read() + return { + "temperature_c": r.get(temp.name, {}).get("value"), + "setpoint_c": r.get(f"{temp.name}_setpoint", {}).get("value"), + "state": r.get(f"{temp.name}_state", {}).get("value"), + } + except Exception: + return None + + async def handle_get_temperature_config(self, request): + """GET /api/temperature/config - current thermalizer config (password + redacted) + live backend + read() state, for the settings panel.""" + try: + cfg = dict((self.config or {}).get("temperature") or {}) + has_pw = bool(cfg.get("password")) + temp = self.devices.get("temperature") + return web.json_response( + { + "success": True, + "config": self._redact_temp_cfg(cfg), + "password_set": has_pw, + "active": temp is not None, + "live_backend": type(temp._dev).__name__ if temp is not None else None, + "state": self._temp_state(temp), + } + ) + except Exception as e: + import traceback + + return web.json_response( + {"success": False, "error": str(e), "traceback": traceback.format_exc()}, + status=500, + ) + + async def handle_test_temperature_config(self, request): + """POST /api/temperature/config/test - probe a candidate config WITHOUT + committing. Builds a transient controller, reads state, closes it. Never + touches self.devices.""" + try: + cfg = await request.json() + except Exception: + return web.json_response({"success": False, "error": "invalid JSON body"}, status=400) + err = self._validate_temp_cfg(cfg) + if err: + return web.json_response({"success": False, "error": err}, status=400) + eff = self._preserve_temp_password(cfg) # probe with the same creds Apply would use + + def _probe(): + from gently.hardware.temperature import create_temperature_controller + + tc = create_temperature_controller(eff) + try: + r = tc.read() + return { + "backend": type(tc._dev).__name__, + "temperature_c": r.get(tc.name, {}).get("value"), + "state": r.get(f"{tc.name}_state", {}).get("value"), + } + finally: + try: + tc.close() + except Exception: + pass + + try: + result = await asyncio.to_thread(_probe) + return web.json_response({"success": True, "result": result}) + except Exception as e: + return web.json_response({"success": False, "error": str(e)}, status=502) + + def _write_temp_sidecar(self, temp_cfg: dict) -> None: + """Persist the temperature block to config/config.local.yml (merged over + config.yml at boot). Best-effort; 0600 perms since it may hold the MQTT + password. Keeps config.yml (and its comments) untouched.""" + try: + import os + + sidecar = Path(self.config_path).parent / "config.local.yml" + existing: dict = {} + if sidecar.exists(): + with open(sidecar) as f: + existing = yaml.safe_load(f) or {} + existing["temperature"] = temp_cfg + # Create with 0600 up front so the plaintext MQTT password is never + # briefly world/group-readable (O_CREAT mode only applies to new files; + # the follow-up chmod downgrades any pre-existing loose-perm file). + fd = os.open(sidecar, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + with os.fdopen(fd, "w") as f: + yaml.safe_dump(existing, f, default_flow_style=False, sort_keys=False) + try: + os.chmod(sidecar, 0o600) + except OSError: + pass + except Exception: + logger.debug("temp sidecar write failed", exc_info=True) + + async def handle_set_temperature_config(self, request): + """POST /api/temperature/config - reconfigure the thermalizer live. + + Build-new-before-swap: refuse (409) while the RunEngine is running or a + set() worker holds the controller lock; build the NEW controller first + (keep the old one on failure); swap self.devices and close the old; + persist to the config.local.yml sidecar. On a build/connect failure the + intent is still saved and restart_required is returned. + """ + try: + cfg = await request.json() + except Exception: + return web.json_response({"success": False, "error": "invalid JSON body"}, status=400) + err = self._validate_temp_cfg(cfg) + if err: + return web.json_response({"success": False, "error": err}, status=400) + + # --- 409 guard: never swap mid-plan / mid-ramp --- + re_state = str(getattr(self.RE, "state", "idle")) if self.RE is not None else "idle" + if re_state != "idle": + return web.json_response( + { + "success": False, + "blocked": True, + "error": f"RunEngine is {re_state}; stop the plan before reconfiguring", + }, + status=409, + ) + old = self.devices.get("temperature") + lock = getattr(old, "_lock", None) + if lock is not None and lock.locked(): + return web.json_response( + { + "success": False, + "blocked": True, + "error": "a temperature ramp is in progress; wait or stop it first", + }, + status=409, + ) + + # Fill in the stored password when the client didn't submit a new one. + eff = self._preserve_temp_password(cfg) + + # Build the NEW controller first (eager connect off the event loop). + def _build(): + from gently.hardware.temperature import create_temperature_controller + + return create_temperature_controller(eff) + + try: + new_tc = await asyncio.to_thread(_build) + except Exception as e: + self._write_temp_sidecar(eff) # save intent; a restart will apply it + return web.json_response( + { + "success": False, + "applied": False, + "restart_required": True, + "error": f"could not connect with the new config: {e}. " + "Saved — restart the device layer to apply.", + }, + status=502, + ) + + # Swap + retire the old controller. + self.devices["temperature"] = new_tc + if old is not None: + try: + old.close() + except Exception: + logger.debug("old temperature controller close failed", exc_info=True) + self.config = self.config or {} + self.config["temperature"] = eff + self._write_temp_sidecar(eff) + logger.info("Thermalizer reconfigured live (backend=%s)", eff.get("backend", "serial")) + return web.json_response( + { + "success": True, + "applied": True, + "restart_required": False, + "config": self._redact_temp_cfg(eff), + "live_backend": type(new_tc._dev).__name__, + "state": self._temp_state(new_tc), + } + ) + async def handle_set_camera_led_mode(self, request): """POST /api/camera/led_mode - Enable/disable automatic LED for bottom camera""" try: @@ -1776,6 +2497,81 @@ async def handle_get_light_source_power(self, request): status=500, ) + async def handle_set_laser_config(self, request): + """POST /api/laser/config — apply a Laser config-group preset. + + Body: {"config": } + Calls light_source.set(config_name) which maps to + core.setConfig(group_name, config_name) + waitForConfig. + """ + try: + data = await request.json() + config_name = data.get("config") + if not config_name: + return web.json_response( + {"success": False, "error": "missing 'config' field"}, + status=400, + ) + light_source = self.devices.get("light_source") or self.devices.get("laser_control") + if light_source is None: + return web.json_response( + {"success": False, "error": "Light source device not found"}, + status=503, + ) + try: + light_source.set(config_name) + except Exception as e: + return web.json_response( + {"success": False, "error": str(e)}, + status=400, + ) + return web.json_response({"success": True, "config": config_name}) + except Exception as e: + import traceback + + return web.json_response( + { + "success": False, + "error": str(e), + "traceback": traceback.format_exc(), + }, + status=500, + ) + + async def handle_get_laser_configs(self, request): + """GET /api/laser/configs — list available Laser config-group presets.""" + try: + light_source = self.devices.get("light_source") or self.devices.get("laser_control") + if light_source is None: + return web.json_response( + {"success": False, "error": "Light source device not found"}, + status=503, + ) + configs = light_source._get_available_configs() + return web.json_response({"configs": configs}) + except Exception as e: + import traceback + + return web.json_response( + { + "success": False, + "error": str(e), + "traceback": traceback.format_exc(), + }, + status=500, + ) + + async def handle_get_cameras(self, request): + """GET /api/cameras — list available SPIM camera roles. + + Returns ``{"cameras": ["A"]}`` on single-camera rigs, or + ``{"cameras": ["A", "B"]}`` when camera_b (HamCam2) is registered. + """ + cameras = ["A"] + if "camera_b" in self.devices: + cameras.append("B") + return web.json_response({"cameras": cameras}) + async def handle_get_camera_exposure(self, request): """GET /api/camera/exposure - Get bottom camera exposure time""" try: @@ -1800,6 +2596,119 @@ async def handle_get_camera_exposure(self, request): status=500, ) + # ------------------------------------------------------------------ + # Fenced focus axes — bottom-camera focus Z (z_stage) + SPIM-head F-drive. + # Read + relative nudge only; the device classes enforce hard limits, and + # the handlers reject out-of-range targets with 400. No autofocus. + # ------------------------------------------------------------------ + + def _nudge_axis_blocking(self, device, target: float) -> float: + """Blocking: move a fenced ophyd positioner to ``target`` and return the + new position. ``device.set()`` enforces the hardware limits.""" + status = device.set(target) + try: + status.wait() + except Exception: + logger.exception("Axis move to %.2f did not complete cleanly", target) + return float(device.read()[device.name]["value"]) + + def _axis_status_response(self, device_key: str, label: str): + device = self.devices.get(device_key) + if device is None: + return web.json_response( + {"success": False, "error": f"{label} device not found"}, status=503 + ) + lo, hi = device.limits + try: + pos = float(device.read()[device.name]["value"]) + except Exception as exc: + return web.json_response( + {"success": False, "error": f"position read failed: {exc}"}, status=502 + ) + return web.json_response( + { + "success": True, + "position": pos, + "min": float(lo), + "max": float(hi), + "distance_to_floor": pos - float(lo), + } + ) + + async def _handle_axis_nudge(self, request, device_key: str, label: str): + try: + data = await request.json() + delta = float(data.get("delta", 0.0)) + except Exception: + return web.json_response( + {"success": False, "error": "numeric 'delta' required"}, status=400 + ) + device = self.devices.get(device_key) + if device is None: + return web.json_response( + {"success": False, "error": f"{label} device not found"}, status=503 + ) + lo, hi = device.limits + try: + cur = float(device.read()[device.name]["value"]) + except Exception as exc: + return web.json_response( + {"success": False, "error": f"position read failed: {exc}"}, status=502 + ) + target = round(cur + delta, 2) + if not (float(lo) <= target <= float(hi)): + return web.json_response( + { + "success": False, + "error": f"target {target} outside limits [{lo}, {hi}]", + "position": cur, + "min": float(lo), + "max": float(hi), + }, + status=400, + ) + try: + async with self.pause_state_updates(): + new_pos = await asyncio.to_thread(self._nudge_axis_blocking, device, target) + except ValueError as exc: # device-level fence (defensive) + return web.json_response({"success": False, "error": str(exc)}, status=400) + except Exception as exc: + import traceback + + return web.json_response( + { + "success": False, + "error": str(exc), + "traceback": traceback.format_exc(), + }, + status=500, + ) + return web.json_response( + { + "success": True, + "position": new_pos, + "min": float(lo), + "max": float(hi), + "distance_to_floor": new_pos - float(lo), + } + ) + + async def handle_get_bottom_z(self, request): + """GET /api/stage/bottom_z — bottom-camera focus Z position + limits.""" + return self._axis_status_response("z_stage", "Bottom-Z (z_stage)") + + async def handle_nudge_bottom_z(self, request): + """POST /api/stage/bottom_z/nudge — fenced relative move of the bottom-camera focus Z.""" + return await self._handle_axis_nudge(request, "z_stage", "Bottom-Z (z_stage)") + + async def handle_get_fdrive(self, request): + """GET /api/spim/fdrive — SPIM-head F-drive position + limits + distance to floor.""" + return self._axis_status_response("fdrive", "F-drive") + + async def handle_nudge_fdrive(self, request): + """POST /api/spim/fdrive/nudge — fenced relative move of the SPIM-head F-drive.""" + return await self._handle_axis_nudge(request, "fdrive", "F-drive") + async def handle_get_plan_log(self, request): """GET /api/plan_log - Get recent plan execution log with timing""" try: @@ -1932,6 +2841,25 @@ async def handle_detect_embryos(self, request): if bottom_camera: bottom_camera.configure_exposure(exposure_ms) + # Detection needs even illumination. Prefer the ROOM LIGHT + # (SwitchBot) and disable the camera LED. But the room light is an + # OPTIONAL BLE accessory — when it's absent or the command fails, + # forcing the LED off leaves the frame near-black and SAM finds + # nothing. So only disable the LED once the room light is actually + # on; otherwise fall back to the camera LED as the light source. + bottom_camera = self.devices.get("bottom_camera") + if await self._set_room_light("on"): + if bottom_camera is not None: + bottom_camera.use_led = False + logger.info("[detect_embryos] Room light ON, camera LED disabled") + else: + if bottom_camera is not None: + bottom_camera.use_led = True + logger.warning( + "[detect_embryos] Room light unavailable (no SwitchBot " + "configured or command failed) — falling back to camera LED" + ) + # Capture image via plan logger.info("[detect_embryos] Capturing bottom camera image...") capture_result = await self.submit_plan( @@ -2746,13 +3674,25 @@ async def on_start(self): self._app.router.add_post("/api/led/set", self.handle_set_led) self._app.router.add_get("/api/temperature/status", self.handle_get_temperature_status) self._app.router.add_post("/api/temperature/set", self.handle_set_temperature) + self._app.router.add_get("/api/temperature/config", self.handle_get_temperature_config) + self._app.router.add_post( + "/api/temperature/config/test", self.handle_test_temperature_config + ) + self._app.router.add_post("/api/temperature/config", self.handle_set_temperature_config) self._app.router.add_get("/api/room_light/status", self.handle_get_room_light_status) self._app.router.add_post("/api/room_light/set", self.handle_set_room_light) self._app.router.add_post("/api/camera/led_mode", self.handle_set_camera_led_mode) self._app.router.add_post("/api/camera/exposure", self.handle_set_camera_exposure) self._app.router.add_get("/api/camera/exposure", self.handle_get_camera_exposure) + # Fenced focus axes (read + relative nudge; no autofocus) + self._app.router.add_get("/api/stage/bottom_z", self.handle_get_bottom_z) + self._app.router.add_post("/api/stage/bottom_z/nudge", self.handle_nudge_bottom_z) + self._app.router.add_get("/api/spim/fdrive", self.handle_get_fdrive) + self._app.router.add_post("/api/spim/fdrive/nudge", self.handle_nudge_fdrive) self._app.router.add_post("/api/light_source/power", self.handle_set_light_source_power) self._app.router.add_get("/api/light_source/power", self.handle_get_light_source_power) + self._app.router.add_post("/api/laser/config", self.handle_set_laser_config) + self._app.router.add_get("/api/laser/configs", self.handle_get_laser_configs) self._app.router.add_get("/api/plan_log", self.handle_get_plan_log) self._app.router.add_post("/session/configure", self.handle_session_configure) @@ -2772,6 +3712,11 @@ async def on_start(self): # Bottom-camera live stream (subscriber-gated, off when nobody listens) self._app.router.add_get("/api/bottom_camera/stream", self.handle_bottom_camera_stream) + # Lightsheet (SPIM) live stream — continuous sequence acquisition + self._app.router.add_get("/api/lightsheet/stream", self.handle_lightsheet_stream) + self._app.router.add_post("/api/lightsheet/live/params", self.handle_lightsheet_params) + self._app.router.add_get("/api/cameras", self.handle_get_cameras) + # Start plan executor self._executor_task = asyncio.create_task(self._plan_executor()) diff --git a/gently/hardware/dispim/devices/acquisition.py b/gently/hardware/dispim/devices/acquisition.py index 98d443b2..8c26ac68 100644 --- a/gently/hardware/dispim/devices/acquisition.py +++ b/gently/hardware/dispim/devices/acquisition.py @@ -188,6 +188,14 @@ def wait(): self.core.setConfig(self.laser_control.group_name, self._laser_config) self.core.waitForConfig(self.laser_control.group_name, self._laser_config) + # Defensive stop: the lightsheet live streamer may have left the + # camera in continuous acquisition. Starting a new sequence on + # an already-running camera triggers an MMCore error (and is + # thread-unsafe). Stop cleanly before reconfiguring. + if self.core.isSequenceRunning(): + self.core.stopSequenceAcquisition() + self.core.waitForDevice(self.camera.name) + # Prepare circular buffer self.core.clearCircularBuffer() buffer_capacity = self.core.getBufferTotalCapacity() diff --git a/gently/hardware/dispim/devices/camera.py b/gently/hardware/dispim/devices/camera.py index db6bb881..164c42da 100644 --- a/gently/hardware/dispim/devices/camera.py +++ b/gently/hardware/dispim/devices/camera.py @@ -392,7 +392,9 @@ def __init__( self.pixel_size_um = pixel_size_um self.magnification = magnification self.effective_pixel_size = pixel_size_um / magnification - self.use_led = False # Set to True to enable automatic LED control + # Retained for API compatibility but ignored: the bottom camera never + # drives the LED (see trigger()). Imaging uses room light only. + self.use_led = False def pixel_to_um(self, pixels: float) -> float: """ @@ -412,14 +414,12 @@ def pixel_to_um(self, pixels: float) -> float: def trigger(self): """ - Trigger image acquisition with optional LED management. + Trigger image acquisition. - Overrides parent trigger() to add LED control (if use_led=True): - 1. Turn LED on (if use_led=True) - 2. Capture image - 3. Turn LED off (if use_led=True, always even on error) - - Set self.use_led = False to capture without LED (ambient light only). + The bottom camera NEVER drives the LED — imaging is done under room + light only. The ``use_led`` flag is retained for API compatibility but + is intentionally ignored here so that no caller (manual marking, + detection, live preview, …) can ever flash the LED. Returns ------- @@ -430,28 +430,13 @@ def trigger(self): def wait(): try: - # Turn LED on for transmitted light imaging (if enabled) - if self.use_led: - self.led_control.set("Open").wait(timeout=5) - time.sleep(0.1) # Allow LED to stabilize - - # Capture image + # LED is never used — capture under ambient/room light only. self._ensure_active() self.core.snapImage() self._last_image = _safe_obtain(self.core.getImage()) self._last_image_time = time.time() - # Turn LED off (if enabled - important to prevent sample heating!) - if self.use_led: - self.led_control.set("Closed").wait(timeout=5) - except Exception as exc: - # Critical: always turn off LED even on error (if enabled) - if self.use_led: - try: - self.led_control.set("Closed").wait(timeout=5) - except Exception: - pass status.set_exception(exc) else: status.set_finished() diff --git a/gently/hardware/dispim/sam_detection.py b/gently/hardware/dispim/sam_detection.py index 49bc4a82..3d33ec12 100644 --- a/gently/hardware/dispim/sam_detection.py +++ b/gently/hardware/dispim/sam_detection.py @@ -19,16 +19,15 @@ import numpy as np from PIL import Image -from gently.settings import settings - -logger = logging.getLogger(__name__) - -from gently.core.coordinates import ( # noqa: E402 +from gently.core.coordinates import ( DEFAULT_OBJECTIVE_MAG, DEFAULT_PIXEL_SIZE_UM, get_um_per_pixel, pixel_to_stage_position, ) +from gently.settings import settings + +logger = logging.getLogger(__name__) class SAMEmbryoDetector: @@ -757,8 +756,8 @@ async def _review_with_claude( image_base64 = self._encode_image_base64(annotated) - prompt = f"""You are a microscopy expert analyzing embryo detections from a bottom -camera view. + prompt = f"""\ +You are a microscopy expert analyzing embryo detections from a bottom camera view. CURRENT DETECTIONS: {len(embryos)} embryos labeled 0-{len(embryos) - 1} with colored bounding boxes. @@ -790,7 +789,9 @@ async def _review_with_claude( message = self.claude_client.messages.create( model=settings.models.perception, max_tokens=8000, - thinking={"type": "enabled", "budget_tokens": 5000}, + output_config={ + "effort": "high" + }, # was thinking budget_tokens (Opus 4.8 rejects it) messages=[ { "role": "user", @@ -860,7 +861,9 @@ async def _verify_with_claude( message = self.claude_client.messages.create( model=settings.models.perception, max_tokens=6000, - thinking={"type": "enabled", "budget_tokens": 4000}, + output_config={ + "effort": "high" + }, # was thinking budget_tokens (Opus 4.8 rejects it) messages=[ { "role": "user", diff --git a/gently/hardware/temperature.py b/gently/hardware/temperature.py index dba8865b..b4e3dae8 100644 --- a/gently/hardware/temperature.py +++ b/gently/hardware/temperature.py @@ -20,9 +20,14 @@ peltier temp when the transport provides it). BLE-style work runs in a worker thread so the Status integrates with the RunEngine. -NOTE: the vendor `acuitynano_precision_thermalizer_*` packages are NOT on PyPI — -install them on the device-layer machine. Local logic can be exercised with the -built-in mock backend: `python gently/hardware/temperature.py --mock 20`. +NOTE: the vendor `acuitynano_precision_thermalizer_*` packages are NOT on PyPI. +gently bundles the SERIAL transport under `gently.hardware.vendor`; the MQTT +transport is NOT bundled (it embeds broker credentials) so install it on the +device-layer machine to use `backend: mqtt`. A system-installed copy of either +name takes precedence over the bundled one. Both transports need the `device` +extra (`uv sync --extra device`) for pyserial / paho-mqtt. Local logic can be +exercised with the built-in mock backend: +`python gently/hardware/temperature.py --mock 20`. """ from __future__ import annotations @@ -38,22 +43,48 @@ TEMP_MAX_C = 99.9 +def _load_vendor(module: str, cls: str): + """Resolve a vendor SDK class, preferring a system-installed copy. + + The ACUITYnano packages aren't on PyPI. gently bundles the SERIAL transport + under ``gently.hardware.vendor``; the MQTT transport is NOT bundled (it + embeds broker credentials), so it must be installed on the device-layer + machine. We try the top-level module name first (lets an officially-installed + vendor build override the bundled one), then fall back to the vendored copy. + """ + import importlib + + last_err: ImportError | None = None + for name in (module, f"gently.hardware.vendor.{module}"): + try: + return getattr(importlib.import_module(name), cls) + except ImportError as exc: + last_err = exc + continue + raise ImportError( + f"could not import {cls}: {module!r} is not installed and there is no " + f"bundled gently.hardware.vendor copy ({last_err})" + ) from last_err + + def _make_backend(cfg: dict): """Construct the vendor SDK transport from a config mapping.""" backend = str(cfg.get("backend", "serial")).lower() if backend == "mock": return _MockBackend() if backend == "serial": - from acuitynano_precision_thermalizer_serial import ( - AcuityNanoPrecisionThermalizerSerial, + AcuityNanoPrecisionThermalizerSerial = _load_vendor( + "acuitynano_precision_thermalizer_serial", + "AcuityNanoPrecisionThermalizerSerial", ) return AcuityNanoPrecisionThermalizerSerial( cfg["com_port"], baud_rate=cfg.get("baud_rate", 115200) ) if backend == "mqtt": - from acuitynano_precision_thermalizer_api import ( - AcuityNanoPrecisionThermalizerAPI, + AcuityNanoPrecisionThermalizerAPI = _load_vendor( + "acuitynano_precision_thermalizer_api", + "AcuityNanoPrecisionThermalizerAPI", ) # The vendor package ships with an embedded HiveMQ Cloud broker + creds, diff --git a/gently/hardware/vendor/__init__.py b/gently/hardware/vendor/__init__.py new file mode 100644 index 00000000..a8b74ea5 --- /dev/null +++ b/gently/hardware/vendor/__init__.py @@ -0,0 +1,16 @@ +"""Vendored third-party SDKs bundled with gently. + +Vendor-supplied packages that are NOT published to PyPI, copied in so the device +layer always has them regardless of the machine's environment. + +- ``acuitynano_precision_thermalizer_serial`` — USB serial transport for the + ACUITYnano Precision Thermal Controller. + +The MQTT transport (``acuitynano_precision_thermalizer_api``) is deliberately +NOT bundled here: it embeds broker credentials, which don't belong in the repo. +Install it on the device-layer machine if you use ``backend: mqtt``. + +``gently.hardware.temperature`` imports these via ``_load_vendor()``, preferring +a system-installed copy of the same package name (so an official vendor update +can override), and falling back to the bundled copy here. +""" diff --git a/gently/hardware/vendor/acuitynano_precision_thermalizer_serial.py b/gently/hardware/vendor/acuitynano_precision_thermalizer_serial.py new file mode 100644 index 00000000..9a70b3b5 --- /dev/null +++ b/gently/hardware/vendor/acuitynano_precision_thermalizer_serial.py @@ -0,0 +1,84 @@ +"""ACUITYnano Precision Thermalizer — USB serial transport (vendored). + +Vendor-supplied SDK, not on PyPI; bundled with gently so the device layer always +has it. Requires ``pyserial`` (the ``device`` extra). See gently.hardware.vendor. +""" + +import threading +import time + +import serial + + +class AcuityNanoPrecisionThermalizerSerial: + def __init__(self, com_port, baud_rate=115200): + self.telemetry = { + "target": 20.0, + "water": 20.0, + "peltier": 20.0, + "state": "DISCONNECTED", + "errors": "0", + } + self.running = True + self.ser = serial.Serial(com_port, baud_rate, timeout=0.1) + time.sleep(2) + + self.thread = threading.Thread(target=self._read_loop, daemon=True) + self.thread.start() + + def _read_loop(self): + while self.running and self.ser.is_open: + try: + if self.ser.in_waiting: + line = self.ser.readline().decode("utf-8", errors="ignore").strip() + if "=" in line: + key, val = line.split("=", 1) + if key == "TARGET": + self.telemetry["target"] = float(val) + elif key == "WATER": + self.telemetry["water"] = float(val) + elif key == "ACTUAL": + self.telemetry["peltier"] = float(val) + elif key == "STATE": + self.telemetry["state"] = val + elif key == "ERRORS": + self.telemetry["errors"] = val + except Exception: + pass + time.sleep(0.01) + + def close(self): + self.running = False + if self.ser.is_open: + self.ser.close() + + def set_temperature(self, target_celsius): + if 0.0 <= target_celsius <= 99.9: + cmd = f"TEMP={target_celsius}\n" + self.ser.write(cmd.encode("utf-8")) + else: + raise ValueError("Target must be between 0.0 and 99.9 C") + + def enable_tec(self, enable=True): + val = "1" if enable else "0" + cmd = f"ENABLE={val}\n" + self.ser.write(cmd.encode("utf-8")) + + def set_feedback_sensor(self, use_peltier=False): + val = "1" if use_peltier else "0" + cmd = f"SENSOR={val}\n" + self.ser.write(cmd.encode("utf-8")) + + def get_water_temp(self): + return self.telemetry["water"] + + def get_system_state(self): + return self.telemetry["state"] + + def wait_for_target(self, timeout_seconds=300): + start = time.time() + while time.time() - start < timeout_seconds: + if "[ SYSTEM LOCKED ]" in self.telemetry["state"]: + return True + time.sleep(0.5) + return False diff --git a/gently/harness/bridge.py b/gently/harness/bridge.py index ddbfa9ca..fdd21040 100644 --- a/gently/harness/bridge.py +++ b/gently/harness/bridge.py @@ -251,9 +251,12 @@ def _candidate_to_option(self, item, spec, campaign) -> dict: spec_dict: dict[str, Any] = {} for field in ( "strain", + "genotype", + "reporter", "temperature_c", "num_slices", "exposure_ms", + "laser_wavelength_nm", "interval_s", "stop_condition", "success_criteria", @@ -261,6 +264,11 @@ def _candidate_to_option(self, item, spec, campaign) -> dict: val = getattr(spec, field, None) if val is not None: spec_dict[field] = val + # Carry per-field provenance so the UI can tag inferred values + # (e.g. "561 nm · inferred · medium") and show what to confirm. + prov = getattr(spec, "provenance", None) + if prov: + spec_dict["provenance"] = prov if spec_dict: meta["spec"] = spec_dict diff --git a/gently/harness/conversation.py b/gently/harness/conversation.py index 6d6dcaf2..210aa669 100644 --- a/gently/harness/conversation.py +++ b/gently/harness/conversation.py @@ -12,6 +12,8 @@ import time from typing import Any +from ..settings import settings + logger = logging.getLogger(__name__) @@ -159,15 +161,13 @@ def should_use_thinking(self, message: str, mode: str) -> bool: if re.search(r"\b(plan|timelapse|time-lapse|acquisition)\b", msg_lower): return True if re.search( - r"\b(analy[sz]e|look at|check|inspect|review).*(image|volume|embryo)", - msg_lower, + r"\b(analy[sz]e|look at|check|inspect|review).*(image|volume|embryo)", msg_lower ): return True if re.search(r"\b(all|every|each)\s+(embryo|sample)", msg_lower): return True if re.search( - r"\b(first|then|after|next|finally)\b.*\b(first|then|after|next|finally)\b", - msg_lower, + r"\b(first|then|after|next|finally)\b.*\b(first|then|after|next|finally)\b", msg_lower ): return True if re.search(r"\b(why|problem|issue|error|wrong|fail|debug|troubleshoot)", msg_lower): @@ -177,6 +177,33 @@ def should_use_thinking(self, message: str, mode: str) -> bool: # ===== Non-Streaming API Call ===== + async def _create_with_refusal_fallback(self, api_kwargs): + """messages.create with main-tier resilience: if the model rejects the + request with a 400 (e.g. Fable 5 under <30-day org data retention, or + unavailable) OR declines it (stop_reason="refusal", empty content), retry + the SAME request once on the fallback model (Opus 4.8) — so gently keeps + working whether or not Fable 5 is currently serviceable. The moment the + org retention is fixed, Fable 5 serves with no code change.""" + from anthropic import BadRequestError + + fb = settings.models.refusal_fallback + model = api_kwargs.get("model") + try: + response = await self._call_api_with_retry(self.claude.messages.create, **api_kwargs) + except BadRequestError: + if not fb or fb == model: + raise + logger.warning("Model %s rejected the request (400); falling back to %s", model, fb) + return await self._call_api_with_retry( + self.claude.messages.create, **{**api_kwargs, "model": fb} + ) + if response.stop_reason == "refusal" and fb and fb != model: + logger.warning("Model %s declined the turn; retrying on %s", model, fb) + response = await self._call_api_with_retry( + self.claude.messages.create, **{**api_kwargs, "model": fb} + ) + return response + async def call_claude( self, user_message: str, system_prompt, tools, mode: str, auto_save_fn ) -> str: @@ -244,10 +271,11 @@ async def call_claude( "max_tokens": 16000 if use_thinking else 4096, } if use_thinking: - budget = 30000 if mode == "plan" else 10000 - api_kwargs["thinking"] = {"type": "enabled", "budget_tokens": budget} + # Fable 5 / Opus 4.8 reject thinking budget_tokens (400) — thinking + # is adaptive; control depth via effort instead of a token budget. + api_kwargs["output_config"] = {"effort": "high" if mode == "plan" else "medium"} - response = await self._call_api_with_retry(self.claude.messages.create, **api_kwargs) + response = await self._create_with_refusal_fallback(api_kwargs) self._track_token_usage(response) _extend_tool_calls(tool_calls_collected, response.content) @@ -259,17 +287,21 @@ async def call_claude( self.conversation_history.append({"role": "user", "content": tool_results}) api_kwargs["messages"] = self.conversation_history - response = await self._call_api_with_retry( - self.claude.messages.create, **api_kwargs - ) + response = await self._create_with_refusal_fallback(api_kwargs) self._track_token_usage(response) _extend_tool_calls(tool_calls_collected, response.content) - # Extract text response - assistant_message = "" - for block in response.content: - if hasattr(block, "text"): - assistant_message += block.text + # Extract text response. Fable 5 may refuse (stop_reason="refusal") + # with empty content — surface it instead of returning blank. + if response.stop_reason == "refusal": + assistant_message = ( + "(The request was declined by the model's safety system. Try rephrasing.)" + ) + else: + assistant_message = "" + for block in response.content: + if hasattr(block, "text"): + assistant_message += block.text self.conversation_history.append({"role": "assistant", "content": response.content}) @@ -400,6 +432,9 @@ async def get_tool_call(self, user_message: str, system_prompt, tools) -> dict | input_tokens = getattr(response.usage, "input_tokens", 0) output_tokens = getattr(response.usage, "output_tokens", 0) + # A refusal returns empty content — treat as "no tool call". + if response.stop_reason == "refusal" or not response.content: + return None for block in response.content: if block.type == "tool_use": return { @@ -510,71 +545,164 @@ async def call_claude_stream(self, system_prompt, tools, tool_label_fn, auto_sav dict Chunks as they arrive from Claude """ - from anthropic import APIStatusError - - def stream_and_collect(): - events = [] - final_message = None - - with self.claude.messages.stream( - model=self.model, - system=system_prompt, - messages=self.conversation_history, - tools=tools, - max_tokens=4096, - ) as stream: - for event in stream: - events.append(event) - final_message = stream.get_final_message() - - return events, final_message + from anthropic import APIStatusError, BadRequestError + + # Live streaming: a worker thread drains the SDK's (blocking) stream and + # pushes each event onto an asyncio queue as it arrives, so this coroutine + # can yield text/thinking deltas in real time instead of collecting the + # whole turn first (which left the UI on a blank spinner for the entire + # turn). thinking=summarized surfaces the model's reasoning during the + # wait. The full assistant content (incl. thinking blocks) is replayed from + # final_message below, so the tool-loop continuation stays valid. + _DONE = object() + + async def _stream_live(model, sink): + """Stream one attempt live: yield delta dicts as they arrive; record + events / final_message / error / full_text into `sink`.""" + loop = asyncio.get_running_loop() + queue: asyncio.Queue = asyncio.Queue() + state: dict = {} + + def worker(): + try: + with self.claude.messages.stream( + model=model, + system=system_prompt, + messages=self.conversation_history, + tools=tools, + max_tokens=16000, + # Adaptive thinking with a streamed, human-readable summary — + # this is what fills the "working…" wait. Opus 4.8 defaults to + # display="omitted" (empty thinking text), so it must be set. + thinking={"type": "adaptive", "display": "summarized"}, + output_config={"effort": "medium"}, + ) as stream: + for event in stream: + loop.call_soon_threadsafe(queue.put_nowait, event) + state["final"] = stream.get_final_message() + except BaseException as exc: # noqa: BLE001 — re-raised to caller below + state["error"] = exc + finally: + loop.call_soon_threadsafe(queue.put_nowait, _DONE) + + task = asyncio.create_task(asyncio.to_thread(worker)) + events: list = [] + full_text: list = [] + while True: + item = await queue.get() + if item is _DONE: + break + events.append(item) + if item.type != "content_block_delta": + continue + delta = item.delta + dtype = getattr(delta, "type", None) + if dtype == "thinking_delta": + chunk = getattr(delta, "thinking", "") or "" + if chunk: + yield {"type": "thinking", "text": chunk} + elif dtype == "text_delta" or hasattr(delta, "text"): + chunk = getattr(delta, "text", "") or "" + if chunk: + full_text.append(chunk) + yield {"type": "text", "text": chunk} + await task + sink["events"] = events + sink["full_text"] = full_text + sink["final"] = state.get("final") + sink["error"] = state.get("error") - # Run streaming in thread with retry logic max_retries = 3 retry_delay = 1.0 + fb = settings.models.refusal_fallback + model_in_use = self.model + sink: dict = {} for attempt in range(max_retries): - try: - events, final_message = await asyncio.to_thread(stream_and_collect) - self._track_token_usage(final_message) + sink = {} + yielded_any = False + async for chunk in _stream_live(model_in_use, sink): + yielded_any = True + yield chunk + err = sink.get("error") + if err is None: break - except APIStatusError as e: - error_type = getattr(e, "body", {}) + # Fable 5 under <30-day data retention (or unavailable) rejects with a + # 400 — fall back to Opus 4.8. Only safe before any partial was streamed. + if isinstance(err, BadRequestError) and fb and fb != model_in_use and not yielded_any: + logger.warning( + "Stream model %s rejected the request (400); falling back to %s", + model_in_use, + fb, + ) + model_in_use = fb + continue + if isinstance(err, APIStatusError): + error_type = getattr(err, "body", {}) if isinstance(error_type, dict): error_type = error_type.get("error", {}).get("type", "") - - if ( + overloaded = ( error_type in ("overloaded_error", "rate_limit_error") - or "overloaded" in str(e).lower() - ): - if attempt < max_retries - 1: - wait_time = retry_delay * (2**attempt) - logger.warning( - f"API overloaded, retrying in {wait_time:.1f}s" - f" (attempt {attempt + 1}/{max_retries})" - ) - yield { - "type": "text", - "text": f"\n*[API busy, retrying in {wait_time:.0f}s...]*\n", - } - await asyncio.sleep(wait_time) - continue - raise + or "overloaded" in str(err).lower() + ) + if overloaded and attempt < max_retries - 1 and not yielded_any: + wait_time = retry_delay * (2**attempt) + logger.warning( + "API overloaded, retrying in %.1fs (attempt %d/%d)", + wait_time, + attempt + 1, + max_retries, + ) + yield { + "type": "text", + "text": f"\n*[API busy, retrying in {wait_time:.0f}s...]*\n", + } + await asyncio.sleep(wait_time) + continue + raise err else: raise RuntimeError("API overloaded after multiple retries") - # Diagnostic: log stop_reason and tool block counts + final_message = sink["final"] + full_text = sink["full_text"] + self._track_token_usage(final_message) + + # Refusal → retry on the fallback model. Re-streaming live is only safe when + # the refusal came before any visible output (pre-output refusals carry empty + # content, so nothing was yielded); otherwise we keep the partial we showed. + if final_message.stop_reason == "refusal" and fb and model_in_use != fb and not full_text: + logger.warning("Model %s declined the streamed turn; retrying on %s", model_in_use, fb) + sink = {} + async for chunk in _stream_live(fb, sink): + yield chunk + model_in_use = fb + final_message = sink["final"] + full_text = sink["full_text"] + self._track_token_usage(final_message) + + # Last resort: if even the fallback declined, surface it and stop. + if final_message.stop_reason == "refusal": + logger.warning("Claude declined the request (model=%s)", model_in_use) + yield { + "type": "text", + "text": "(The request was declined by the model's safety system. Try rephrasing.)", + } + return + + # Diagnostic: per-response counts. DEBUG, not WARNING — stop_reason=tool_use + # with matching tool blocks is normal; the genuine anomaly is the + # logger.error below (tool blocks present but stop_reason != tool_use). tool_block_count = sum( 1 for b in final_message.content if hasattr(b, "type") and b.type == "tool_use" ) - logger.warning( - "Claude response: stop_reason=%s, content_blocks=%d, tool_use_blocks=%d," - " tools_passed=%d, model=%s", + logger.debug( + "Claude response: stop_reason=%s, content_blocks=%d, " + "tool_use_blocks=%d, tools_passed=%d, model=%s", final_message.stop_reason, len(final_message.content), tool_block_count, len(tools), - self.model, + model_in_use, ) if tool_block_count > 0 and final_message.stop_reason != "tool_use": logger.error( @@ -583,14 +711,6 @@ def stream_and_collect(): final_message.stop_reason, ) - # Process events and yield text - full_text = [] - for event in events: - if event.type == "content_block_delta": - if hasattr(event.delta, "text"): - full_text.append(event.delta.text) - yield {"type": "text", "text": event.delta.text} - # Detect fake XML tool calls in text (Claude writing tool_use as text) joined_text = "".join(full_text) if "" in joined_text or "" in joined_text: @@ -611,7 +731,94 @@ def stream_and_collect(): await asyncio.sleep(0.05) tool_results = [] + + # Concurrency fast-path: run a turn's tool calls in parallel when ALL of + # them are non-hardware and non-interactive (e.g. several strain / paper / + # lab-history lookups). Any microscope action or ask_user_choice in the + # batch falls back to the serial path below, so we never race hardware or + # an interactive prompt, and ordering of stateful ops is preserved. + tool_blocks = [b for b in response_content if getattr(b, "type", None) == "tool_use"] + _interactive = {"ask_user_choice"} + # Only parallelize genuinely read-only tools (independent lookups). Mutating + # tools (create_/update_/delete_/set_…) must stay serial — they share state + # (e.g. a campaign's plan file) and are order-dependent, so concurrent runs + # could race or corrupt it. + _readonly_prefixes = ( + "search_", + "read_", + "query_", + "get_", + "list_", + "recall_", + "find_", + "fetch_", + "lookup_", + ) + + def _parallel_safe(b): + td = self._tool_registry.get(b.name) + return ( + td is not None + and not td.requires_microscope + and b.name not in _interactive + and b.name.startswith(_readonly_prefixes) + ) + + handled_parallel = False + if len(tool_blocks) > 1 and all(_parallel_safe(b) for b in tool_blocks): + handled_parallel = True + starts = {b.id: time.time() for b in tool_blocks} + for b in tool_blocks: + yield { + "type": "tool_start", + "tool_name": b.name, + "tool_input": b.input, + "tool_label": tool_label_fn(b.name, b.input), + } + gathered = await asyncio.gather( + *[self._execute_single_tool(b.name, b.input) for b in tool_blocks], + return_exceptions=True, + ) + for b, res in zip(tool_blocks, gathered, strict=True): + if isinstance(res, BaseException): + is_error_flag = True + result_text = f"Error: {res}" + tool_results.append( + { + "type": "tool_result", + "tool_use_id": b.id, + "content": result_text, + "is_error": True, + } + ) + else: + is_error_flag = False + result_text = res if isinstance(res, str) else str(res) + tool_results.append( + {"type": "tool_result", "tool_use_id": b.id, "content": res} + ) + result_summary = next( + (ln.strip() for ln in (result_text or "").splitlines() if ln.strip()), + "", + ) + if len(result_summary) > 140: + result_summary = result_summary[:139] + "…" + result_full = result_text or "" + if len(result_full) > 4000: + result_full = result_full[:4000] + "\n…(truncated)" + yield { + "type": "tool_call", + "tool_name": b.name, + "tool_input": b.input, + "duration": time.time() - starts[b.id], + "result_summary": result_summary, + "result_full": result_full, + "is_error": is_error_flag, + } + for block in response_content: + if handled_parallel: + break if hasattr(block, "type") and block.type == "tool_use": start_time = time.time() @@ -629,9 +836,7 @@ def stream_and_collect(): if isinstance(tool_result, str): try: - from gently.app.tools.interaction_tools import ( - CHOICE_RESPONSE_TYPE, - ) + from gently.app.tools.interaction_tools import CHOICE_RESPONSE_TYPE choice_data = json.loads(tool_result) if ( @@ -650,11 +855,7 @@ def stream_and_collect(): tool_result if isinstance(tool_result, str) else str(tool_result) ) tool_results.append( - { - "type": "tool_result", - "tool_use_id": block.id, - "content": tool_result, - } + {"type": "tool_result", "tool_use_id": block.id, "content": tool_result} ) except Exception as e: is_error_flag = True @@ -678,12 +879,21 @@ def stream_and_collect(): if len(result_summary) > 140: result_summary = result_summary[:139] + "…" + # Full result (bounded) so the UI's expandable tool card can + # show what the tool actually returned — not just the 140-char + # one-liner. The web client caps/scrolls this further; keep the + # streamed payload sane. + result_full = result_text or "" + if len(result_full) > 4000: + result_full = result_full[:4000] + "\n…(truncated)" + yield { "type": "tool_call", "tool_name": block.name, "tool_input": block.input, "duration": time.time() - start_time, "result_summary": result_summary, + "result_full": result_full, "is_error": is_error_flag, } @@ -910,8 +1120,8 @@ async def _call_api_with_retry(self, api_func, *args, max_retries=3, **kwargs): if is_retryable and attempt < max_retries - 1: wait_time = retry_delay * (2**attempt) logger.warning( - f"API error ({error_type}), retrying in {wait_time:.1f}s" - f" (attempt {attempt + 1}/{max_retries})" + f"API error ({error_type}), retrying in {wait_time:.1f}s " + f"(attempt {attempt + 1}/{max_retries})" ) await asyncio.sleep(wait_time) continue diff --git a/gently/harness/detection/verifier.py b/gently/harness/detection/verifier.py index d7ce1e58..a540b337 100644 --- a/gently/harness/detection/verifier.py +++ b/gently/harness/detection/verifier.py @@ -27,13 +27,123 @@ logger = logging.getLogger(__name__) +# Each verification strategy is pinned to its tool via tool_choice, so the +# verdict arrives as a validated dict on the tool_use block — no +# startswith()-scraping of a "FIELD: VALUE" plain-text format, no silent +# defaults from a missed line. Downstream vote-tally / consensus logic is +# untouched: these helpers still produce the same strategy dataclasses. +# +# We deliberately don't ask the model to self-rate confidence (a heuristics-era +# artifact) — the boolean verdict is the signal, and the only confidence-like +# measure we keep is the ensemble's agreement ratio, which is *derived* from +# many independent votes rather than introspected by one call. +_ADVERSARIAL_TOOL = { + "name": "record_adversarial_review", + "description": ( + "Record the critical review verdict: whether counter-evidence " + "against the detection was found." + ), + "input_schema": { + "type": "object", + "properties": { + "found_counter_evidence": { + "type": "boolean", + "description": "True only if there is real evidence the detection is wrong.", + }, + "concerns": { + "type": "array", + "items": {"type": "string"}, + "description": "Specific doubts or alternative explanations; empty list if none.", + }, + }, + "required": ["found_counter_evidence", "concerns"], + }, +} + +_INDEPENDENT_TOOL = { + "name": "record_independent_assessment", + "description": ( + "Record an unbiased fresh assessment of whether the event occurred in this image." + ), + "input_schema": { + "type": "object", + "properties": { + "detected": { + "type": "boolean", + "description": "True if the event is observed in this image.", + }, + "key_evidence": { + "type": "string", + "description": "What specifically supports the conclusion.", + }, + }, + "required": ["detected", "key_evidence"], + }, +} + +_TEMPORAL_TOOL = { + "name": "record_temporal_comparison", + "description": ( + "Record whether a real change consistent with the event occurred " + "between the previous and current frames." + ), + "input_schema": { + "type": "object", + "properties": { + "change_detected": { + "type": "boolean", + "description": ( + "True if a clear change consistent with the event is visible across frames." + ), + }, + "description": { + "type": "string", + "description": "The specific change observed between previous and current frames.", + }, + }, + "required": ["change_detected", "description"], + }, +} + +_HARDWARE_CONTEXT_TOOL = { + "name": "record_hardware_context", + "description": "Record whether hardware errors could have caused a false-positive detection.", + "input_schema": { + "type": "object", + "properties": { + "suspicious": { + "type": "boolean", + "description": ( + "True if hardware errors could have affected image quality " + "or positioning for this embryo." + ), + }, + "concerns": { + "type": "array", + "items": {"type": "string"}, + "description": "Specific hardware concerns; empty list if none.", + }, + "reasoning": {"type": "string", "description": "Brief explanation of the analysis."}, + }, + "required": ["suspicious", "concerns", "reasoning"], + }, +} + + +def _tool_input(response) -> dict[str, Any] | None: + """Return the parsed input of the first tool_use block, or None.""" + for block in getattr(response, "content", None) or []: + if getattr(block, "type", None) == "tool_use": + return block.input + return None + + @dataclass class AdversarialResult: """Result of adversarial verification strategy""" found_counter_evidence: bool concerns: list[str] - confidence_in_original: ConfidenceLevel | None raw_response: str @@ -42,7 +152,6 @@ class IndependentResult: """Result of independent verification strategy""" detected: bool - confidence: ConfidenceLevel | None key_evidence: str raw_response: str @@ -53,7 +162,6 @@ class TemporalResult: change_detected: bool description: str - confidence: ConfidenceLevel | None raw_response: str @@ -111,21 +219,14 @@ def to_dict(self) -> dict[str, Any]: "adversarial": { "found_counter_evidence": self.adversarial.found_counter_evidence, "concerns": self.adversarial.concerns, - "confidence_in_original": self.adversarial.confidence_in_original.value - if self.adversarial.confidence_in_original - else None, }, "independent": { "detected": self.independent.detected, - "confidence": self.independent.confidence.value - if self.independent.confidence - else None, "key_evidence": self.independent.key_evidence, }, "temporal": { "change_detected": self.temporal.change_detected, "description": self.temporal.description, - "confidence": self.temporal.confidence.value if self.temporal.confidence else None, }, "consensus": self.consensus, "consensus_reasoning": self.consensus_reasoning, @@ -327,19 +428,10 @@ async def verify_with_context( ensemble_result, hardware_result, ) = await asyncio.gather( - adversarial_task, - independent_task, - temporal_task, - ensemble_task, - hardware_task, + adversarial_task, independent_task, temporal_task, ensemble_task, hardware_task ) else: - ( - adversarial, - independent, - temporal, - ensemble_result, - ) = await asyncio.gather( + adversarial, independent, temporal, ensemble_result = await asyncio.gather( adversarial_task, independent_task, temporal_task, ensemble_task ) else: @@ -354,6 +446,11 @@ async def verify_with_context( # Adversarial result strategies_complete += 1 + adversarial_summary = ( + "YES - " + ", ".join(adversarial.concerns) + if adversarial.found_counter_evidence + else "None found" + ) self._emit_event( EventType.VERIFICATION_STRATEGY, { @@ -361,17 +458,7 @@ async def verify_with_context( "detector_name": detector.name, "strategy": "adversarial", "passed": not adversarial.found_counter_evidence, - "summary": ( - "Counter-evidence: " - + ( - "YES - " + ", ".join(adversarial.concerns) - if adversarial.found_counter_evidence - else "None found" - ) - ), - "confidence": adversarial.confidence_in_original.value - if adversarial.confidence_in_original - else None, + "summary": f"Counter-evidence: {adversarial_summary}", }, ) self._emit_event( @@ -396,7 +483,6 @@ async def verify_with_context( f"Independent detection: {'YES' if independent.detected else 'NO'}" f" - {independent.key_evidence}" ), - "confidence": independent.confidence.value if independent.confidence else None, }, ) self._emit_event( @@ -421,7 +507,6 @@ async def verify_with_context( f"Change detected: {'YES' if temporal.change_detected else 'NO'}" f" - {temporal.description}" ), - "confidence": temporal.confidence.value if temporal.confidence else None, }, ) self._emit_event( @@ -464,6 +549,11 @@ async def verify_with_context( # Hardware context result (if applicable) if hardware_result: strategies_complete += 1 + hardware_summary = ( + "YES - " + ", ".join(hardware_result.concerns) + if hardware_result.suspicious + else "No" + ) self._emit_event( EventType.VERIFICATION_STRATEGY, { @@ -471,14 +561,7 @@ async def verify_with_context( "detector_name": detector.name, "strategy": "hardware_context", "passed": not hardware_result.suspicious, - "summary": ( - "Hardware errors suspicious: " - + ( - "YES - " + ", ".join(hardware_result.concerns) - if hardware_result.suspicious - else "No" - ) - ), + "summary": f"Hardware errors suspicious: {hardware_summary}", "reasoning": hardware_result.reasoning, }, ) @@ -493,12 +576,7 @@ async def verify_with_context( # Determine consensus (with hardware context) consensus, reasoning = self._evaluate_consensus_with_hardware( - original_result, - adversarial, - independent, - temporal, - ensemble_result, - hardware_result, + original_result, adversarial, independent, temporal, ensemble_result, hardware_result ) duration = (datetime.now() - start_time).total_seconds() @@ -577,8 +655,8 @@ async def _run_hardware_context_analysis( Analysis result """ try: - prompt = f"""You are analyzing hardware error context for a microscopy detection -verification. + prompt = f"""\ +You are analyzing hardware error context for a microscopy detection verification. GLOBAL ERROR LOG: {global_error_context} @@ -595,24 +673,31 @@ async def _run_hardware_context_analysis( (stage drift, hardware instability) - Multiple errors in quick succession suggests hardware problems -If ANY errors occurred that could have affected the image quality or positioning for -{embryo_id}, report as SUSPICIOUS. +If ANY errors occurred that could have affected the image quality or positioning +for {embryo_id}, mark it suspicious. -Respond in EXACTLY this format: -SUSPICIOUS: [YES/NO] -CONCERNS: [list specific concerns, separated by semicolons] -REASONING: [brief explanation of your analysis] +Record your analysis with the record_hardware_context tool. """ response = await asyncio.to_thread( self.claude.messages.create, model=self.ensemble_model, # Use Haiku for speed max_tokens=300, + tools=[_HARDWARE_CONTEXT_TOOL], + tool_choice={"type": "tool", "name": _HARDWARE_CONTEXT_TOOL["name"]}, messages=[{"role": "user", "content": prompt}], ) - response_text = response.content[0].text - return self._parse_hardware_context_response(response_text) + data = _tool_input(response) + if not isinstance(data, dict): + raise ValueError("no tool_use block in response") + concerns = data.get("concerns") or [] + return HardwareContextResult( + suspicious=bool(data.get("suspicious", True)), + concerns=[str(c) for c in concerns], + reasoning=str(data.get("reasoning", "")), + raw_response=str(data), + ) except Exception as e: logger.error(f"Hardware context analysis failed: {e}") @@ -623,30 +708,6 @@ async def _run_hardware_context_analysis( raw_response="", ) - def _parse_hardware_context_response(self, response: str) -> HardwareContextResult: - """Parse hardware context analysis response""" - suspicious = False - concerns = [] - reasoning = "" - - for line in response.split("\n"): - line = line.strip() - if line.startswith("SUSPICIOUS:"): - value = line.split(":", 1)[1].strip().upper() - suspicious = value == "YES" - elif line.startswith("CONCERNS:"): - concerns_str = line.split(":", 1)[1].strip() - concerns = [c.strip() for c in concerns_str.split(";") if c.strip()] - elif line.startswith("REASONING:"): - reasoning = line.split(":", 1)[1].strip() - - return HardwareContextResult( - suspicious=suspicious, - concerns=concerns, - reasoning=reasoning, - raw_response=response, - ) - def _evaluate_consensus_with_hardware( self, original: DetectionResult, @@ -753,7 +814,6 @@ async def _run_adversarial( return AdversarialResult( found_counter_evidence=False, concerns=["No images available for verification"], - confidence_in_original=None, raw_response="", ) @@ -771,11 +831,10 @@ async def _run_adversarial( else: specific_guidance = "" - prompt = f"""You are reviewing a detection result for a C. elegans embryo -(diSPIM max projection). + prompt = f"""\ +You are reviewing a detection result for a C. elegans embryo (diSPIM max projection). The system detected: {detector.name} -Original confidence: {original_result.confidence.value if original_result.confidence else "unknown"} Original reasoning: {original_result.reasoning or "not provided"} NOW ACT AS A CRITICAL REVIEWER. Your job is to find reasons why this detection might be INCORRECT: @@ -784,10 +843,7 @@ async def _run_adversarial( - Is the evidence actually conclusive, or could it be interpreted differently? - Are there alternative explanations for what is observed? {specific_guidance} -Analyze the image(s) carefully and respond in EXACTLY this format: -COUNTER_EVIDENCE_FOUND: [YES/NO] -CONCERNS: [list specific doubts or alternative explanations, separated by semicolons] -CONFIDENCE_IN_ORIGINAL: [HIGH/MEDIUM/LOW] +Analyze the image(s) carefully and record your review with the record_adversarial_review tool. """ content = [{"type": "text", "text": prompt}] + images @@ -796,18 +852,26 @@ async def _run_adversarial( self.claude.messages.create, model=self.model, max_tokens=500, + tools=[_ADVERSARIAL_TOOL], + tool_choice={"type": "tool", "name": _ADVERSARIAL_TOOL["name"]}, messages=[{"role": "user", "content": content}], ) - response_text = response.content[0].text - return self._parse_adversarial_response(response_text) + data = _tool_input(response) + if not isinstance(data, dict): + raise ValueError("no tool_use block in response") + concerns = data.get("concerns") or [] + return AdversarialResult( + found_counter_evidence=bool(data.get("found_counter_evidence", False)), + concerns=[str(c) for c in concerns], + raw_response=str(data), + ) except Exception as e: logger.error(f"Adversarial verification failed: {e}") return AdversarialResult( found_counter_evidence=False, concerns=[f"Verification error: {str(e)}"], - confidence_in_original=None, raw_response="", ) @@ -829,7 +893,6 @@ async def _run_independent( if not images: return IndependentResult( detected=False, - confidence=None, key_evidence="No images available", raw_response="", ) @@ -849,8 +912,8 @@ async def _run_independent( criteria = detector.description # Use a neutral prompt that doesn't reveal the previous detection - prompt = f"""Analyze this C. elegans embryo image (diSPIM max projection) at -timepoint {timepoint}. + prompt = f"""\ +Analyze this C. elegans embryo image (diSPIM max projection) at timepoint {timepoint}. Question: Has '{detector.name}' occurred in this embryo? @@ -859,10 +922,7 @@ async def _run_independent( Provide an independent assessment based SOLELY on what you observe in this image. Do not assume any prior state - analyze only what is visible now. -Respond in EXACTLY this format: -DETECTED: [YES/NO] -CONFIDENCE: [HIGH/MEDIUM/LOW] -KEY_EVIDENCE: [what specifically do you observe that supports your conclusion?] +Record your assessment with the record_independent_assessment tool. """ content = [{"type": "text", "text": prompt}] + images @@ -871,17 +931,24 @@ async def _run_independent( self.claude.messages.create, model=self.model, max_tokens=400, + tools=[_INDEPENDENT_TOOL], + tool_choice={"type": "tool", "name": _INDEPENDENT_TOOL["name"]}, messages=[{"role": "user", "content": content}], ) - response_text = response.content[0].text - return self._parse_independent_response(response_text) + data = _tool_input(response) + if not isinstance(data, dict): + raise ValueError("no tool_use block in response") + return IndependentResult( + detected=bool(data.get("detected", False)), + key_evidence=str(data.get("key_evidence", "")), + raw_response=str(data), + ) except Exception as e: logger.error(f"Independent verification failed: {e}") return IndependentResult( detected=False, - confidence=None, key_evidence=f"Verification error: {str(e)}", raw_response="", ) @@ -904,7 +971,6 @@ async def _run_temporal_check( return TemporalResult( change_detected=True, # Can't disprove without history description="Insufficient temporal history for comparison", - confidence=ConfidenceLevel.LOW, raw_response="", ) @@ -930,7 +996,6 @@ async def _run_temporal_check( return TemporalResult( change_detected=True, description="No previous images available", - confidence=ConfidenceLevel.LOW, raw_response="", ) @@ -947,8 +1012,8 @@ async def _run_temporal_check( - Not just a static state that could have existed before - Clear evidence of progression or event occurrence""" - prompt = f"""Compare these sequential timepoints of a C. elegans embryo -(diSPIM max projection). + prompt = f"""\ +Compare these sequential timepoints of a C. elegans embryo (diSPIM max projection). PREVIOUS TIMEPOINTS (shown first): These are from t={timepoint - 2} to t={timepoint - 1} @@ -960,10 +1025,7 @@ async def _run_temporal_check( {temporal_criteria} -Respond in EXACTLY this format: -CHANGE_DETECTED: [YES/NO] -DESCRIPTION: [what specific change do you see between the previous and current frames?] -CONFIDENCE: [HIGH/MEDIUM/LOW] +Record your comparison with the record_temporal_comparison tool. """ # Combine: previous images first, then prompt, then current @@ -973,18 +1035,25 @@ async def _run_temporal_check( self.claude.messages.create, model=self.model, max_tokens=400, + tools=[_TEMPORAL_TOOL], + tool_choice={"type": "tool", "name": _TEMPORAL_TOOL["name"]}, messages=[{"role": "user", "content": content}], ) - response_text = response.content[0].text - return self._parse_temporal_response(response_text) + data = _tool_input(response) + if not isinstance(data, dict): + raise ValueError("no tool_use block in response") + return TemporalResult( + change_detected=bool(data.get("change_detected", True)), + description=str(data.get("description", "")), + raw_response=str(data), + ) except Exception as e: logger.error(f"Temporal verification failed: {e}") return TemporalResult( change_detected=True, # Don't block on error description=f"Verification error: {str(e)}", - confidence=None, raw_response="", ) @@ -1038,10 +1107,10 @@ async def _run_ensemble_hatching(self, embryo_state: EmbryoState) -> EnsembleRes Answer ONE question: Has the embryo HATCHED? -HATCHED means: The worm body is OUTSIDE the eggshell (free-floating, elongated, or field is -empty because worm left). -NOT HATCHED means: The worm is still INSIDE the eggshell (coiled/pretzel-shaped, even if -shell looks expanded). +HATCHED means: The worm body is OUTSIDE the eggshell (free-floating, elongated, +or field is empty because worm left). +NOT HATCHED means: The worm is still INSIDE the eggshell (coiled/pretzel-shaped, +even if shell looks expanded). Respond with ONLY: YES or NO""" @@ -1062,8 +1131,8 @@ async def single_vote() -> str: # Run all votes in parallel logger.info( - f"[ENSEMBLE] Running {self.ensemble_size} parallel Haiku calls" - " for hatching verification" + f"[ENSEMBLE] Running {self.ensemble_size} parallel Haiku calls " + "for hatching verification" ) tasks = [single_vote() for _ in range(self.ensemble_size)] responses = await asyncio.gather(*tasks) @@ -1113,88 +1182,6 @@ async def single_vote() -> str: raw_responses=[f"Error: {str(e)}"], ) - def _parse_adversarial_response(self, response: str) -> AdversarialResult: - """Parse adversarial strategy response""" - found_counter = False - concerns = [] - confidence = None - - for line in response.split("\n"): - line = line.strip() - if line.startswith("COUNTER_EVIDENCE_FOUND:"): - value = line.split(":", 1)[1].strip().upper() - found_counter = value == "YES" - elif line.startswith("CONCERNS:"): - concerns_str = line.split(":", 1)[1].strip() - concerns = [c.strip() for c in concerns_str.split(";") if c.strip()] - elif line.startswith("CONFIDENCE_IN_ORIGINAL:"): - value = line.split(":", 1)[1].strip().upper() - try: - confidence = ConfidenceLevel(value) - except ValueError: - pass - - return AdversarialResult( - found_counter_evidence=found_counter, - concerns=concerns, - confidence_in_original=confidence, - raw_response=response, - ) - - def _parse_independent_response(self, response: str) -> IndependentResult: - """Parse independent strategy response""" - detected = False - confidence = None - evidence = "" - - for line in response.split("\n"): - line = line.strip() - if line.startswith("DETECTED:"): - value = line.split(":", 1)[1].strip().upper() - detected = value == "YES" - elif line.startswith("CONFIDENCE:"): - value = line.split(":", 1)[1].strip().upper() - try: - confidence = ConfidenceLevel(value) - except ValueError: - pass - elif line.startswith("KEY_EVIDENCE:"): - evidence = line.split(":", 1)[1].strip() - - return IndependentResult( - detected=detected, - confidence=confidence, - key_evidence=evidence, - raw_response=response, - ) - - def _parse_temporal_response(self, response: str) -> TemporalResult: - """Parse temporal strategy response""" - change_detected = False - description = "" - confidence = None - - for line in response.split("\n"): - line = line.strip() - if line.startswith("CHANGE_DETECTED:"): - value = line.split(":", 1)[1].strip().upper() - change_detected = value == "YES" - elif line.startswith("DESCRIPTION:"): - description = line.split(":", 1)[1].strip() - elif line.startswith("CONFIDENCE:"): - value = line.split(":", 1)[1].strip().upper() - try: - confidence = ConfidenceLevel(value) - except ValueError: - pass - - return TemporalResult( - change_detected=change_detected, - description=description, - confidence=confidence, - raw_response=response, - ) - def _evaluate_consensus( self, original: DetectionResult, @@ -1253,8 +1240,8 @@ def _evaluate_consensus( f"All verification strategies agree ({total_strategies}/{total_strategies}): " f"no counter-evidence found, independent analysis confirms detection, " f"temporal change observed, ensemble voting confirms " - f"({ensemble.votes_yes}/{ensemble.total_votes}" - f" = {ensemble.agreement_ratio:.0%} YES)." + f"({ensemble.votes_yes}/{ensemble.total_votes} = " + f"{ensemble.agreement_ratio:.0%} YES)." ) else: reasoning = ( diff --git a/gently/harness/memory/file_store.py b/gently/harness/memory/file_store.py index b1b612b7..a5a1580f 100644 --- a/gently/harness/memory/file_store.py +++ b/gently/harness/memory/file_store.py @@ -503,6 +503,7 @@ def update_campaign_progress(self, campaign_id: str, progress: str): data["progress"] = progress data["updated_at"] = self._now() self._write_yaml(folder / "campaign.yaml", data) + self._notify_plan_change(campaign_id) def update_campaign_status(self, campaign_id: str, status: Status): folder = self._campaign_folder(campaign_id) @@ -582,6 +583,11 @@ def get_subcampaigns(self, campaign_id: str) -> list[Campaign]: return children def get_nth_subcampaign(self, parent_id: str, n: int) -> Campaign | None: + # Tolerate n arriving as a numeric string (tool args are often stringified). + try: + n = int(n) + except (ValueError, TypeError): + return None phases = self.get_subcampaigns(parent_id) if 1 <= n <= len(phases): return phases[n - 1] @@ -805,6 +811,64 @@ def complete_session_intent(self, session_id: str, actual_summary: str): data["completed_at"] = self._now() self._write_yaml(path, data) + # ================================================================== + # Operation Plans + # ================================================================== + + def set_operation_plan(self, session_id: str, plan: dict) -> None: + """Persist the agent-authored Operation Plan for a session. + + The plan dict is stored verbatim (agent is the source of truth). + Fires CONTEXT_UPDATED so the Operations UI refreshes live. + """ + path = self.agent_dir / "operation_plans" / f"{session_id}.yaml" + self._write_yaml(path, plan) + self._notify_context_change("operation_plan") + + def get_operation_plan(self, session_id: str) -> dict | None: + """Return the stored Operation Plan for a session, or None if absent.""" + path = self.agent_dir / "operation_plans" / f"{session_id}.yaml" + return self._read_yaml(path) + + def transition_tactic( + self, session_id: str, tactic_id: str, state: str | None = None, **bind + ) -> bool: + """Atomically update a tactic's state and/or bind live values onto it. + + Reads the plan via get_operation_plan, locates the tactic by id, + sets its state (if provided), merges bind kwargs into its live dict + (creating it if absent), stamps updated_at/updated_reason, and writes + back via set_operation_plan so CONTEXT_UPDATED fires exactly once. + + Returns True on success, False if the plan is absent or the tactic id + is not found (no-op, no crash). + + Note: read-modify-write with no lock; safe because all subscribed event + emissions run on the single asyncio loop thread — revisit if a + worker-thread emitter is ever added. + """ + plan = self.get_operation_plan(session_id) + if plan is None: + return False + + tactics = plan.get("tactics", []) + tactic = next((t for t in tactics if t.get("id") == tactic_id), None) + if tactic is None: + return False + + if state is not None: + tactic["state"] = state + + if bind: + live = tactic.setdefault("live", {}) + live.update(bind) + + plan["updated_at"] = self._now() + plan["updated_reason"] = f"tactic {tactic_id} transitioned" + + self.set_operation_plan(session_id, plan) + return True + # ================================================================== # Session <-> Campaign (many-to-many) # ================================================================== @@ -827,6 +891,7 @@ def link_session_campaign(self, session_id: str, campaign_id: str): cids.append(campaign_id) data["campaign_ids"] = cids self._write_yaml(path, data) + self._notify_plan_change(campaign_id) def unlink_session_campaign(self, session_id: str, campaign_id: str): path = self.agent_dir / "session_intents" / f"{session_id}.yaml" @@ -1140,6 +1205,7 @@ def create_plan_item( "inherit_from": inherit_from, "planned_session_id": planned_session_id, "session_id": None, + "session_ids": [], "estimated_days": estimated_days, "phase_order": phase_order, "references": references, @@ -1151,6 +1217,7 @@ def create_plan_item( } items.append(item_data) self._write_plan_items(campaign_id, items) + self._notify_plan_change(campaign_id) logger.info(f"Created plan item {pid} [{type}] #{phase_order}: {title}") return pid @@ -1331,10 +1398,12 @@ def update_plan_item( new_items = self._read_plan_items_raw(campaign_id) new_items.append(item) self._write_plan_items(campaign_id, new_items) + self._notify_plan_change(campaign_id) return item["updated_at"] = self._now() self._write_plan_items(old_campaign_id, items) + self._notify_plan_change(old_campaign_id) def complete_plan_item(self, item_id: str, outcome: str): self.update_plan_item( @@ -1343,6 +1412,76 @@ def complete_plan_item(self, item_id: str, outcome: str): outcome=outcome, ) + def link_plan_item_session( + self, item_id: str, session_id: str, set_in_progress: bool = True + ) -> bool: + """Attach a session to a plan item — APPENDS (an item may run several times: + re-runs, multi-sitting, more embryos later). Records the session as the latest + `session_id` (back-compat), flips a PLANNED item to IN_PROGRESS, emits PLAN_UPDATED. + Returns False if the item isn't found.""" + loc = self._find_plan_item_location(item_id) + if not loc: + return False + campaign_id, items, idx = loc + item = items[idx] + sids = item.get("session_ids") or ([item["session_id"]] if item.get("session_id") else []) + if session_id and session_id not in sids: + sids.append(session_id) + item["session_ids"] = sids + if sids: + item["session_id"] = sids[-1] # most recent run; back-compat for older readers + if set_in_progress and item.get("status") == "planned": + item["status"] = PlanItemStatus.IN_PROGRESS.value + item["updated_at"] = self._now() + self._write_plan_items(campaign_id, items) + self._notify_plan_change(campaign_id) + return True + + def unlink_plan_item_session(self, item_id: str, session_id: str) -> bool: + """Remove a session from a plan item's session_ids list. + + Mirrors the load/persist/notify pattern of link_plan_item_session. + Clears the back-compat scalar session_id when it matched the removed + session (sets it to the most-recent remaining session_id, or None). + Idempotent: returns False without writing if the session isn't linked. + Returns True on successful removal. + """ + loc = self._find_plan_item_location(item_id) + if not loc: + return False + campaign_id, items, idx = loc + item = items[idx] + sids = item.get("session_ids") or ([item["session_id"]] if item.get("session_id") else []) + if session_id not in sids: + return False + sids = [s for s in sids if s != session_id] + item["session_ids"] = sids + item["session_id"] = sids[-1] if sids else None # back-compat: most recent remaining + item["updated_at"] = self._now() + self._write_plan_items(campaign_id, items) + self._notify_plan_change(campaign_id) + return True + + def get_plan_items_for_session(self, session_id: str) -> list["PlanItem"]: + """Return all plan items linked to a session. + + Iterates active campaigns only (mirrors the normal read path). + Back-compat: matches items whose scalar session_id equals the query even + when session_ids is empty (old items written before the list field existed). + Deduplicates by item id. + """ + seen: set[str] = set() + result: list[PlanItem] = [] + for campaign in self.get_active_campaigns(): + for item in self.get_plan_items(campaign.id): + if item.id in seen: + continue + # session_ids is already populated from back-compat in _dict_to_plan_item + if session_id in (item.session_ids or []) or item.session_id == session_id: + seen.add(item.id) + result.append(item) + return result + def skip_plan_item(self, item_id: str, reason: str | None = None): self.update_plan_item( item_id, @@ -1699,6 +1838,98 @@ def delete_plan_template(self, template_id: str) -> bool: return True return False + # ================================================================== + # Tactic Library + # ================================================================== + + def save_tactic(self, tactic: dict, name: str | None = None) -> str: + """Persist a tactic as a reusable template in agent/tactic_library/. + + Strips runtime state (live, state, original id) and assigns a new + template id + slug. Fires CONTEXT_UPDATED. Returns the template id. + """ + name = name or tactic.get("name") or "unnamed" + slug = self._slugify(name) + tid = self._gen_id() + now = self._now() + + template = { + "id": tid, + "name": name, + "slug": slug, + "kind": tactic.get("kind", "unknown"), + "structure": copy.deepcopy(tactic.get("structure") or {}), + "scope_hint": copy.deepcopy(tactic.get("scope")), + "description": tactic.get("description") or tactic.get("rationale"), + "rationale": tactic.get("rationale"), + "params": copy.deepcopy(tactic.get("params")), + "relations": copy.deepcopy(tactic.get("relations") or {}), + "live_bind": list(tactic.get("live_bind") or []), + "created_at": now, + "created_by": tactic.get("created_by", "agent"), + } + + path = self.agent_dir / "tactic_library" / f"{tid}_{slug}.yaml" + self._write_yaml(path, template) + self._notify_context_change("tactic_library") + logger.info(f"Saved tactic template '{name}' ({tid})") + return tid + + def list_tactics(self) -> list[dict]: + """List all saved tactic templates, ordered by created_at descending.""" + tl_dir = self.agent_dir / "tactic_library" + if not tl_dir.exists(): + return [] + tactics: list[dict] = [] + for f in tl_dir.iterdir(): + if f.suffix in (".yaml", ".yml"): + data = self._read_yaml(f) + if data: + tactics.append(data) + tactics.sort(key=lambda t: t.get("created_at", ""), reverse=True) + return tactics + + def get_tactic(self, id_or_name: str) -> dict | None: + """Return a tactic template by id or name, or None if not found. + + Uses list_tactics() (sorted newest-first by created_at) so that name + lookups are deterministic: on a name collision, the newest entry wins. + id lookups are unique by construction so order does not matter. + """ + tl_dir = self.agent_dir / "tactic_library" + if not tl_dir.exists(): + return None + for tactic in self.list_tactics(): + if tactic.get("id") == id_or_name or tactic.get("name") == id_or_name: + return tactic + return None + + def apply_tactic(self, id_or_name: str) -> dict | None: + """Return a fresh planned tactic from a saved template. + + The returned dict has a new run id (distinct from the template id), + state="planned", and no live/runtime state. Returns None if the + template is not found. + """ + tmpl = self.get_tactic(id_or_name) + if tmpl is None: + return None + tactic = copy.deepcopy(tmpl) + # Assign a new run id — must differ from the template id + tactic["id"] = self._gen_id() + tactic["state"] = "planned" + # Promote scope_hint back to scope for the tactic + scope_hint = tactic.pop("scope_hint", None) + if scope_hint is not None: + tactic["scope"] = scope_hint + # Strip template-internal metadata + tactic.pop("slug", None) + tactic.pop("created_at", None) + tactic.pop("created_by", None) + # Strip runtime state (should not exist in a template, but guard anyway) + tactic.pop("live", None) + return tactic + # ================================================================== # Plan Snapshots # ================================================================== @@ -1908,6 +2139,26 @@ def get_observations_for_embryo(self, embryo_id: str, limit: int = 20) -> list[O # Expectations # ================================================================== + def _notify_context_change(self, kind: str = "context") -> None: + """Emit CONTEXT_UPDATED on the global bus so the shared-visibility + surface refreshes live. Best-effort — a bus failure never breaks a write.""" + try: + from gently.core.event_bus import EventType, emit + + emit(EventType.CONTEXT_UPDATED, {"kind": kind}, source="context_store") + except Exception: + pass + + def _notify_plan_change(self, campaign_id: str | None = None) -> None: + """Emit PLAN_UPDATED so the Plans UI refreshes live when a plan item or + campaign changes (status, session link, new item, progress). Best-effort.""" + try: + from gently.core.event_bus import EventType, emit + + emit(EventType.PLAN_UPDATED, {"campaign_id": campaign_id}, source="context_store") + except Exception: + pass + def add_expectation(self, exp: Expectation): path = self.agent_dir / "active" / "expectations.yaml" items = self._read_yaml(path) or [] @@ -1925,6 +2176,7 @@ def add_expectation(self, exp: Expectation): } ) self._write_yaml(path, items) + self._notify_context_change("expectation") def get_pending_expectations(self) -> list[Expectation]: path = self.agent_dir / "active" / "expectations.yaml" @@ -1956,6 +2208,7 @@ def resolve_expectation(self, exp_id: str, status: ExpectationStatus): item["resolved_at"] = now break self._write_yaml(path, items) + self._notify_context_change("expectation") # ================================================================== # Watchpoints @@ -1975,6 +2228,7 @@ def add_watchpoint(self, wp: Watchpoint): } ) self._write_yaml(path, items) + self._notify_context_change("watchpoint") def get_active_watchpoints(self) -> list[Watchpoint]: path = self.agent_dir / "active" / "watchpoints.yaml" @@ -2002,6 +2256,7 @@ def resolve_watchpoint(self, wp_id: str): item["status"] = "resolved" break self._write_yaml(path, items) + self._notify_context_change("watchpoint") # ================================================================== # Questions @@ -2021,6 +2276,7 @@ def add_question(self, q: Question): } ) self._write_yaml(path, items) + self._notify_context_change("question") def get_open_questions(self) -> list[Question]: path = self.agent_dir / "active" / "questions.yaml" @@ -2042,6 +2298,7 @@ def resolve_question(self, q_id: str, resolution: str): item["resolved_at"] = now break self._write_yaml(path, items) + self._notify_context_change("question") # ================================================================== # Learnings @@ -2154,6 +2411,17 @@ def set_state(self, key: str, value: str): # Batch Updates # ================================================================== + @property + def notebook(self): + """The shared lab notebook, rooted at agent_dir/notebook (lazy).""" + nb = getattr(self, "_notebook", None) + if nb is None: + from .notebook import NotebookStore + + nb = NotebookStore(self.agent_dir / "notebook") + self._notebook = nb + return nb + def apply_updates(self, updates: ContextUpdates): for obs in updates.new_observations: self.add_observation(obs) @@ -2181,6 +2449,18 @@ def apply_updates(self, updates: ContextUpdates): if updates.new_focus is not None: self.set_state("current_focus", updates.new_focus) + # Mirror new observations & learnings into the shared notebook + # (best-effort — a notebook failure never breaks the legacy write). + from .notebook import learning_to_note, observation_to_note + + try: + for obs in updates.new_observations: + self.notebook.write_note(observation_to_note(obs)) + for learning in updates.new_learnings: + self.notebook.write_note(learning_to_note(learning)) + except Exception: + logger.warning("notebook mirror failed", exc_info=True) + # ================================================================== # ML Pipelines # ================================================================== @@ -2528,6 +2808,14 @@ def _dict_to_plan_item(d: dict) -> PlanItem: imaging_spec = None bench_spec = None + # Tolerate specs persisted as JSON strings (older tool calls that passed + # spec as a string instead of an object) so read-back never crashes. + if isinstance(spec_data, str): + try: + spec_data = json.loads(spec_data) + except (json.JSONDecodeError, TypeError): + spec_data = None + if spec_data: if item_type == PlanItemType.IMAGING: valid = {f.name for f in dataclasses.fields(ImagingSpec)} @@ -2537,6 +2825,11 @@ def _dict_to_plan_item(d: dict) -> PlanItem: bench_spec = BenchSpec(**{k: v for k, v in spec_data.items() if k in valid}) references = d.get("references") or [] + if isinstance(references, str): + try: + references = json.loads(references) or [] + except (json.JSONDecodeError, TypeError): + references = [] return PlanItem( id=d["id"], @@ -2554,6 +2847,7 @@ def _dict_to_plan_item(d: dict) -> PlanItem: bench_spec=bench_spec, planned_session_id=d.get("planned_session_id"), session_id=d.get("session_id"), + session_ids=d.get("session_ids") or ([d["session_id"]] if d.get("session_id") else []), inherit_from=d.get("inherit_from"), estimated_days=d.get("estimated_days"), phase_order=d.get("phase_order", 0), diff --git a/gently/harness/memory/interface.py b/gently/harness/memory/interface.py index 2e27db8d..d6824a69 100644 --- a/gently/harness/memory/interface.py +++ b/gently/harness/memory/interface.py @@ -215,15 +215,42 @@ def get_awareness_summary(self) -> str: spec = self.store.resolve_imaging_spec(item) campaign = self.store.get_campaign(item.campaign_id) campaign_name = _short_name(campaign) if campaign else "?" + # Walk to the root campaign for the overall goal (the item's + # campaign may be a phase under it). + root = campaign + seen_ids: set[str] = set() + while root and root.parent_id and root.parent_id not in seen_ids: + seen_ids.add(root.id) + root = self.store.get_campaign(root.parent_id) lines.append(f"\n## Active Plan Item: {item.title}") - lines.append(f"Campaign: {campaign_name}") + if root and root.target: + lines.append(f"Goal of the investigation: {root.target}") + if campaign and root and campaign.id != root.id: + lines.append(f"Phase: {campaign_name}") + lines.append(f"Campaign: {_short_name(root) if root else campaign_name}") lines.append(f"Status: {item.status.value}") if spec: lines.append(self.format_imaging_spec_block(spec)) + # What's next — the items/gates this run unblocks. + try: + root_id = root.id if root else item.campaign_id + nxt = [ + u + for u in self.store.get_unblocked_plan_items(root_id) + if u.id != item.id + ][:3] + if nxt: + bits = [] + for u in nxt: + is_dp = u.type.value == "decision_point" + bits.append(u.title + (" (decision point)" if is_dp else "")) + lines.append("Next up: " + "; ".join(bits)) + except Exception: + pass lines.append( - "\nUse this spec when configuring embryos and " - "starting the timelapse. The user expects these " - "settings from their experimental plan." + "\nYou're executing this item within the plan above — use the " + "spec to configure and run, and keep the goal and what's next in " + "mind (you can make go/no-go calls). The user expects these settings." ) except Exception: pass diff --git a/gently/harness/memory/model.py b/gently/harness/memory/model.py index 2a164176..55bff965 100644 --- a/gently/harness/memory/model.py +++ b/gently/harness/memory/model.py @@ -240,6 +240,19 @@ class ImagingSpec: success_criteria: str | None = None comparison_to: str | None = None # "Compare to WT session 1" + # Per-field provenance for INFERRED values — field name -> {source, confidence}. + # e.g. {"laser_wavelength_nm": {"source": "inferred:genotype", "confidence": "medium"}} + # Lets the UI tag each value with where it came from and what to confirm. + provenance: dict[str, dict[str, str]] = field(default_factory=dict) + + # Tactical outline — intended Operations for this imaging session. + # Each entry is a lightweight tactic descriptor: + # {kind, name, target?, scope?, structure?} + # kind ∈ standing_timelapse | reactive_monitor | scripted_protocol | + # exclusive_burst | oneshot | custom + # Populated at plan time; later seeds the session's Operation Plan. + tactics: list[dict] = field(default_factory=list) + @dataclass class BenchSpec: @@ -287,7 +300,8 @@ class PlanItem: # Linking planned_session_id: str | None = None # → PlannedSession (for imaging items) - session_id: str | None = None # → Actual session (once executed) + session_id: str | None = None # → most recent actual session (back-compat / "primary") + session_ids: list[str] = field(default_factory=list) # all sessions that ran this item (1→many) inherit_from: str | None = None # PlanItem ID to inherit spec from # Scheduling — relative timeline from Day 0 diff --git a/gently/harness/memory/notebook.py b/gently/harness/memory/notebook.py new file mode 100644 index 00000000..88271fa8 --- /dev/null +++ b/gently/harness/memory/notebook.py @@ -0,0 +1,294 @@ +""" +The shared lab notebook — unified memory entry (Note) and file-backed store. + +One Note kind taxonomy (observation / finding / question); everything else +(author, status, confidence, scope, links) is an orthogonal field. See +docs/superpowers/specs/2026-06-16-shared-lab-notebook-design.md. +""" + +from __future__ import annotations + +import os +import re +import uuid +from dataclasses import dataclass, field +from datetime import datetime +from enum import Enum +from pathlib import Path +from typing import Any + +import yaml + +from .model import Confidence, Learning, Observation + + +class NoteKind(str, Enum): + OBSERVATION = "observation" # immutable record of what was seen/done/read/noted + FINDING = "finding" # revisable, supersedable believed claim + QUESTION = "question" # open inquiry; large ones are the thread spine + + +class Author(str, Enum): + HUMAN = "human" + AGENT = "agent" + PERCEPTION = "perception" + + +class NoteStatus(str, Enum): + OPEN = "open" # questions not yet answered + PROPOSED = "proposed" # agent-drafted finding awaiting human confirm + CONFIRMED = "confirmed" # accepted observation/finding (default) + ANSWERED = "answered" # question resolved + SUPERSEDED = "superseded" # replaced by a newer note + + +@dataclass +class Note: + id: str + kind: NoteKind + body: str + author: Author = Author.AGENT + title: str | None = None + status: NoteStatus = NoteStatus.CONFIRMED + confidence: Confidence | None = None + strains: list[str] = field(default_factory=list) + embryos: list[str] = field(default_factory=list) + sessions: list[str] = field(default_factory=list) + threads: list[str] = field(default_factory=list) + basis: list[str] = field(default_factory=list) # note ids this rests on + links: list[dict] = field(default_factory=list) # [{"rel": ..., "to": ...}] + artifacts: list[dict] = field(default_factory=list) # FileStore pointers + superseded_by: str | None = None + created_at: datetime = field(default_factory=datetime.now) + updated_at: datetime = field(default_factory=datetime.now) + + +def note_to_dict(n: Note) -> dict[str, Any]: + return { + "id": n.id, + "kind": n.kind.value, + "body": n.body, + "author": n.author.value, + "title": n.title, + "status": n.status.value, + "confidence": n.confidence.value if n.confidence else None, + "strains": list(n.strains), + "embryos": list(n.embryos), + "sessions": list(n.sessions), + "threads": list(n.threads), + "basis": list(n.basis), + "links": list(n.links), + "artifacts": list(n.artifacts), + "superseded_by": n.superseded_by, + "created_at": n.created_at.isoformat(), + "updated_at": n.updated_at.isoformat(), + } + + +def note_from_dict(d: dict[str, Any]) -> Note: + conf = d.get("confidence") + return Note( + id=d["id"], + kind=NoteKind(d["kind"]), + body=d.get("body", ""), + author=Author(d.get("author", "agent")), + title=d.get("title"), + status=NoteStatus(d.get("status", "confirmed")), + confidence=Confidence(conf) if conf else None, + strains=list(d.get("strains") or []), + embryos=list(d.get("embryos") or []), + sessions=list(d.get("sessions") or []), + threads=list(d.get("threads") or []), + basis=list(d.get("basis") or []), + links=list(d.get("links") or []), + artifacts=list(d.get("artifacts") or []), + superseded_by=d.get("superseded_by"), + created_at=datetime.fromisoformat(d["created_at"]), + updated_at=datetime.fromisoformat(d["updated_at"]), + ) + + +def observation_to_note(obs: Observation) -> Note: + """Bridge a legacy Observation into a notebook Note (kind=observation).""" + return Note( + id=obs.id, + kind=NoteKind.OBSERVATION, + body=obs.content, + author=Author.AGENT, + embryos=[obs.embryo_id] if obs.embryo_id else [], + sessions=[obs.session_id] if obs.session_id else [], + links=[{"rel": "relates_to", "to": r} for r in (obs.relates_to or [])], + artifacts=[obs.gently_refs] if obs.gently_refs else [], + created_at=obs.timestamp, + updated_at=obs.timestamp, + ) + + +def learning_to_note(learning: Learning) -> Note: + """Bridge a legacy Learning into a notebook Note (kind=finding, proposed).""" + return Note( + id=learning.id, + kind=NoteKind.FINDING, + body=learning.content, + author=Author.AGENT, + status=NoteStatus.PROPOSED, + confidence=learning.confidence, + created_at=learning.created_at, + updated_at=learning.created_at, + ) + + +class NotebookStore: + """File-backed store for notebook Notes. One YAML per note under notes/; + flat pool, rebuildable reverse-indexes (added in Task 3).""" + + _FACETS = {"strain": "strains", "embryo": "embryos", "thread": "threads"} + + def __init__(self, notebook_dir: Path): + self.root = Path(notebook_dir) + self.notes_dir = self.root / "notes" + self.index_dir = self.root / "index" + self.notes_dir.mkdir(parents=True, exist_ok=True) + self.index_dir.mkdir(parents=True, exist_ok=True) + # reverse indexes: facet -> {value: [note_id, ...]} + self._index: dict[str, dict[str, list[str]]] = {"strain": {}, "embryo": {}, "thread": {}} + self.rebuild_index() + + # ---- reverse indexes (notes are authoritative; indexes are caches) ---- + def _index_note(self, note: Note) -> None: + for facet, attr in self._FACETS.items(): + for value in getattr(note, attr): + bucket = self._index[facet].setdefault(value, []) + if note.id not in bucket: + bucket.append(note.id) + + def rebuild_index(self) -> None: + """Rebuild reverse-indexes by scanning notes/.""" + self._index = {"strain": {}, "embryo": {}, "thread": {}} + for f in sorted(self.notes_dir.glob("*.yaml")): + data = self._read_yaml(f) + if data: + self._index_note(note_from_dict(data)) + + def ids_for_strain(self, strain: str) -> list[str]: + return list(self._index["strain"].get(strain, [])) + + def ids_for_embryo(self, embryo: str) -> list[str]: + return list(self._index["embryo"].get(embryo, [])) + + def ids_for_thread(self, thread: str) -> list[str]: + return list(self._index["thread"].get(thread, [])) + + # ---- helpers (mirror FileContextStore conventions) ---- + @staticmethod + def _gen_id() -> str: + return str(uuid.uuid4())[:8] + + @staticmethod + def _slugify(text: str) -> str: + slug = re.sub(r"[^a-z0-9]+", "-", (text or "").lower()).strip("-") + return slug[:30] + + def _write_yaml(self, path: Path, data: dict) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_suffix(".tmp") + with open(tmp, "w", encoding="utf-8") as fh: + yaml.safe_dump(data, fh, default_flow_style=False, allow_unicode=True, sort_keys=False) + os.replace(str(tmp), str(path)) + + def _read_yaml(self, path: Path) -> dict | None: + try: + with open(path, encoding="utf-8") as fh: + return yaml.safe_load(fh) + except OSError: + return None + + def _note_path(self, note_id: str) -> Path | None: + return next(self.notes_dir.glob(f"{note_id}_*.yaml"), None) + + # ---- read/write ---- + def write_note(self, note: Note) -> str: + if not note.id: + note.id = self._gen_id() + note.updated_at = datetime.now() + slug = self._slugify(note.title or note.body or note.kind.value) + # remove any stale file for this id (slug may have changed) + old = self._note_path(note.id) + if old is not None: + old.unlink() + self._write_yaml(self.notes_dir / f"{note.id}_{slug}.yaml", note_to_dict(note)) + self._index_note(note) + return note.id + + def get_note(self, note_id: str) -> Note | None: + path = self._note_path(note_id) + if path is None: + return None + data = self._read_yaml(path) + return note_from_dict(data) if data else None + + def query_notes( + self, + *, + kind: NoteKind | None = None, + author: Author | None = None, + status: NoteStatus | None = None, + strain: str | None = None, + embryo: str | None = None, + thread: str | None = None, + ) -> list[Note]: + """Structural query: narrow by scope via the indexes, then filter by + kind/author/status. Returned newest-first. (No semantic ranking here — + that's a later increment.)""" + # 1. candidate ids — intersect any scope facets given, else all notes + scope_sets: list[set[str]] = [] + if strain is not None: + scope_sets.append(set(self.ids_for_strain(strain))) + if embryo is not None: + scope_sets.append(set(self.ids_for_embryo(embryo))) + if thread is not None: + scope_sets.append(set(self.ids_for_thread(thread))) + candidate_ids: set[str] | None = set.intersection(*scope_sets) if scope_sets else None + + # 2. load + filter + if candidate_ids is not None: + notes = [n for n in (self.get_note(i) for i in candidate_ids) if n] + else: + notes = [ + note_from_dict(d) + for d in (self._read_yaml(f) for f in self.notes_dir.glob("*.yaml")) + if d + ] + results: list[Note] = [] + for n in notes: + if kind is not None and n.kind != kind: + continue + if author is not None and n.author != author: + continue + if status is not None and n.status != status: + continue + results.append(n) + results.sort(key=lambda n: n.created_at, reverse=True) + return results + + # ---- links + supersession (append-only history) ---- + def link_notes(self, from_id: str, rel: str, to_id: str) -> None: + """Add a typed edge from one note to another (append-only).""" + note = self.get_note(from_id) + if note is None: + raise KeyError(from_id) + edge = {"rel": rel, "to": to_id} + if edge not in note.links: + note.links.append(edge) + self.write_note(note) + + def supersede_note(self, old_id: str, new_id: str) -> None: + """Mark old as superseded (kept, never deleted) and link the new note + back to it as a refinement — the chain is the intellectual history.""" + old = self.get_note(old_id) + if old is None: + raise KeyError(old_id) + old.status = NoteStatus.SUPERSEDED + old.superseded_by = new_id + self.write_note(old) + self.link_notes(new_id, "refines", old_id) diff --git a/gently/harness/memory/notebook_ask.py b/gently/harness/memory/notebook_ask.py new file mode 100644 index 00000000..e8f1228c --- /dev/null +++ b/gently/harness/memory/notebook_ask.py @@ -0,0 +1,112 @@ +"""Ask the notebook — retrieve relevant Notes and synthesize a grounded, +cited answer with Claude. Structural retrieval only (semantic recall is a +later increment). See docs/superpowers/specs/2026-06-16-shared-lab-notebook-design.md §4. +""" + +from __future__ import annotations + +from .notebook import Note, NotebookStore + + +def select_notes( + store: NotebookStore, + *, + thread: str | None = None, + strain: str | None = None, + limit: int = 12, +) -> list[Note]: + """Structural narrowing: scope by thread/strain when given, else recent. + Returns newest-first, capped at ``limit``.""" + notes = store.query_notes(thread=thread, strain=strain) + return notes[:limit] + + +# ASK_TOOL pins the structured output. No confidence field — we don't ask the +# model to self-rate (lab rule); "coverage" is a factual grounding classification. +ASK_TOOL = { + "name": "answer_from_notebook", + "description": "Return a grounded answer built ONLY from the provided notebook entries.", + "input_schema": { + "type": "object", + "properties": { + "answer": {"type": "string", "description": "Direct synthesis grounded in the notes."}, + "points": { + "type": "array", + "description": "Supporting points, each citing the note ids it rests on.", + "items": { + "type": "object", + "properties": { + "text": {"type": "string"}, + "note_ids": {"type": "array", "items": {"type": "string"}}, + }, + "required": ["text", "note_ids"], + }, + }, + "suggested_next": { + "type": "array", + "items": {"type": "string"}, + "description": ( + "Concrete next experiments/moves if the question asks what to do; else empty." + ), + }, + "coverage": { + "type": "string", + "enum": ["covered", "partial", "not_in_notebook"], + "description": "How well the provided notes cover the question.", + }, + }, + "required": ["answer", "points", "coverage"], + }, +} + +_SYSTEM = ( + "You reason over a shared lab notebook. Answer ONLY from the notebook entries " + "provided — every claim must cite the note id(s) it rests on. If the notes do " + "not contain the answer, say so plainly and set coverage to 'not_in_notebook'. " + "Never invent facts not in the notes. Call the answer_from_notebook tool." +) + + +def _render_notes(notes: list[Note]) -> str: + lines = [] + for n in notes: + scope = [] + if n.strains: + scope.append("strains=" + ",".join(n.strains)) + if n.embryos: + scope.append("embryos=" + ",".join(n.embryos)) + tag = f" [{'; '.join(scope)}]" if scope else "" + lines.append(f"[{n.id}] ({n.kind.value}){tag} {n.body}") + return "\n".join(lines) + + +def build_ask_messages(question: str, notes: list[Note]) -> list[dict]: + body = ( + "Notebook entries:\n" + _render_notes(notes) + f"\n\nQuestion: {question}\n\n" + "Answer using only these entries, citing note ids." + ) + return [{"role": "user", "content": body}] + + +async def answer_question(client, model: str, question: str, notes: list[Note]) -> dict: + """Force the ask tool and return its validated input dict. Short-circuits + (no API call) when there are no notes to ground on.""" + if not notes: + return { + "answer": "The notebook doesn't cover this yet.", + "points": [], + "suggested_next": [], + "coverage": "not_in_notebook", + } + resp = await client.messages.create( + model=model, + max_tokens=2048, + system=_SYSTEM, + tools=[ASK_TOOL], + tool_choice={"type": "tool", "name": ASK_TOOL["name"]}, + messages=build_ask_messages(question, notes), + ) + for block in resp.content: + if getattr(block, "type", None) == "tool_use": + return block.input + return {"answer": "", "points": [], "suggested_next": [], "coverage": "not_in_notebook"} diff --git a/gently/harness/plan_mode/prompt.py b/gently/harness/plan_mode/prompt.py index 4228249c..055d0df2 100644 --- a/gently/harness/plan_mode/prompt.py +++ b/gently/harness/plan_mode/prompt.py @@ -21,8 +21,17 @@ 6. Challenge assumptions — suggest controls the researcher might not have thought of 7. Suggest experiments outside of imaging where appropriate (bench assays, genetics, analysis) -DO NOT rush to a plan. Gather information first. Ask questions. Search the literature. -Understand the researcher's goals and constraints before proposing. +Work INFERENCE-FIRST: arrive with a draft, don't interrogate. Infer what you +reasonably can — read the reporters in the strain's genotype and set the +excitation wavelengths from your knowledge of fluorophore spectra (e.g. +TagRFP/mCherry ≈ 561 nm, GFP/GCaMP ≈ 488 nm), let the organism set sensible +defaults, and let lab/campaign context fill the rest. Record each inferred +value's source and confidence in the imaging spec's ``provenance``. State a +wavelength only when you're confident; if a reporter is unfamiliar or ambiguous, +mark it low-confidence and confirm via ask_user_choice rather than guessing a +number. Then surface the draft for review, asking ONLY for genuine gaps, +low-confidence guesses, or consequential choices. Search the literature to +confirm, not to stall. ## How to Design an Experimental Plan @@ -67,9 +76,38 @@ 3. Set dependencies between items 4. Present the full plan for review with propose_plan +After propose_plan, close with a short confirmation of what the plan contains +(item/phase count, the critical path, anything notable) and stop there. Do NOT +offer to export it, save it as a template, or ask "what would you like to do +next?" — exporting and opening the workspace are handled by the interface, not +this conversation. End on the summary, not an upsell. + IMPORTANT: ALWAYS use ask_user_choice when asking the researcher questions. Never present options as text lists. +## Communication style — keep it light to read + +You're talking to a working biologist, not a software user. Optimize every +user-facing message for fast reading, not completeness: + +- **Lead with the ask or the finding.** The first sentence should be the question, + the decision, or what you found — supporting detail comes after, and only when it + changes what they'd do next. +- **Short questions, short options.** Keep an ask_user_choice question to one line, + and each option to a few-word label plus at most a one-line rationale — never a + paragraph. Trust the biologist to know the domain; don't re-explain standard + concepts (what a histone marker is, why controls matter). +- **Plain words, not process jargon.** Use the field's real terms (strain names, + stages, wavelengths) but drop software/workflow jargon and hedging. +- **Give the short "why", not the survey.** One clause of rationale beats an + exhaustive list of everything you weighed. Put the full reasoning in the spec's + provenance and references, not in the message. +- **One idea per message.** Don't stack caveats, alternatives, and next steps into + one dense block. If something is optional, say so briefly or leave it out. + +Readability and brevity are different — choose readability, but get there by +saying less, not by compressing into fragments or abbreviations. + ## Reading Papers Use read_paper to retrieve and read scientific papers. It accepts: @@ -115,8 +153,12 @@ PLAN_MODE_GUIDELINES = """\ # Behavior in Plan Mode -1. **Ask before assuming**: Don't assume the researcher's constraints. Ask about - available strains, timeline, equipment access, collaborators. +1. **Infer, then confirm — don't interrogate**: Fill what you can from the strain + genotype, organism defaults, and lab/campaign context, and record where each + value came from (database citation, or your own fluorophore/biology knowledge) + in the spec's ``provenance``. Ask — via ask_user_choice — only for genuine + gaps, low-confidence guesses, or consequential choices, not for things you can + derive or look up. 2. **Think about the full story**: What would reviewers want to see? What controls would strengthen the claims? 3. **Be realistic about timelines**: Genetic crosses take weeks. Behavioral assays @@ -140,6 +182,14 @@ items, search to confirm strain availability, check the literature for recent protocols, and attach references. Your built-in knowledge is a great starting point for brainstorming — the databases are where you confirm before finalizing. +11. **Batch independent lookups**: When you need several independent reads — multiple + strains, several papers, or a few lab-history queries — request them together in + one turn so they run in parallel. Don't fetch one, wait for it, then fetch the + next; that's slow. (The system runs same-turn read-only lookups concurrently.) +12. **Build the plan in few turns**: Each turn is a model round-trip, so creating one + item per turn makes plan construction crawl. When writing a phase's items, emit + several create_plan_item calls in a single turn (then set any dependencies in a + follow-up). Fewer turns = a much faster plan. """ diff --git a/gently/harness/plan_mode/tools/planning.py b/gently/harness/plan_mode/tools/planning.py index 34785e5f..87715acd 100644 --- a/gently/harness/plan_mode/tools/planning.py +++ b/gently/harness/plan_mode/tools/planning.py @@ -7,9 +7,34 @@ """ import dataclasses +import json from ...tools.registry import ToolCategory, ToolExample, tool + +def _coerce_plan_args(spec, references, estimated_days): + """The model often serializes nested args (spec/references) as JSON strings + instead of objects — accept either so plan-item creation doesn't store a raw + string (which later breaks ImagingSpec/BenchSpec hydration). Returns the + normalized (spec, references, estimated_days).""" + if isinstance(spec, str): + try: + spec = json.loads(spec) + except (json.JSONDecodeError, TypeError): + spec = None + if isinstance(references, str): + try: + references = json.loads(references) + except (json.JSONDecodeError, TypeError): + references = None + if isinstance(estimated_days, str): + try: + estimated_days = int(estimated_days) + except (ValueError, TypeError): + estimated_days = None + return spec, references, estimated_days + + # --------------------------------------------------------------------------- # Campaign / Phase Management # --------------------------------------------------------------------------- @@ -132,6 +157,17 @@ async def create_plan_item( return "Error: Context store not available" store = agent.context_store + spec, references, estimated_days = _coerce_plan_args(spec, references, estimated_days) + if isinstance(phase_number, str): + try: + phase_number = int(phase_number) + except (ValueError, TypeError): + phase_number = None + if isinstance(phase_order, str): + try: + phase_order = int(phase_order) + except (ValueError, TypeError): + phase_order = -1 # Resolve phase_number → subcampaign ID target_campaign_id = campaign_id @@ -226,6 +262,7 @@ async def update_plan_item( from gently.harness.memory.model import PlanItemStatus status_enum = PlanItemStatus(status) if status else None + spec, references, estimated_days = _coerce_plan_args(spec, references, estimated_days) store.update_plan_item( item_id=resolved_id, status=status_enum, diff --git a/gently/harness/prompts/templates.py b/gently/harness/prompts/templates.py index f75fc7a5..06b7cbad 100644 --- a/gently/harness/prompts/templates.py +++ b/gently/harness/prompts/templates.py @@ -222,6 +222,104 @@ """ +OPERATION_PLAN_GUIDANCE = """ +## Operation Plan — keep it current + +At experiment planning time, call `declare_operation_plan` with every tactic +you intend to run. Each tactic needs at minimum: `id` (short stable string), +`name`, `kind`, `state` (start as `"planned"`), `scope`, and `rationale`. +For richer display, populate a `live` object on the tactic: + +- `readouts` — list of `{label, value}` dicts for the instrument strip + (e.g. `{label: "cadence", value: "120 s"}`). +- `phases` — list of `{name, state, count, pips}` for scripted/phased tactics. +- Flat bound keys (`request_id`, `sustained_hz`, `setpoint`, `locked`, + `last_fired`, `new_phase`, …) are merged in by the updater as live telemetry + arrives; you can seed them at declaration time if the value is already known. + +### Allowed values — use these exact strings (renderer dispatches on them) + +**`kind`** ∈ one of: +| value | use when | +|---|---| +| `standing_timelapse` | continuous / periodic imaging running throughout | +| `reactive_monitor` | armed watcher that fires on a condition (signal, threshold) | +| `scripted_protocol` | fixed sequence of named phases (ramp, hold, recovery, …) | +| `exclusive_burst` | short high-cadence burst that blocks other acquisition | +| `oneshot` | single action (z-stack, snapshot, one-off step) | +| `custom` | anything that doesn't fit the above | + +**`state`** (tactic) ∈ `planned | active | done | paused` +Start every tactic as `"planned"`; advance to `"active"` when it begins, +`"done"` when it finishes, `"paused"` if suspended. + +**`scope`** — always an object with a `mode` key (never a bare list or string): +- `{"mode": "global"}` — applies to every embryo in the session +- `{"mode": "embryos", "embryo_ids": ["E01", "E02"]}` — specific embryo IDs +- `{"mode": "role", "role": "test"}` — all embryos carrying the named role + +**`live.phases[].state`** ∈ `todo | active | done` + +### Minimal tactic example + +```json +{ + "id": "t2", + "name": "Temperature ramp", + "kind": "scripted_protocol", + "state": "planned", + "scope": {"mode": "embryos", "embryo_ids": ["E01", "E02"]}, + "rationale": "25 → 16 °C step to trigger stress response", + "live": { + "readouts": [{"label": "setpoint", "value": "25 °C"}], + "phases": [ + {"name": "ramp", "state": "todo", "count": 0, "pips": []}, + {"name": "hold", "state": "todo", "count": 0, "pips": []} + ] + } +} +``` + +### Plan the whole roster — subjects AND references + +Experiments often require both subjects and references. Declare tactics for all +roster classes in the same `declare_operation_plan` call. + +| Roster class | Role value(s) | Planning note | +|---|---|---| +| Subjects | `test` | adaptive protocol + reactive tactics apply | +| References | `calibration`, `lineaging` | steady acquisition only; no adaptive protocol | + +**Reference tactic**: when the assay needs calibration or stage-clock embryos, declare +a `standing_timelapse` scoped to that role alongside the subject tactics: + +```json +{ + "id": "ref_acq", + "name": "Reference steady acquisition", + "kind": "standing_timelapse", + "state": "planned", + "scope": {"mode": "role", "role": "calibration"}, + "rationale": "Steady 5-min imaging of calibration embryos — stage timing + normalization" +} +``` + +**Surface role requirements** in the plan `goal` or a tactic `rationale`: state which +roles the run needs and roughly how many (e.g. *"needs ≥2 test subjects + ≥1 calibration +reference"*) so the operator knows what embryos to assign before the run begins. Never +plan only for `test` when the assay depends on reference embryos. + +Valid `scope.role` strings: `test` / `calibration` / `lineaging` / `unassigned`. + +Re-call `declare_operation_plan` (patch) whenever a tactic's state changes: +`"planned"` → `"active"` when you start it, `"active"` → `"done"` when it +finishes. This keeps the Operations view in the UI synchronized with reality. +Execution tools (`queue_burst`, `enable_monitoring_mode`, `stop_timelapse`, +`pause_timelapse`) also accept an optional `tactic_id` and flip the state +automatically — pass it when a tool maps cleanly to one tactic. +""" + + ADAPTIVE_TIMELAPSE = """ # Adaptive Timelapse System @@ -483,6 +581,8 @@ def build_system_prompt( {REACTIVE_MONITORING_MODES} +{OPERATION_PLAN_GUIDANCE} + {AUTONOMY_AND_ADAPTATION} {USER_INTERACTION_GUIDELINES} diff --git a/gently/harness/roles.py b/gently/harness/roles.py index beaa59a4..68bee551 100644 --- a/gently/harness/roles.py +++ b/gently/harness/roles.py @@ -15,8 +15,16 @@ - ``test``: the biological subject (precious sample). Custom ad-hoc detector. - ``calibration``: reference embryo used for staging/calibration. Absorbs more photodose. Standard perception pipeline. +- ``lineaging``: lineage-tracing reference — tracks nuclei/divisions. Often + a pan-nuclear strain, but the strain is separate from this use. - ``unassigned``: explicit "not yet classified" state. Treated like ``test`` for safety until the user resolves the assignment. + +Role classes +------------ +``role_class`` distinguishes how Operations foregrounds embryos: +- ``"subject"`` — the primary biological subjects of the experiment. +- ``"reference"`` — reference embryos (staging, calibration, lineaging). """ from dataclasses import dataclass @@ -44,6 +52,9 @@ class EmbryoRole: # a short threshold. Test embryos can occasionally pop out and # back, so they get a longer one. no_object_consecutive_terminal: int | None = None + # 'subject' | 'reference' — used by Operations to foreground subjects + # vs references in multi-embryo layouts and scheduling decisions. + role_class: str = "subject" REGISTRY: dict[str, EmbryoRole] = { @@ -56,6 +67,7 @@ class EmbryoRole: ui_color="#888888", ui_icon="circle", no_object_consecutive_terminal=None, + role_class="subject", # safe default: protect like a subject ), "test": EmbryoRole( name="test", @@ -71,6 +83,7 @@ class EmbryoRole: ui_color="#ff66cc", # magenta ui_icon="star", no_object_consecutive_terminal=5, # forgiving — they might drift back + role_class="subject", ), "calibration": EmbryoRole( name="calibration", @@ -85,6 +98,21 @@ class EmbryoRole: ui_color="#00cccc", # cyan ui_icon="diamond", no_object_consecutive_terminal=2, # they don't drift back; gone == gone + role_class="reference", + ), + "lineaging": EmbryoRole( + name="lineaging", + description=( + "Lineage-tracing reference — tracks nuclei/divisions; often a " + "pan-nuclear strain but the strain is separate from this use." + ), + default_cadence_seconds=300.0, + detector_name="perception", # nuclear pipeline, same as calibration + photodose_budget_multiplier=5.0, + ui_color="#33cc88", # teal-green — distinct from cyan (calibration) and magenta (test) + ui_icon="triangle", + no_object_consecutive_terminal=2, # reference embryos don't drift back + role_class="reference", ), } diff --git a/gently/harness/session/timeline.py b/gently/harness/session/timeline.py index 4df09e00..5bb7fc97 100644 --- a/gently/harness/session/timeline.py +++ b/gently/harness/session/timeline.py @@ -228,6 +228,24 @@ def description(self) -> str: "icon": "v", "severity": "success", }, + EventType.TEMPERATURE_SETPOINT_CHANGED: { + "event_type": "temperature", + "event_subtype": "setpoint_changed", + "icon": "T", + "severity": "info", + }, + EventType.TEMP_PROTOCOL_STARTED: { + "event_type": "tactic", + "event_subtype": "temp_protocol_started", + "icon": "~", + "severity": "info", + }, + EventType.TEMP_PROTOCOL_COMPLETED: { + "event_type": "tactic", + "event_subtype": "temp_protocol_completed", + "icon": "+", + "severity": "success", + }, } diff --git a/gently/harness/state.py b/gently/harness/state.py index 34b7d8f6..5e2af6c4 100644 --- a/gently/harness/state.py +++ b/gently/harness/state.py @@ -136,6 +136,9 @@ class EmbryoState: # is the safe choice — accidental Calibration→Test only over-protects; # accidental Test→Calibration would burn extra dose on the precious sample. role: str = "test" + # Free-form biological sample descriptor (orthogonal to role). Examples: + # "pan-nuclear GFP", "H2B-mCherry", "wild-type". None = unspecified. + strain: str | None = None # Position — two-stage: coarse (bottom-camera detection or manual map # placement, always present once an embryo exists) and fine (populated diff --git a/gently/harness/tools/registry.py b/gently/harness/tools/registry.py index 88475b42..040487aa 100644 --- a/gently/harness/tools/registry.py +++ b/gently/harness/tools/registry.py @@ -164,6 +164,55 @@ def _python_type_to_json_schema(python_type) -> str: return "string" +def _unwrap_optional(tp: Any) -> Any: + """Reduce ``float | None`` / ``Optional[int]`` to the underlying scalar type. + + Returns the single non-None member of a union, or the type unchanged for a + plain annotation. Returns None when there's no unambiguous scalar (so callers + skip coercion). + """ + args = get_args(tp) + if args: + non_none = [a for a in args if a is not type(None)] + return non_none[0] if len(non_none) == 1 else None + return tp + + +def _coerce_kwargs(handler: Callable, kwargs: dict) -> dict: + """Best-effort coercion of string tool args to their annotated scalar types. + + Tool inputs arrive as JSON from the model (and sometimes as UI form strings), + so a param annotated ``float``/``int`` can show up as e.g. ``"120"``. Without + this, a downstream numeric comparison raises + ``'<' not supported between instances of 'str' and 'int'``. Coercion is + conservative: only string values whose annotation resolves to int/float/bool + are touched; anything that fails to parse is left as-is for the tool to report. + """ + try: + hints = get_type_hints(handler) + except Exception: + return kwargs + for name, value in list(kwargs.items()): + if name == "context" or not isinstance(value, str): + continue + target = _unwrap_optional(hints.get(name)) + if target in (int, float): + s = value.strip() + if not s: + continue + try: + kwargs[name] = target(s) + except (ValueError, TypeError): + pass + elif target is bool: + low = value.strip().lower() + if low in ("true", "1", "yes", "on"): + kwargs[name] = True + elif low in ("false", "0", "no", "off"): + kwargs[name] = False + return kwargs + + def _extract_parameters_from_function(func: Callable) -> list[ToolParameter]: """Extract parameter definitions from function signature and type hints""" sig = inspect.signature(func) @@ -461,6 +510,11 @@ async def execute(self, tool_name: str, tool_input: dict, context: dict | None = # Prepare arguments kwargs = dict(tool_input) + # Coerce string args to their annotated scalar types. JSON/UI inputs + # can deliver e.g. new_interval_seconds="120", which would otherwise + # crash on a numeric comparison inside the tool. + kwargs = _coerce_kwargs(tool.handler, kwargs) + # Inject context if handler expects it (but don't overwrite if already provided) sig = inspect.signature(tool.handler) if "context" in sig.parameters and "context" not in kwargs: diff --git a/gently/log_config.py b/gently/log_config.py index 95eb5e34..9adf93f1 100644 --- a/gently/log_config.py +++ b/gently/log_config.py @@ -69,6 +69,15 @@ def configure_logging( ): logging.getLogger(name).setLevel(logging.WARNING) + # The `websockets` library logs a full "data transfer failed" traceback at + # ERROR level every time a client disconnects ungracefully (e.g. a browser + # tab sleeping or dropping — Windows raises WinError 121, "semaphore timeout"). + # These are routine, not faults, and flood the console hundreds of lines deep, + # burying real errors. Suppress below CRITICAL so a genuinely fatal WS fault + # still surfaces. WARNING is not enough here because the noise is ERROR-level. + for name in ("websockets", "websockets.server", "websockets.client"): + logging.getLogger(name).setLevel(logging.CRITICAL) + # File handler — always INFO+ regardless of console level if log_file: file_fmt = os.environ.get("GENTLY_LOG_FILE_FORMAT", _DEFAULT_FILE_FORMAT) diff --git a/gently/settings.py b/gently/settings.py index 68cebd38..7314b135 100644 --- a/gently/settings.py +++ b/gently/settings.py @@ -27,6 +27,30 @@ def _env(key: str, default): return val +def _load_local_overrides(): + """Merge config/settings.local.yml (a flat map of GENTLY_* keys) into the + environment BEFORE settings are resolved. setdefault so a real env var still + wins over the file. This is how the Settings panel's restart-required editors + persist overrides — every entry point (viz, device layer, agent) picks them + up at import.""" + try: + import yaml + + path = Path(__file__).resolve().parents[1] / "config" / "settings.local.yml" + if not path.exists(): + return + data = yaml.safe_load(path.read_text()) or {} + if isinstance(data, dict): + for k, v in data.items(): + if v is not None: + os.environ.setdefault(str(k), str(v)) + except Exception: + pass + + +_load_local_overrides() + + @dataclass(frozen=True) class NetworkSettings: """Ports, hosts, and bind addresses.""" @@ -56,14 +80,38 @@ class MeshSettings: @dataclass(frozen=True) class ModelSettings: - """Claude model identifiers.""" - - main: str = field(default_factory=lambda: _env("MODEL_MAIN", "claude-opus-4-6")) - perception: str = field( - default_factory=lambda: _env("MODEL_PERCEPTION", "claude-opus-4-5-20251101") + """Claude model identifiers — the single source of truth for every tier. + + Tiers are split by role; capability-first per the latest models: + - main: Opus 4.8 ($5/$25). Per-user-turn reasoning + tool + orchestration (plan mode) and the dopaminergic classifier + stage. (Fable 5 was tried here but declined benign planning + turns — stop_reason="refusal" — forcing a fallback on every + turn; set MODEL_MAIN=claude-fable-5 to retry it.) + - perception: Opus 4.8 (high-res vision, $5/$25). Highest-frequency tier + (per timepoint); Opus-tier vision for perception accuracy. + - medium: Opus 4.8. Onboarding / wizard summaries. + - fast: Sonnet 4.6 ($3/$15). The cheaper/faster tier — drives the + verifier's parallel ensemble (ensemble_size calls per + verification) and blank-image / summary checks. + + API note: Opus 4.8 rejects thinking budget_tokens and sampling params + (temperature/top_p/top_k) — adaptive thinking only, depth via effort. + Sonnet 4.6 supports adaptive thinking. No assistant prefills anywhere + (4.6+ family rejects them). + """ + + main: str = field(default_factory=lambda: _env("MODEL_MAIN", "claude-opus-4-8")) + perception: str = field(default_factory=lambda: _env("MODEL_PERCEPTION", "claude-opus-4-8")) + fast: str = field(default_factory=lambda: _env("MODEL_FAST", "claude-sonnet-4-6")) + medium: str = field(default_factory=lambda: _env("MODEL_MEDIUM", "claude-opus-4-8")) + # If the main tier declines a turn (stop_reason="refusal"), retry it on this + # model instead of surfacing the refusal. Inert while main is Opus 4.8 (the + # guard skips it when fallback == main); relevant if main is set to Fable 5. + # Empty disables the fallback. + refusal_fallback: str = field( + default_factory=lambda: _env("MODEL_REFUSAL_FALLBACK", "claude-opus-4-8") ) - fast: str = field(default_factory=lambda: _env("MODEL_FAST", "claude-haiku-4-5-20251001")) - medium: str = field(default_factory=lambda: _env("MODEL_MEDIUM", "claude-sonnet-4-5-20250929")) @dataclass(frozen=True) @@ -86,7 +134,6 @@ class TimeoutSettings: """Timeout values in seconds.""" plan_execution: int = field(default_factory=lambda: _env("TIMEOUT_PLAN", 300)) - rpc_call: int = field(default_factory=lambda: _env("TIMEOUT_RPC", 60)) volume_acquisition: int = field(default_factory=lambda: _env("TIMEOUT_VOLUME", 15)) api_call: int = field(default_factory=lambda: _env("TIMEOUT_API", 10)) @@ -120,6 +167,17 @@ class TransferSettings: ) +@dataclass(frozen=True) +class UISettings: + """Web UI feature flags.""" + + # New agent-first UX paradigm (welcome→shell unfold, dual-rendered agent + # asks, inference-first plan mode, shared-visibility surface). Now ON by + # default; the v1 dashboard remains available as a fallback via + # GENTLY_UX_V2=0 until the v1 markup is removed in a later cleanup step. + ux_v2: bool = field(default_factory=lambda: _env("UX_V2", True)) + + @dataclass(frozen=True) class Settings: """Top-level settings container.""" @@ -132,6 +190,7 @@ class Settings: api: ApiSettings = field(default_factory=ApiSettings) ml: MlSettings = field(default_factory=MlSettings) transfer: TransferSettings = field(default_factory=TransferSettings) + ui: UISettings = field(default_factory=UISettings) # Singleton — import this everywhere diff --git a/gently/ui/web/connection_manager.py b/gently/ui/web/connection_manager.py index 11cc3fe4..1d1f2a23 100644 --- a/gently/ui/web/connection_manager.py +++ b/gently/ui/web/connection_manager.py @@ -158,7 +158,12 @@ async def broadcast(self, message: dict): try: await connection.send_text(message_json) except Exception as e: - logger.warning(f"Failed to send to websocket: {e}") + # Expected when a client disconnects/reloads mid-broadcast + # (send after websocket.close). The connection is dropped + # below, so this is debug-level, not a warning. + logger.debug( + "Dropping a websocket that errored on send (client likely gone): %s", e + ) disconnected.append(connection) # Remove disconnected clients diff --git a/gently/ui/web/routes/__init__.py b/gently/ui/web/routes/__init__.py index ebd90770..a881a0bb 100644 --- a/gently/ui/web/routes/__init__.py +++ b/gently/ui/web/routes/__init__.py @@ -10,11 +10,17 @@ from .auth_routes import create_router as create_auth_router from .campaigns import create_router as create_campaigns_router from .chat import create_router as create_chat_router +from .context import create_router as create_context_router from .data import create_router as create_data_router from .experiments import create_router as create_experiments_router from .images import create_router as create_images_router +from .notebook import create_router as create_notebook_router +from .operation_plan import create_router as create_operation_plan_router from .pages import create_router as create_pages_router +from .roles import create_router as create_roles_router from .sessions import create_router as create_sessions_router +from .tactic_library import create_router as create_tactic_library_router +from .temperature import create_router as create_temperature_router from .volumes import create_router as create_volumes_router from .websocket import create_router as create_websocket_router @@ -33,6 +39,12 @@ def register_all_routes(server): create_websocket_router, create_agent_ws_router, create_chat_router, + create_context_router, + create_notebook_router, + create_temperature_router, + create_operation_plan_router, + create_roles_router, + create_tactic_library_router, ): router = factory(server) server.app.include_router(router) diff --git a/gently/ui/web/routes/agent_ws.py b/gently/ui/web/routes/agent_ws.py index 82228c56..4c3a8ea7 100644 --- a/gently/ui/web/routes/agent_ws.py +++ b/gently/ui/web/routes/agent_ws.py @@ -14,6 +14,8 @@ from fastapi import APIRouter, WebSocket, WebSocketDisconnect +from gently.settings import settings + logger = logging.getLogger(__name__) @@ -744,9 +746,18 @@ async def _run_resolution_bootstrap(): pass if not wizard_ran: - if bridge.should_enter_resolution(): + enter_resolution = bridge.should_enter_resolution() + # Under ux_v2 the agent-first landing owns the session-entry + # decision ("Plan an experiment" / "Take a quick look"), so the + # legacy connect-time resolution picker would just duplicate it — + # and contradict it, by offering "Standalone" after the user has + # already chosen to plan. Stay quiet on connect for new sessions; + # the landing drives plan-mode (/plan) or standalone instead. + if enter_resolution and not settings.ui.ux_v2: bootstrap_task = asyncio.create_task(_run_resolution_bootstrap()) - else: + elif not enter_resolution: + # Resume / already-resolved sessions still get their briefing + # (it sits behind the landing overlay until dismissed). briefing = bridge.get_session_briefing() if briefing: await send_fn({"type": "stream_start"}) diff --git a/gently/ui/web/routes/campaigns.py b/gently/ui/web/routes/campaigns.py index d3f2901b..7d5f6e62 100644 --- a/gently/ui/web/routes/campaigns.py +++ b/gently/ui/web/routes/campaigns.py @@ -244,8 +244,12 @@ async def get_item_detail(campaign_id: str, item_id: str): } ) - # Sessions linked to this campaign - sessions = cs.get_sessions_for_campaign(item.campaign_id) + # Sessions — return only those linked to this specific item (item.session_ids), + # not all campaign sessions. The frontend uses item.session_ids as the canonical + # list and this pool as metadata (name, created_at) for display. + item_sids = set(item.session_ids or []) + all_sessions = cs.get_sessions_for_campaign(item.campaign_id) + sessions = [s for s in all_sessions if s.session_id in item_sids] return { "item": _serialize(item), @@ -254,6 +258,43 @@ async def get_item_detail(campaign_id: str, item_id: str): "sessions": [_serialize(s) for s in sessions], } + @router.post("/api/campaigns/{campaign_id}/items/{item_id}/sessions") + async def link_session_to_item(campaign_id: str, item_id: str, request: Request): + """Link a session to a plan item (appends) and record it against the campaign.""" + cs = _get_store() + campaign = _resolve(cs, campaign_id) + item = cs.get_plan_item(item_id) + if not item: + raise HTTPException(status_code=404, detail="Plan item not found") + + body = await request.json() + session_id = body.get("session_id") + if not session_id: + raise HTTPException(status_code=400, detail="session_id required") + + cs.link_plan_item_session(item_id, session_id) + cs.link_session_campaign(session_id, campaign.id) + + # Re-fetch item so session_ids reflects the just-added link; then filter + # exactly as get_item_detail does — POST and GET return the same scope. + item = cs.get_plan_item(item_id) + item_sids = set(item.session_ids or []) + all_sessions = cs.get_sessions_for_campaign(item.campaign_id) + sessions = [s for s in all_sessions if s.session_id in item_sids] + return {"sessions": [_serialize(s) for s in sessions]} + + @router.delete("/api/campaigns/{campaign_id}/items/{item_id}/sessions/{session_id}") + async def unlink_session_from_item(campaign_id: str, item_id: str, session_id: str): + """Remove a session link from a plan item. Returns {unlinked: bool}.""" + cs = _get_store() + _resolve(cs, campaign_id) + item = cs.get_plan_item(item_id) + if not item: + raise HTTPException(status_code=404, detail="Plan item not found") + + unlinked = cs.unlink_plan_item_session(item_id, session_id) + return {"unlinked": unlinked} + @router.get("/api/campaigns/{campaign_id}/planned-sessions") async def get_planned_sessions(campaign_id: str): """Planned sessions linked to a campaign.""" @@ -321,6 +362,58 @@ async def _require(request: Request): return _require + @router.patch( + "/api/campaigns/{campaign_id}/items/{item_id}", + dependencies=[Depends(_make_campaign_auth("campaigns"))], + ) + async def update_item(campaign_id: str, item_id: str, request: Request): + """Edit plan-item fields and/or imaging-spec fields inline. + + Send only the fields you're changing. Spec edits are *merged* into the + existing spec, so the UI can PATCH a single field (e.g. laser_power_pct) + without losing the rest. An empty string clears a spec field to null. + Persists via update_plan_item, which fires PLAN_UPDATED for live refresh. + """ + cs = _get_store() + _resolve(cs, campaign_id) + item = cs.get_plan_item(item_id) + if not item: + raise HTTPException(status_code=404, detail="Plan item not found") + + body = await request.json() + if not isinstance(body, dict): + raise HTTPException(status_code=400, detail="Body must be a JSON object") + + kwargs: dict[str, Any] = {} + for f in ("title", "description", "outcome"): + if isinstance(body.get(f), str): + kwargs[f] = body[f] + if body.get("estimated_days") is not None: + kwargs["estimated_days"] = body["estimated_days"] + + if body.get("status"): + try: + kwargs["status"] = PlanItemStatus(body["status"]) + except ValueError as err: + raise HTTPException( + status_code=400, detail=f"Invalid status: {body['status']}" + ) from err + + spec_patch = body.get("spec") + if isinstance(spec_patch, dict): + current = item.imaging_spec or item.bench_spec + merged = asdict(current) if current else {} + for k, v in spec_patch.items(): + merged[k] = None if v == "" else v + kwargs["spec"] = merged + + if not kwargs: + raise HTTPException(status_code=400, detail="No editable fields supplied") + + cs.update_plan_item(item_id=item_id, **kwargs) # fires PLAN_UPDATED + updated = cs.get_plan_item(item_id) + return {"ok": True, "item": _serialize(updated)} + @router.post( "/api/campaigns/{campaign_id}/share", dependencies=[Depends(_make_campaign_auth("campaigns:admin"))], diff --git a/gently/ui/web/routes/chat.py b/gently/ui/web/routes/chat.py index 8c66dd45..e07d4501 100644 --- a/gently/ui/web/routes/chat.py +++ b/gently/ui/web/routes/chat.py @@ -19,11 +19,13 @@ from fastapi.responses import StreamingResponse from pydantic import BaseModel +from gently.settings import settings from gently.ui.web.auth import require_control logger = logging.getLogger(__name__) -CHAT_MODEL = "claude-opus-4-7" +# Per-timepoint VLM chat → perception tier (Opus 4.8); centralized, not hardcoded. +CHAT_MODEL = settings.models.perception SYSTEM_PROMPT = ( "You are helping a biologist interpret a microscopy perception " "assessment of a C. elegans embryo at a specific timepoint. You can " diff --git a/gently/ui/web/routes/context.py b/gently/ui/web/routes/context.py new file mode 100644 index 00000000..66c7f25c --- /dev/null +++ b/gently/ui/web/routes/context.py @@ -0,0 +1,74 @@ +"""Context (shared-visibility) routes. + +Exposes the agent's "mind" — its open questions (uncertainty), active +watchpoints (attention), and pending expectations (beliefs) — read by anyone, +resolvable only by the control holder. Live updates ride the CONTEXT_UPDATED +event the FileContextStore emits on the global bus, which the server already +broadcasts to /ws; the client just re-fetches /api/context on it (no polling). +""" + +from fastapi import APIRouter, Body, Depends + +from gently.ui.web.auth import require_control + +from .campaigns import _serialize + + +def create_router(server) -> APIRouter: + router = APIRouter() + + def _store(): + # Defensive: the store is wired after construction; tolerate cold start. + return getattr(server, "context_store", None) + + @router.get("/api/context") + async def get_context(): + cs = _store() + empty = {"available": False, "expectations": [], "watchpoints": [], "questions": []} + if cs is None: + return empty + try: + return { + "available": True, + "questions": [_serialize(q) for q in cs.get_open_questions()], + "watchpoints": [_serialize(w) for w in cs.get_active_watchpoints()], + "expectations": [_serialize(e) for e in cs.get_pending_expectations()], + } + except Exception: + return empty + + @router.post("/api/context/questions/{q_id}/resolve", dependencies=[Depends(require_control)]) + async def resolve_question(q_id: str, resolution: str = Body("", embed=True)): + cs = _store() + if cs is None: + return {"ok": False, "error": "context store unavailable"} + cs.resolve_question(q_id, resolution or "") + return {"ok": True} + + @router.post( + "/api/context/watchpoints/{wp_id}/resolve", dependencies=[Depends(require_control)] + ) + async def resolve_watchpoint(wp_id: str): + cs = _store() + if cs is None: + return {"ok": False, "error": "context store unavailable"} + cs.resolve_watchpoint(wp_id) + return {"ok": True} + + @router.post( + "/api/context/expectations/{exp_id}/resolve", dependencies=[Depends(require_control)] + ) + async def resolve_expectation(exp_id: str, status: str = Body("confirmed", embed=True)): + cs = _store() + if cs is None: + return {"ok": False, "error": "context store unavailable"} + from gently.harness.memory.model import ExpectationStatus + + try: + st = ExpectationStatus(status) + except ValueError: + st = ExpectationStatus.CONFIRMED + cs.resolve_expectation(exp_id, st) + return {"ok": True} + + return router diff --git a/gently/ui/web/routes/data.py b/gently/ui/web/routes/data.py index c4cee1b6..01d334ac 100644 --- a/gently/ui/web/routes/data.py +++ b/gently/ui/web/routes/data.py @@ -16,6 +16,30 @@ _HARDWARE_CONFIG_PATH = Path(__file__).resolve().parents[4] / "config" / "hardware.yaml" +def _json_safe(obj): + """Make an acquisition result JSON-encodable for FastAPI. + + ``client.acquire_volume``/``acquire_burst`` return the pixel data under + ``volume``/``image`` as numpy arrays (internal callers like the timelapse + orchestrator need them). FastAPI's ``jsonable_encoder`` can't serialize a + raw ndarray — it tries ``dict(arr)`` and blows up — and the web UI only + needs paths + metadata anyway. Replace arrays with a small shape/dtype + hint and coerce numpy scalars to native types; recurse so the burst + ``frames`` list is covered too. + """ + import numpy as np + + if isinstance(obj, np.ndarray): + return {"shape": list(obj.shape), "dtype": str(obj.dtype)} + if isinstance(obj, np.generic): + return obj.item() + if isinstance(obj, dict): + return {k: _json_safe(v) for k, v in obj.items()} + if isinstance(obj, (list, tuple)): + return [_json_safe(v) for v in obj] + return obj + + def create_router(server) -> APIRouter: router = APIRouter() @@ -214,6 +238,48 @@ async def get_coverslip(): } } + @router.get("/api/devices/scan_geometry") + async def get_scan_geometry(): + """Return the most recent scan geometry for the 3D optical-space view. + + SCAN_GEOMETRY_UPDATE is published only when a volume is acquired, so a + page opened before the first acquisition would have no cuboid to draw. + This serves the last emitted payload (stashed on the agent by + acquisition_tools._publish_scan_geometry), or nominal defaults so the + scene is never empty. + """ + bridge = getattr(server, "agent_bridge", None) + agent = bridge.agent if bridge is not None else None + last = getattr(agent, "last_scan_geometry", None) if agent else None + if isinstance(last, dict): + return last + # Nominal defaults (calibration defaults; no acquisition yet). + num_slices = 50 + piezo_amplitude = 25.0 + piezo_center = 50.0 + z_extent = 2.0 * piezo_amplitude + return { + "embryo_id": None, + "stage_position_um": {"x": None, "y": None}, + "scan": { + "num_slices": num_slices, + "exposure_ms": 10.0, + "galvo_amplitude_deg": 0.5, + "galvo_center_deg": 0.0, + "piezo_amplitude_um": piezo_amplitude, + "piezo_center_um": piezo_center, + }, + "derived": { + "z_extent_um": z_extent, + "slice_spacing_um": z_extent / (num_slices - 1), + "z_min_um": piezo_center - piezo_amplitude, + "z_max_um": piezo_center + piezo_amplitude, + }, + "mode": "sheet", + "ts": None, + "is_default": True, + } + @router.get("/api/devices/bottom_camera/status") async def get_bottom_camera_status(): """Return whether the bottom-camera stream bridge is running.""" @@ -370,6 +436,1222 @@ async def set_temperature(payload: dict = Body(...)): # noqa: B008 "waited": res.get("waited", False), } + @router.get("/api/devices/temperature/config") + async def get_temperature_config(): + """Thermalizer connection config (password redacted) + live backend/state + for the Settings panel. Read-only, so no control elevation required.""" + client = _resolve_client() + if client is None: + return {"available": False} + try: + res = await client.get_temperature_config() + except Exception as exc: + logger.debug("temperature config fetch failed: %s", exc) + return {"available": False} + return {"available": bool(res.get("success", False)), **res} + + @router.post("/api/devices/temperature/config/test", dependencies=[Depends(require_control)]) + async def test_temperature_config(payload: dict = Body(...)): # noqa: B008 + """Probe a candidate thermalizer config without committing it.""" + client = _resolve_client() + if client is None: + raise HTTPException(status_code=503, detail="Microscope not connected") + try: + res = await client.test_temperature_config(payload) + except Exception as exc: + logger.exception("Thermalizer test failed") + raise HTTPException(status_code=502, detail=f"thermalizer test failed: {exc}") from exc + return res + + @router.post("/api/devices/temperature/config", dependencies=[Depends(require_control)]) + async def set_temperature_config(payload: dict = Body(...)): # noqa: B008 + """Reconfigure the thermalizer (serial/mqtt/mock). Live hot-swap where + possible; otherwise persisted for the next device-layer restart.""" + client = _resolve_client() + if client is None: + raise HTTPException(status_code=503, detail="Microscope not connected") + try: + res = await client.set_temperature_config(payload) + except Exception as exc: + logger.exception("Thermalizer reconfigure failed") + raise HTTPException( + status_code=502, detail=f"thermalizer reconfigure failed: {exc}" + ) from exc + return res + + @router.get("/api/config/effective") + async def get_effective_config(): + """Read-only view of the effective server config, secrets redacted. + + settings.py values are frozen at import (resolved from env vars once), so + changing them requires a process restart — surfaced here for visibility, + not editing. Secrets are shown only as present/absent booleans. + """ + import os + + from gently.settings import settings as S + + return { + "note": "settings.py values are read from env at startup; " + "changing them needs a restart.", + "network": { + "viz_host": S.network.viz_host, + "viz_port": S.network.viz_port, + "device_host": S.network.device_host, + "device_port": S.network.device_port, + "mesh_port": S.network.mesh_port, + }, + "models": { + "main": S.models.main, + "perception": S.models.perception, + "fast": S.models.fast, + "medium": S.models.medium, + "refusal_fallback": S.models.refusal_fallback, + }, + "storage": {"base_path": str(S.storage.base_path)}, + "timeouts": { + "plan_execution": S.timeouts.plan_execution, + "volume_acquisition": S.timeouts.volume_acquisition, + "api_call": S.timeouts.api_call, + }, + "ml": { + "default_batch_size": S.ml.default_batch_size, + "default_epochs": S.ml.default_epochs, + "default_lr": S.ml.default_lr, + "model_cache_dir": str(S.ml.model_cache_dir), + }, + "transfer": { + "transfer_port": S.transfer.transfer_port, + "chunk_size": S.transfer.chunk_size, + "max_concurrent_transfers": S.transfer.max_concurrent_transfers, + }, + "mesh": { + "broadcast_interval_s": S.mesh.broadcast_interval_s, + "stale_threshold_s": S.mesh.stale_threshold_s, + "dead_threshold_s": S.mesh.dead_threshold_s, + }, + "ui": {"ux_v2": S.ui.ux_v2}, + "api": {"ncbi_tool": S.api.ncbi_tool}, + "secrets_present": { + "anthropic_api_key": bool(os.getenv("ANTHROPIC_API_KEY")), + "control_token": bool(os.getenv("GENTLY_CONTROL_TOKEN")), + }, + } + + # --- Rig-wide dashboard-preference defaults (layered UNDER per-browser localStorage) --- + _dashboard_defaults_path = _HARDWARE_CONFIG_PATH.parent / "dashboard_defaults.json" + + @router.get("/api/config/dashboard-defaults") + async def get_dashboard_defaults(): + """Rig-wide dashboard-pref defaults (JSON). The browser layers localStorage + over these, so a fresh browser inherits the rig's defaults.""" + import json + + if not _dashboard_defaults_path.exists(): + return {} + try: + return json.loads(_dashboard_defaults_path.read_text()) + except Exception: + return {} + + @router.put("/api/config/dashboard-defaults", dependencies=[Depends(require_control)]) + async def put_dashboard_defaults(payload: dict = Body(...)): # noqa: B008 + """Save the current dashboard prefs as the rig-wide defaults.""" + import json + + if not isinstance(payload, dict): + raise HTTPException(status_code=400, detail="body must be an object") + _dashboard_defaults_path.write_text(json.dumps(payload, indent=2)) + return {"saved": True} + + # --- Restart-required settings.py editors (persisted to config/settings.local.yml) --- + # Allowlist of knobs that are ACTUALLY consumed by the runtime (verified live + # readers) + grouped for the UI. Never expose ports/hosts/model-IDs/storage/ + # secrets. Deliberately omitted: timeouts.rpc_call (removed — RPyC-era dead), + # timeouts.plan_execution and ml.* defaults (currently 0 readers — editing + # would be a silent no-op). + _override_keys = [ + { + "env": "GENTLY_TIMEOUT_VOLUME", + "label": "Volume acquisition (s)", + "type": "int", + "group": "Timeouts", + "get": lambda S: S.timeouts.volume_acquisition, + }, + { + "env": "GENTLY_TIMEOUT_API", + "label": "External API call (s)", + "type": "int", + "group": "Timeouts", + "get": lambda S: S.timeouts.api_call, + }, + { + "env": "GENTLY_MESH_BROADCAST_INTERVAL", + "label": "Broadcast interval (s)", + "type": "float", + "group": "Mesh network", + "get": lambda S: S.mesh.broadcast_interval_s, + }, + { + "env": "GENTLY_MESH_STALE_THRESHOLD", + "label": "Stale threshold (s)", + "type": "float", + "group": "Mesh network", + "get": lambda S: S.mesh.stale_threshold_s, + }, + { + "env": "GENTLY_MESH_DEAD_THRESHOLD", + "label": "Dead threshold (s)", + "type": "float", + "group": "Mesh network", + "get": lambda S: S.mesh.dead_threshold_s, + }, + { + "env": "GENTLY_UX_V2", + "label": "UX v2 dashboard", + "type": "bool", + "group": "Interface", + "get": lambda S: S.ui.ux_v2, + }, + { + "env": "GENTLY_NCBI_TOOL", + "label": "Tool name", + "type": "str", + "group": "NCBI (Entrez)", + "get": lambda S: S.api.ncbi_tool, + }, + { + "env": "GENTLY_NCBI_EMAIL", + "label": "Contact email", + "type": "str", + "group": "NCBI (Entrez)", + "get": lambda S: S.api.ncbi_email, + }, + ] + _settings_local_path = _HARDWARE_CONFIG_PATH.parent / "settings.local.yml" + + def _coerce_override(typ, val): + if typ == "int": + return int(val) + if typ == "float": + return float(val) + if typ == "bool": + return val if isinstance(val, bool) else str(val).lower() in ("1", "true", "yes", "on") + return str(val) + + def _read_settings_local(): + if not _settings_local_path.exists(): + return {} + try: + return yaml.safe_load(_settings_local_path.read_text()) or {} + except Exception: + return {} + + @router.get("/api/config/settings-overrides") + async def get_settings_overrides(): + """Editable (restart-required) settings.py knobs: current effective value + + whether an override file entry exists.""" + from gently.settings import settings as S + + file_over = _read_settings_local() + items = [ + { + "env": k["env"], + "label": k["label"], + "type": k["type"], + "group": k["group"], + "current": k["get"](S), + "overridden": k["env"] in file_over, + } + for k in _override_keys + ] + return {"note": "changes take effect on the next process restart", "items": items} + + @router.put("/api/config/settings-overrides", dependencies=[Depends(require_control)]) + async def put_settings_overrides(payload: dict = Body(...)): # noqa: B008 + """Persist restart-required overrides to config/settings.local.yml. Only + allowlisted keys; never mutates the frozen settings singleton live.""" + allowed = {k["env"]: k["type"] for k in _override_keys} + updates = {} + for k, v in (payload or {}).items(): + if k not in allowed: + raise HTTPException(status_code=400, detail=f"unknown or non-editable key: {k}") + if v is None or v == "": + continue + try: + updates[k] = _coerce_override(allowed[k], v) + except (TypeError, ValueError): + raise HTTPException( + status_code=400, detail=f"{k}: invalid {allowed[k]} value" + ) from None + existing = _read_settings_local() + existing.update(updates) + _settings_local_path.write_text( + yaml.safe_dump(existing, default_flow_style=False, sort_keys=True) + ) + return { + "saved": list(updates.keys()), + "restart_required": True, + "note": "restart the server for these to take effect", + } + + # ------------------------------------------------------------------ + # Lightsheet live stream + # ------------------------------------------------------------------ + + @router.get("/api/devices/lightsheet/live/status") + async def get_lightsheet_live_status(): + """Return whether the lightsheet live stream bridge is running.""" + bridge = getattr(server, "agent_bridge", None) + agent = bridge.agent if bridge is not None else None + monitor = getattr(agent, "lightsheet_monitor", None) if agent else None + return { + "available": monitor is not None, + "streaming": bool(monitor and monitor.running), + "last_frame_ts": getattr(monitor, "_last_frame_ts", None) if monitor else None, + } + + @router.post( + "/api/devices/lightsheet/live/start", + dependencies=[Depends(require_control)], + ) + async def start_lightsheet_live_stream(): + """Start the lightsheet live stream bridge. + + Idempotent — calling start() while already running is a no-op. + """ + bridge = getattr(server, "agent_bridge", None) + agent = bridge.agent if bridge is not None else None + monitor = getattr(agent, "lightsheet_monitor", None) if agent else None + if monitor is None: + raise HTTPException( + status_code=503, + detail="Lightsheet monitor not initialised (agent or microscope not ready)", + ) + try: + await monitor.start() + except Exception as exc: + logger.exception("Failed to start lightsheet monitor") + raise HTTPException(status_code=500, detail=f"start failed: {exc}") from exc + return {"streaming": monitor.running} + + @router.post( + "/api/devices/lightsheet/live/stop", + dependencies=[Depends(require_control)], + ) + async def stop_lightsheet_live_stream(): + """Stop the lightsheet live stream bridge. Idempotent.""" + bridge = getattr(server, "agent_bridge", None) + agent = bridge.agent if bridge is not None else None + monitor = getattr(agent, "lightsheet_monitor", None) if agent else None + if monitor is None: + return {"streaming": False} + try: + await monitor.stop() + except Exception as exc: + logger.exception("Failed to stop lightsheet monitor") + raise HTTPException(status_code=500, detail=f"stop failed: {exc}") from exc + return {"streaming": False} + + # ------------------------------------------------------------------ + # Lightsheet live params + # ------------------------------------------------------------------ + + @router.post("/api/devices/lightsheet/live/params", dependencies=[Depends(require_control)]) + async def lightsheet_live_params(payload: dict = Body(...)): # noqa: B008 + """Forward galvo/piezo/exposure/side params to the device-layer lightsheet streamer.""" + client = _resolve_client() + if client is None: + raise HTTPException(status_code=503, detail="Microscope not connected") + try: + res = await client.set_lightsheet_live_params( + galvo=payload.get("galvo"), + piezo=payload.get("piezo"), + exposure=payload.get("exposure"), + side=payload.get("side"), + ) + except Exception as exc: + logger.exception("lightsheet live params failed") + raise HTTPException(status_code=502, detail=f"params failed: {exc}") from exc + return res + + # ------------------------------------------------------------------ + # LED / laser / camera + # ------------------------------------------------------------------ + + @router.post("/api/devices/led/set", dependencies=[Depends(require_control)]) + async def led_set(payload: dict = Body(...)): # noqa: B008 + """Set the LED shutter state. Body: {"state": "Open"|"Closed"}.""" + client = _resolve_client() + if client is None: + raise HTTPException(status_code=503, detail="Microscope not connected") + try: + return await client.set_led(str(payload.get("state", "Closed"))) + except Exception as exc: + logger.exception("LED set command failed") + raise HTTPException(status_code=502, detail=f"led failed: {exc}") from exc + + @router.post("/api/devices/laser/off", dependencies=[Depends(require_control)]) + async def laser_off(): + """Gate ALL laser lines off via the Laser config group "ALL OFF" preset. + + Uses setConfig("Laser", "ALL OFF") which drives the PLogic + OutputChannel to "none of outputs 5-8" — this gates every line + (488, 561, 405, 637) off, not just the 488 nm setpoint. + Required for safe brightfield live-view (spec §2.7). + """ + client = _resolve_client() + if client is None: + raise HTTPException(status_code=503, detail="Microscope not connected") + try: + return await client.set_laser_config("ALL OFF") + except Exception as exc: + logger.exception("Laser off command failed") + raise HTTPException(status_code=502, detail=f"laser off failed: {exc}") from exc + + @router.get("/api/devices/laser/configs") + async def laser_configs(): + """Return the available Laser config-group presets from the device layer. + + No require_control — read-only status route, mirrors GET status + routes like room_light/status and temperature/status. + """ + client = _resolve_client() + if client is None or not client.is_connected: + # Device layer offline is an expected state (e.g. UI open without the + # device process) — a quiet 503, not an ERROR traceback per poll. + raise HTTPException(status_code=503, detail="Microscope not connected") + try: + return await client.get_laser_configs() + except Exception as exc: + logger.exception("Laser configs fetch failed") + raise HTTPException(status_code=502, detail=f"laser configs failed: {exc}") from exc + + @router.post("/api/devices/laser/config", dependencies=[Depends(require_control)]) + async def laser_config_set(payload: dict = Body(...)): # noqa: B008 + """Apply a named Laser config-group preset (e.g. "ALL OFF", "488 only"). + + Body: {"config": ""} + + Returns the device layer response. 400 if config is missing/empty; + 503 if the microscope is not connected; 502 on device error. + """ + config = payload.get("config") + if not config or not isinstance(config, str): + raise HTTPException(status_code=400, detail="config must be a non-empty string") + client = _resolve_client() + if client is None: + raise HTTPException(status_code=503, detail="Microscope not connected") + try: + return await client.set_laser_config(config) + except Exception as exc: + logger.exception("Laser config set command failed") + raise HTTPException(status_code=502, detail=f"laser config failed: {exc}") from exc + + @router.get("/api/devices/cameras") + async def cameras_list(): + """Return the available SPIM camera roles (A always; B if camera_b registered). + + No require_control — read-only status route, mirrors GET /api/devices/laser/configs. + """ + client = _resolve_client() + if client is None or not client.is_connected: + # Device layer offline is expected — quiet 503, no ERROR traceback. + raise HTTPException(status_code=503, detail="Microscope not connected") + try: + return await client.get_cameras() + except Exception as exc: + logger.exception("Cameras list fetch failed") + raise HTTPException(status_code=502, detail=f"cameras failed: {exc}") from exc + + @router.post("/api/devices/camera/led_mode", dependencies=[Depends(require_control)]) + async def camera_led_mode(payload: dict = Body(...)): # noqa: B008 + """Enable/disable automatic LED for bottom-camera captures. Body: {"use_led": bool}.""" + client = _resolve_client() + if client is None: + raise HTTPException(status_code=503, detail="Microscope not connected") + try: + return await client.set_camera_led_mode(bool(payload.get("use_led", False))) + except Exception as exc: + logger.exception("Camera LED mode command failed") + raise HTTPException(status_code=502, detail=f"camera led mode failed: {exc}") from exc + + # ------------------------------------------------------------------ + # Stage + # ------------------------------------------------------------------ + + @router.post("/api/devices/stage/move", dependencies=[Depends(require_control)]) + async def stage_move(payload: dict = Body(...)): # noqa: B008 + """Move the stage to an absolute XY position. Body: {"x": float, "y": float}.""" + client = _resolve_client() + if client is None: + raise HTTPException(status_code=503, detail="Microscope not connected") + try: + return await client.move_to_position(float(payload["x"]), float(payload["y"])) + except KeyError: + raise HTTPException(status_code=400, detail="x and y required") from None + except Exception as exc: + logger.exception("Stage move command failed") + raise HTTPException(status_code=502, detail=f"stage move failed: {exc}") from exc + + def _num(v): + try: + return float(v) + except (TypeError, ValueError): + return None + + def _persist_detection_labels(agent, payload: dict, markers: list) -> bool: + """Persist the annotated bottom-cam frame + per-marker pixel/stage coords + as a labelled snapshot — localization training data (sub-project B). + + Best-effort: requires a client-supplied image and an active session; any + failure (no session, missing deps) is swallowed so it never blocks the + embryo registration. Reuses FileStore.register_snapshot so labels live in + the standard snapshots/ sidecar, not a bespoke store. + """ + image_b64 = payload.get("image_b64") + store = getattr(agent, "store", None) + session_id = getattr(agent, "session_id", None) + if not image_b64 or store is None or not session_id: + return False + try: + import base64 + import io + import tempfile + import uuid as _uuid + + import numpy as np + import tifffile + from PIL import Image + + from gently.core.coordinates import ( + DEFAULT_OBJECTIVE_MAG, + DEFAULT_PIXEL_SIZE_UM, + ) + + raw = base64.b64decode(image_b64.split(",")[-1]) + img = np.asarray(Image.open(io.BytesIO(raw))) + tmp = Path(tempfile.gettempdir()) / f"operate_{_uuid.uuid4().hex[:12]}.tif" + tifffile.imwrite(str(tmp), img) + + frame = payload.get("frame") or {} + pos = payload.get("stage_position") or [None, None] + meta = { + "kind": "operate_marking", + "stage_position": list(pos), + "frame": { + "width": _num(frame.get("w")), + "height": _num(frame.get("h")), + "downsample": _num(frame.get("downsample")), + }, + "transform": { + "pixel_size_um": DEFAULT_PIXEL_SIZE_UM, + "objective_mag": DEFAULT_OBJECTIVE_MAG, + }, + "embryos": [ + { + "pixel_x": _num(m.get("pixel_x")), + "pixel_y": _num(m.get("pixel_y")), + "stage_x_um": _num(m.get("stage_x_um")), + "stage_y_um": _num(m.get("stage_y_um")), + "source": m.get("source", "manual"), + } + for m in markers + ], + } + store.register_snapshot(session_id, "operate_marked", tmp, metadata=meta) + return True + except Exception: + logger.debug("detection-label persistence skipped", exc_info=True) + return False + + @router.post("/api/devices/detect_embryos", dependencies=[Depends(require_control)]) + async def detect_embryos(payload: dict = Body(default={})): # noqa: B008 + """Run bottom-camera embryo detection and RETURN the candidates for the + Operate-view marking canvas. Does NOT register — the operator confirms + (add/remove/relocate) on a frozen frame, then POSTs /api/devices/embryos/ + confirm. This keeps the human in the loop and the canonical embryo list + clean (a marking step, not a blind auto-register). + + Body (all optional): {exposure_ms, min_confidence, brightness_percentile, + min_area, max_area, use_claude_review}. Claude review defaults OFF. + + Returns: {success, count, stage_position: [x, y] | null, + embryos: [{embryo_id, pixel_x, pixel_y, stage_x_um, stage_y_um, + confidence, area_pixels, bbox_pixel}]}. + """ + client = _resolve_client() + if client is None: + raise HTTPException(status_code=503, detail="Microscope not connected") + if not getattr(client, "has_sam", False): + raise HTTPException( + status_code=503, detail="SAM detection not available on device layer" + ) + + kw: dict = {"use_claude_review": bool(payload.get("use_claude_review", False))} + for key, cast in ( + ("exposure_ms", float), + ("min_confidence", float), + ("brightness_percentile", float), + ("min_area", int), + ("max_area", int), + ): + if payload.get(key) is not None: + kw[key] = cast(payload[key]) + try: + result = await client.detect_embryos(**kw) + except Exception as exc: + logger.exception("Embryo detection failed") + raise HTTPException(status_code=502, detail=f"detection failed: {exc}") from exc + + if not result.get("success"): + raise HTTPException( + status_code=502, detail=str(result.get("error", "detection failed")) + ) + + embryos = [] + for emb in result.get("embryos", []) or []: + bbox = emb.get("bbox_pixel") + embryos.append( + { + "embryo_id": emb.get("embryo_id"), + "pixel_x": _num(emb.get("pixel_x")), + "pixel_y": _num(emb.get("pixel_y")), + "stage_x_um": _num(emb.get("stage_x_um")), + "stage_y_um": _num(emb.get("stage_y_um")), + "confidence": _num(emb.get("confidence")), + "area_pixels": emb.get("area_pixels"), + "bbox_pixel": list(bbox) if bbox is not None else None, + } + ) + pos = result.get("stage_position") + return { + "success": True, + "count": len(embryos), + "stage_position": list(pos) if pos is not None else None, + "embryos": embryos, + } + + @router.post("/api/devices/embryos/confirm", dependencies=[Depends(require_control)]) + async def confirm_embryos(payload: dict = Body(...)): # noqa: B008 + """Register operator-confirmed markers into the canonical embryo list. + + Agent-free commit step for the Operate-view marking canvas. The client + computes each marker's stage XY from the frozen frame (SAM candidates + carry server-computed stage coords; clicked markers are converted with + the frame's downsample-aware transform), so the server just registers + via ExperimentState.add_embryo(role='unassigned'), firing EMBRYOS_UPDATE. + + Body: {markers: [{stage_x_um, stage_y_um, pixel_x?, pixel_y?, source?, + confidence?}], image_b64?, frame? {w,h,downsample}, stage_position?}. + When image_b64 is present the annotated frame + per-marker pixel/stage + coords are persisted as a labelled snapshot (sub-project B: localization + training data) — best-effort, never blocks registration. + Returns {success, registered: [embryo_id, ...], labelled: bool}. + """ + agent = _require_agent_with_experiment() + markers = payload.get("markers") or [] + + def _next_embryo_id(taken: set) -> str: + n = 1 + while f"embryo_{n}" in agent.experiment.embryos or f"embryo_{n}" in taken: + n += 1 + return f"embryo_{n}" + + import uuid + + registered: list[str] = [] + taken: set = set() + for m in markers: + sx, sy = _num(m.get("stage_x_um")), _num(m.get("stage_y_um")) + if sx is None or sy is None: + continue + emb_id = _next_embryo_id(taken) + taken.add(emb_id) + agent.experiment.add_embryo( + embryo_id=emb_id, + position={"x": sx, "y": sy}, + confidence=_num(m.get("confidence")) or 0.0, + uid=str(uuid.uuid4()), + role="unassigned", + ) + registered.append(emb_id) + + labelled = _persist_detection_labels(agent, payload, markers) + return {"success": True, "registered": registered, "labelled": labelled} + + @router.post( + "/api/devices/embryos/{embryo_id}/calibrate", + dependencies=[Depends(require_control)], + ) + async def calibrate_embryo_route(embryo_id: str, payload: dict = Body(default={})): # noqa: B008 + """Run piezo-galvo calibration for one embryo — the Operate B-cal step. + + Reuses the agent's proven ``calibrate_embryo`` tool (Claude-vision edge + detection + adaptive focus sweep, which sets the light-sheet laser config + per snap) rather than the bare device-layer plan, so the operate flow + gets the same calibration quality as the agent. No LLM orchestration — + the coroutine is called directly with an agent/client context. The SPIM + head should already be lowered + focused (operate reaches this step only + after B3). Persists the fit onto ``embryo.calibration``. + """ + agent = _require_agent_with_experiment() + if embryo_id not in agent.experiment.embryos: + raise HTTPException(status_code=404, detail=f"unknown embryo {embryo_id}") + client = _resolve_client() + if client is None or not getattr(client, "is_connected", False): + raise HTTPException(status_code=503, detail="Microscope not connected") + + # Ensure the calibration tools are registered on the global registry, + # then run via the registry so context (agent/client) is injected the + # same way the agent invokes it. Calling the @tool wrapper directly would + # drop the positional embryo_id. + import gently.app.tools.calibration_tools # noqa: F401 (registers the tool) + from gently.harness.tools.registry import get_tool_registry + + registry = get_tool_registry() + try: + message = await registry.execute( + "calibrate_embryo", + {"embryo_id": embryo_id}, + {"agent": agent, "client": client}, + ) + except Exception as exc: + logger.exception("Calibration failed for %s", embryo_id) + raise HTTPException(status_code=502, detail=f"calibration failed: {exc}") from exc + if isinstance(message, str) and message.startswith("Error"): + raise HTTPException(status_code=502, detail=message) + + emb = agent.experiment.embryos.get(embryo_id) + calibration = dict(getattr(emb, "calibration", {}) or {}) if emb else {} + agent.experiment.notify_embryos_changed() + return {"success": True, "message": message, "calibration": calibration} + + @router.post("/api/embryos/roles", dependencies=[Depends(require_control)]) + async def set_embryo_roles(payload: dict = Body(...)): # noqa: B008 + """Assign experimental roles to embryos — the Operate "Run" step. + + Body: {roles: {embryo_id: role_name}} where role_name is a key in + gently.harness.roles.REGISTRY (subject=='test', reference=='calibration', + plus 'lineaging'/'unassigned'). Sets EmbryoState.role and fires one + EMBRYOS_UPDATE (+ per-embryo STATUS_CHANGED) so every consumer refreshes. + Marking stays positions-only; roles are assigned here, not at marking. + Load-bearing: expression_monitoring scopes to role=='test', so the marked + set must be given roles before role-scoped monitoring matches anything. + """ + from gently.harness.roles import is_valid_role + + agent = _require_agent_with_experiment() + roles = payload.get("roles") or {} + if not isinstance(roles, dict) or not roles: + raise HTTPException(status_code=400, detail="roles map required") + embryos = agent.experiment.embryos + for eid, role in roles.items(): + if eid not in embryos: + raise HTTPException(status_code=400, detail=f"unknown embryo {eid}") + if not is_valid_role(str(role)): + raise HTTPException(status_code=400, detail=f"invalid role {role}") + + store = getattr(agent, "store", None) + sid = getattr(agent, "session_id", None) + bus = getattr(agent, "_event_bus", None) + updated: list[str] = [] + for eid, role in roles.items(): + emb = embryos[eid] + old = getattr(emb, "role", None) + emb.role = str(role) + if store is not None and sid: + try: + pos = getattr(emb, "position_coarse", {}) or {} + store.register_embryo( + sid, + eid, + position_x=pos.get("x"), + position_y=pos.get("y"), + calibration=getattr(emb, "calibration", {}) or {}, + role=str(role), + ) + except Exception: + logger.debug("role persist failed for %s", eid, exc_info=True) + if bus is not None: + try: + from gently.core.event_bus import EventType + + bus.publish( + event_type=EventType.STATUS_CHANGED, + data={ + "embryo_id": eid, + "change": "role_assigned", + "old_role": old, + "new_role": str(role), + }, + source="operate_roles", + ) + except Exception: + logger.debug("STATUS_CHANGED publish failed", exc_info=True) + updated.append(eid) + try: + agent.experiment.notify_embryos_changed() # fires EMBRYOS_UPDATE + except Exception: + logger.debug("notify_embryos_changed failed", exc_info=True) + return {"success": True, "updated": updated} + + @router.post("/api/operate/run-tactic", dependencies=[Depends(require_control)]) + async def operate_run_tactic(payload: dict = Body(...)): # noqa: B008 + """Append a tactic to the session Operation Plan and execute it via the + Tactic Executor (resolve scope → dispatch by kind to the orchestrator). + + Body: {tactic: {...}} OR {library_id: "..."} (instantiate a saved tactic); + optional {embryo_ids: [...]} to re-scope it to the marked set. + Returns {success, tactic_id, result}. + """ + from gently.app.orchestration.tactic_executor import ( + append_tactic_to_plan, + execute_tactic, + ) + + agent = _require_agent_with_experiment() + tactic = payload.get("tactic") + lib_id = payload.get("library_id") + if tactic is None and lib_id: + cs = getattr(agent, "context_store", None) + if cs is None: + raise HTTPException(status_code=503, detail="No context store") + tactic = cs.apply_tactic(lib_id) + if tactic is None: + raise HTTPException(status_code=404, detail=f"tactic '{lib_id}' not found") + if not isinstance(tactic, dict): + raise HTTPException(status_code=400, detail="tactic or library_id required") + + eids = payload.get("embryo_ids") + if eids: + tactic = dict(tactic) + tactic["scope"] = {"mode": "embryos", "embryo_ids": list(eids)} + + try: + stored = append_tactic_to_plan(agent, tactic) + except ValueError as exc: + raise HTTPException(status_code=400, detail=f"invalid tactic: {exc}") from exc + if stored is None: + raise HTTPException(status_code=503, detail="No session to attach the tactic to") + try: + result = await execute_tactic(agent, stored) + except Exception as exc: + logger.exception("run-tactic execution failed") + raise HTTPException(status_code=502, detail=f"tactic execution failed: {exc}") from exc + return {"success": bool(result.get("ok")), "tactic_id": stored.get("id"), "result": result} + + @router.get("/api/operation_plan") + async def get_operation_plan_route(): + """Current session's Operation Plan (the tactics document), for the + Operate run-spine. Returns {plan: {...}|null}. Never errors.""" + bridge = getattr(server, "agent_bridge", None) + agent = bridge.agent if bridge is not None else None + cs = getattr(agent, "context_store", None) if agent else None + sid = getattr(agent, "session_id", None) if agent else None + if cs is None or not sid: + return {"plan": None} + try: + return {"plan": cs.get_operation_plan(sid)} + except Exception: + logger.debug("get_operation_plan failed", exc_info=True) + return {"plan": None} + + # ------------------------------------------------------------------ + # Focus Z axes (Operate view) — fenced read + nudge + # ------------------------------------------------------------------ + + @router.get("/api/devices/stage/bottom_z") + async def get_bottom_z(): + """Bottom-camera focus Z position + limits (read-only).""" + client = _resolve_client() + if client is None: + raise HTTPException(status_code=503, detail="Microscope not connected") + try: + return await client.get_bottom_z() + except Exception as exc: + logger.debug("bottom_z read failed: %s", exc) + raise HTTPException(status_code=502, detail=f"bottom_z read failed: {exc}") from exc + + @router.post("/api/devices/stage/bottom_z/nudge", dependencies=[Depends(require_control)]) + async def nudge_bottom_z(payload: dict = Body(...)): # noqa: B008 + """Nudge the bottom-camera focus Z by {delta} µm (fenced to its limits).""" + client = _resolve_client() + if client is None: + raise HTTPException(status_code=503, detail="Microscope not connected") + delta = _num(payload.get("delta")) + if delta is None: + raise HTTPException(status_code=400, detail="delta required") + try: + return await client.nudge_bottom_z(delta) + except Exception as exc: + logger.exception("bottom_z nudge failed") + raise HTTPException(status_code=502, detail=f"bottom_z nudge failed: {exc}") from exc + + @router.get("/api/devices/spim/fdrive") + async def get_fdrive(): + """SPIM-head F-drive position + limits + distance-to-floor (read-only).""" + client = _resolve_client() + if client is None: + raise HTTPException(status_code=503, detail="Microscope not connected") + try: + return await client.get_fdrive() + except Exception as exc: + logger.debug("fdrive read failed: %s", exc) + raise HTTPException(status_code=502, detail=f"fdrive read failed: {exc}") from exc + + @router.post("/api/devices/spim/fdrive/nudge", dependencies=[Depends(require_control)]) + async def nudge_fdrive(payload: dict = Body(...)): # noqa: B008 + """Nudge the SPIM-head F-drive by {delta} µm (fenced; never below floor).""" + client = _resolve_client() + if client is None: + raise HTTPException(status_code=503, detail="Microscope not connected") + delta = _num(payload.get("delta")) + if delta is None: + raise HTTPException(status_code=400, detail="delta required") + try: + return await client.nudge_fdrive(delta) + except Exception as exc: + logger.exception("fdrive nudge failed") + raise HTTPException(status_code=502, detail=f"fdrive nudge failed: {exc}") from exc + + # ------------------------------------------------------------------ + # Acquisition + # ------------------------------------------------------------------ + + @router.post("/api/devices/acquire/burst", dependencies=[Depends(require_control)]) + async def acquire_burst(payload: dict = Body(...)): # noqa: B008 + """Trigger a burst acquisition. + + Body: {frames, mode, num_slices, exposure_ms, + laser_config?, piezo_center?, galvo_center?}. + laser_config is forwarded directly to the device client so callers + can send "ALL OFF" for brightfield-safe Manual-view captures. + piezo_center and galvo_center capture at the dialled focal plane. + """ + client = _resolve_client() + if client is None: + raise HTTPException(status_code=503, detail="Microscope not connected") + try: + kw: dict = {} + if payload.get("laser_config") is not None: + kw["laser_config"] = str(payload["laser_config"]) + if payload.get("piezo_center") is not None: + kw["piezo_center"] = float(payload["piezo_center"]) + if payload.get("galvo_center") is not None: + kw["galvo_center"] = float(payload["galvo_center"]) + result = await client.acquire_burst( + frames=int(payload.get("frames", 60)), + mode=str(payload.get("mode", "1hz")), + num_slices=int(payload.get("num_slices", 1)), + exposure_ms=float(payload.get("exposure_ms", 5.0)), + **kw, + ) + return _json_safe(result) + except Exception as exc: + logger.exception("Burst acquisition failed") + raise HTTPException(status_code=502, detail=f"burst failed: {exc}") from exc + + @router.post("/api/devices/acquire/volume", dependencies=[Depends(require_control)]) + async def acquire_volume(payload: dict = Body(...)): # noqa: B008 + """Trigger a volume acquisition. + + Body: {num_slices, exposure_ms, + laser_config?, piezo_center?, galvo_center?}. + laser_config is forwarded directly to the device client so callers + can send "ALL OFF" for brightfield-safe Manual-view captures. + piezo_center and galvo_center capture at the dialled focal plane. + """ + client = _resolve_client() + if client is None: + raise HTTPException(status_code=503, detail="Microscope not connected") + try: + kw: dict = {} + if payload.get("laser_config") is not None: + kw["laser_config"] = str(payload["laser_config"]) + if payload.get("piezo_center") is not None: + kw["piezo_center"] = float(payload["piezo_center"]) + if payload.get("galvo_center") is not None: + kw["galvo_center"] = float(payload["galvo_center"]) + result = await client.acquire_volume( + num_slices=int(payload.get("num_slices", 50)), + exposure_ms=float(payload.get("exposure_ms", 10.0)), + **kw, + ) + return _json_safe(result) + except Exception as exc: + logger.exception("Volume acquisition failed") + raise HTTPException(status_code=502, detail=f"volume failed: {exc}") from exc + + # ------------------------------------------------------------------ + # Timelapse + # ------------------------------------------------------------------ + + @router.post("/api/devices/timelapse/start", dependencies=[Depends(require_control)]) + async def timelapse_start(payload: dict = Body(...)): # noqa: B008 + """Start an adaptive timelapse from the manual UI. + + Body fields (all optional except interval_seconds has a default): + interval_seconds (float, default 120) — cadence; must be > 0 + stop_condition (str, default "manual") — "manual", "timepoints", "duration" + embryo_ids (list[str] | null) — null = all active embryos + condition_value (int | null) — timepoints count or duration hours + monitoring_mode (str | null) — "idle" / "expression_monitoring" / + "pre_terminal_monitoring" + num_slices (int, default 50) — must be >= 1 if provided + exposure_ms (float, default 10.0) + galvo_amplitude (float, default 0.5) + galvo_center (float, default 0.0) + piezo_amplitude (float, default 25.0) + piezo_center (float, default 50.0) + laser_config (str | null) + + Validation: + - interval_seconds must be > 0 + - num_slices must be >= 1 + + Orchestrator access: server.agent_bridge.agent.timelapse_orchestrator + RIG-DEFERRED: the actual acquisition + galvo/piezo motion. + """ + # --- Validate --- + raw_interval = payload.get("interval_seconds", 120.0) + try: + interval_seconds = float(raw_interval) + except (TypeError, ValueError): + raise HTTPException( # B904 + status_code=400, detail="interval_seconds must be a number" + ) from None + if interval_seconds <= 0: + raise HTTPException(status_code=400, detail="interval_seconds must be > 0") + + raw_slices = payload.get("num_slices") + if raw_slices is not None: + try: + num_slices = int(raw_slices) + except (TypeError, ValueError): + raise HTTPException( # B904 + status_code=400, detail="num_slices must be an integer" + ) from None + if num_slices < 1: + raise HTTPException(status_code=400, detail="num_slices must be >= 1") + else: + num_slices = 50 + + stop_condition = str(payload.get("stop_condition") or "manual") + embryo_ids = payload.get("embryo_ids") or None + condition_value = payload.get("condition_value") + monitoring_mode = payload.get("monitoring_mode") or None + + # Volume geometry — passed through for context / future calibration write; + # not forwarded to orchestrator.start (which owns its own geometry via the + # per-embryo calibration). RIG-DEFERRED: real acquisition uses these. + volume_geometry = { + "num_slices": num_slices, + "exposure_ms": float(payload.get("exposure_ms", 10.0)), + "galvo_amplitude": float(payload.get("galvo_amplitude", 0.5)), + "galvo_center": float(payload.get("galvo_center", 0.0)), + "piezo_amplitude": float(payload.get("piezo_amplitude", 25.0)), + "piezo_center": float(payload.get("piezo_center", 50.0)), + "laser_config": payload.get("laser_config") or None, + } + + # --- Resolve orchestrator --- + bridge = getattr(server, "agent_bridge", None) + agent = bridge.agent if bridge is not None else None + orchestrator = getattr(agent, "timelapse_orchestrator", None) if agent else None + if orchestrator is None: + raise HTTPException( + status_code=503, + detail="Timelapse orchestrator not initialised (agent not running or no session)", + ) + + # --- Start timelapse (RIG-DEFERRED: real acquisition) --- + # TODO: UI-initiated timelapses skip the agent tool's plan auto-linking; + # this is intentional — the agent path wires the plan, this route does not. + try: + result = await orchestrator.start( + embryo_ids=embryo_ids, + stop_condition=stop_condition, + base_interval_seconds=interval_seconds, + condition_value=condition_value, + ) + except Exception as exc: + logger.exception("Timelapse start failed") + raise HTTPException(status_code=502, detail=f"timelapse start failed: {exc}") from exc + + # Optionally install a monitoring mode at startup (mirrors start_adaptive_timelapse) + mode_result = None + if monitoring_mode and monitoring_mode != "idle": + try: + mode_result = orchestrator.enable_monitoring_mode(monitoring_mode) + except Exception as exc: + mode_result = f"warning: failed to enable monitoring mode: {exc}" + + # Seed the session Operation Plan with a standing_timelapse tactic (+ a + # reactive_monitor when a monitoring mode is active) scoped to the marked + # set. Closes the historical "UI timelapses skip plan linking" gap so the + # Operate run-spine and the Operations tab show a real tactic. Best-effort: + # needs a live session + context store; never blocks the start. + seeded_tactics: list[str] = [] + cs = getattr(agent, "context_store", None) + sid = getattr(agent, "session_id", None) + # Skip seeding when start was a no-op ("already running") — don't append a + # phantom active tactic for a run we didn't start. + already_running = isinstance(result, str) and result.startswith("Timelapse already running") + if cs is not None and sid and not already_running: + try: + import uuid as _uuid + + from gently.app.tools.operation_plan_tools import _validate_tactics + + # Scope mirrors what orchestrator.start actually does: an omitted + # embryo_ids images ALL active embryos → record global, not []. + eids = list(embryo_ids or []) + seed_scope = {"mode": "embryos", "embryo_ids": eids} if eids else {"mode": "global"} + st_id = f"op_{_uuid.uuid4().hex[:8]}" + new_tactics: list[dict] = [ + { + "id": st_id, + "name": "Adaptive timelapse", + "kind": "standing_timelapse", + "state": "active", + "scope": dict(seed_scope), + "structure": { + "cadence_s": interval_seconds, + "interval": interval_seconds, + "stop_condition": stop_condition, + "condition_value": condition_value, + "monitoring_mode": monitoring_mode or "idle", + }, + "rationale": "Started from the Operate Run step.", + "live_bind": ["cadence"], + "relations": {}, + "live": {}, + "source": "operate", + } + ] + if monitoring_mode and monitoring_mode != "idle": + new_tactics.append( + { + "id": f"op_{_uuid.uuid4().hex[:8]}", + "name": "Monitor", + "kind": "reactive_monitor", + "state": "active", + "scope": dict(seed_scope), + "structure": {"monitoring_mode": monitoring_mode, "status": "armed"}, + "rationale": f"{monitoring_mode} on the marked subjects.", + "live_bind": ["signal"], + "relations": {"layered_on": [st_id]}, + "live": {}, + "source": "operate", + } + ) + new_tactics = _validate_tactics(new_tactics) + plan = cs.get_operation_plan(sid) or { + "session_id": sid, + "title": "Operate session", + "goal": "", + "tactics": [], + } + # Reconcile: retire any prior still-'active' operate-seeded tactics + # so repeated Start clicks don't accumulate stale active timelapses. + for t in plan.setdefault("tactics", []): + if t.get("source") == "operate" and t.get("state") == "active": + t["state"] = "done" + plan["tactics"].extend(new_tactics) + plan["updated_reason"] = "operate adaptive timelapse" + cs.set_operation_plan(sid, plan) + seeded_tactics = [t["id"] for t in new_tactics] + # Link the run to its tactics so stop/pause/resume can reconcile them. + try: + orchestrator._operate_tactic_ids = list(seeded_tactics) + except Exception: + pass + except Exception: + logger.debug("timelapse tactic seeding skipped", exc_info=True) + + return { + "started": True, + "result": result, + "tactics": seeded_tactics, + "monitoring_mode_result": mode_result, + "config": { + "interval_seconds": interval_seconds, + "stop_condition": stop_condition, + "embryo_ids": embryo_ids, + "condition_value": condition_value, + "monitoring_mode": monitoring_mode, + "volume_geometry": volume_geometry, + }, + } + + def _resolve_orch_and_agent(): + bridge = getattr(server, "agent_bridge", None) + agent = bridge.agent if bridge is not None else None + orch = getattr(agent, "timelapse_orchestrator", None) if agent else None + return orch, agent + + def _reconcile_operate_tactics(orch, agent, state: str, clear: bool): + """Transition the run's operate-seeded tactics to ``state`` so the + Operation Plan / run-spine reflect stop/pause/resume. Best-effort.""" + cs = getattr(agent, "context_store", None) + sid = getattr(agent, "session_id", None) + ids = list(getattr(orch, "_operate_tactic_ids", []) or []) + if cs is not None and sid and ids: + for tid in ids: + try: + cs.transition_tactic(sid, tid, state) + except Exception: + logger.debug("transition_tactic %s failed", tid, exc_info=True) + if clear: + try: + orch._operate_tactic_ids = [] + except Exception: + pass + + @router.post("/api/devices/timelapse/stop", dependencies=[Depends(require_control)]) + async def timelapse_stop(payload: dict = Body(default={})): # noqa: B008 + """Stop the running timelapse (Operate run-spine). Body: {reason?}.""" + orch, agent = _resolve_orch_and_agent() + if orch is None: + raise HTTPException(status_code=503, detail="No timelapse orchestrator") + try: + reason = str(payload.get("reason", "user_request")) + res = await orch.stop(reason=reason) + _reconcile_operate_tactics(orch, agent, "done", clear=True) + return {"stopped": True, "result": res} + except Exception as exc: + logger.exception("Timelapse stop failed") + raise HTTPException(status_code=502, detail=f"timelapse stop failed: {exc}") from exc + + @router.post("/api/devices/timelapse/pause", dependencies=[Depends(require_control)]) + async def timelapse_pause(): + """Pause the running timelapse.""" + orch, agent = _resolve_orch_and_agent() + if orch is None: + raise HTTPException(status_code=503, detail="No timelapse orchestrator") + try: + res = await orch.pause() + _reconcile_operate_tactics(orch, agent, "paused", clear=False) + return {"paused": True, "result": res} + except Exception as exc: + logger.exception("Timelapse pause failed") + raise HTTPException(status_code=502, detail=f"timelapse pause failed: {exc}") from exc + + @router.post("/api/devices/timelapse/resume", dependencies=[Depends(require_control)]) + async def timelapse_resume(): + """Resume a paused timelapse.""" + orch, agent = _resolve_orch_and_agent() + if orch is None: + raise HTTPException(status_code=503, detail="No timelapse orchestrator") + try: + res = await orch.resume() + _reconcile_operate_tactics(orch, agent, "active", clear=False) + return {"resumed": True, "result": res} + except Exception as exc: + logger.exception("Timelapse resume failed") + raise HTTPException(status_code=502, detail=f"timelapse resume failed: {exc}") from exc + @router.get("/api/calibration") async def list_calibration(embryo_id: str | None = None): """Get calibration images""" @@ -422,6 +1704,7 @@ async def embryo_positions(): "x": float(x), "y": float(y), "role": emb.get("role", "test"), + "strain": emb.get("strain"), "user_label": emb.get("user_label"), "confidence": emb.get("confidence"), "cadence_phase": emb.get("cadence_phase"), diff --git a/gently/ui/web/routes/notebook.py b/gently/ui/web/routes/notebook.py new file mode 100644 index 00000000..b9cb27da --- /dev/null +++ b/gently/ui/web/routes/notebook.py @@ -0,0 +1,99 @@ +"""Notebook (shared lab notebook) read routes. + +Exposes the notebook's Notes for the Notebook tab + Agent's-View live edge. +Read-only here; authoring/curation come in a later increment. +""" + +from fastapi import APIRouter, Body, HTTPException + +from gently.harness.memory.notebook import Author, NoteKind, NoteStatus, note_to_dict + + +def _coerce(enum_cls, value): + """Parse a query-param string into an enum; invalid/None → None (no filter).""" + if value is None: + return None + try: + return enum_cls(value) + except ValueError: + return None + + +def create_router(server) -> APIRouter: + router = APIRouter() + + def _nb(): + cs = getattr(server, "context_store", None) + return cs.notebook if cs is not None else None + + @router.get("/api/notebook/notes") + async def list_notes( + kind: str | None = None, + author: str | None = None, + status: str | None = None, + strain: str | None = None, + embryo: str | None = None, + thread: str | None = None, + limit: int | None = None, + ): + nb = _nb() + if nb is None: + return {"available": False, "notes": []} + notes = nb.query_notes( + kind=_coerce(NoteKind, kind), + author=_coerce(Author, author), + status=_coerce(NoteStatus, status), + strain=strain, + embryo=embryo, + thread=thread, + ) + if limit is not None and limit >= 0: + notes = notes[:limit] + return {"available": True, "notes": [note_to_dict(n) for n in notes]} + + @router.get("/api/notebook/notes/{note_id}") + async def get_note(note_id: str): + nb = _nb() + if nb is None: + raise HTTPException(status_code=404, detail="notebook unavailable") + note = nb.get_note(note_id) + if note is None: + raise HTTPException(status_code=404, detail="note not found") + return note_to_dict(note) + + @router.get("/api/notebook/threads") + async def list_threads(): + nb = _nb() + if nb is None: + return {"available": False, "threads": []} + counts: dict[str, int] = {} + for n in nb.query_notes(): + for t in n.threads: + counts[t] = counts.get(t, 0) + 1 + threads = [{"id": t, "count": c} for t, c in sorted(counts.items())] + return {"available": True, "threads": threads} + + @router.post("/api/notebook/ask") + async def ask( + question: str = Body(..., embed=True), + thread: str | None = Body(None, embed=True), + strain: str | None = Body(None, embed=True), + ): + nb = _nb() + if nb is None: + return {"available": False} + from gently.harness.memory.notebook_ask import answer_question, select_notes + from gently.settings import settings + + notes = select_notes(nb, thread=thread, strain=strain) + client = getattr(server, "claude_async", None) + if client is None: + import anthropic + + client = anthropic.AsyncAnthropic() + result = await answer_question(client, settings.models.main, question, notes) + result["available"] = True + result["note_ids"] = [n.id for n in notes] + return result + + return router diff --git a/gently/ui/web/routes/operation_plan.py b/gently/ui/web/routes/operation_plan.py new file mode 100644 index 00000000..a5e636eb --- /dev/null +++ b/gently/ui/web/routes/operation_plan.py @@ -0,0 +1,42 @@ +"""Operation Plan route. + +Returns the agent-authored Operation Plan for a session. +The plan is stored in FileContextStore (``server.context_store``) keyed by +session_id. ``session_id="current"`` resolves to the newest session via the +FileStore (``server.gently_store``). +""" + +from fastapi import APIRouter, HTTPException + + +def create_router(server) -> APIRouter: + router = APIRouter() + + def _resolve_session(session_id: str) -> str: + if session_id == "current": + store = getattr(server, "gently_store", None) + if store is None: + raise HTTPException( + status_code=503, detail="FileStore not configured on viz server" + ) + sessions = store.list_sessions() + if not sessions: + raise HTTPException(status_code=404, detail="No sessions in store") + session_id = sessions[0].get("session_id") + return session_id + + @router.get("/api/operation_plan/{session_id}") + async def get_operation_plan(session_id: str): + real_id = _resolve_session(session_id) + cs = getattr(server, "context_store", None) + if cs is None: + return {"session_id": real_id, "available": False, "plan": None} + try: + plan = cs.get_operation_plan(real_id) + except Exception: + plan = None + if plan is None: + return {"session_id": real_id, "available": False, "plan": None} + return {"session_id": real_id, "available": True, "plan": plan} + + return router diff --git a/gently/ui/web/routes/pages.py b/gently/ui/web/routes/pages.py index 3858f4e7..04a1a959 100644 --- a/gently/ui/web/routes/pages.py +++ b/gently/ui/web/routes/pages.py @@ -3,6 +3,8 @@ from fastapi import APIRouter, Request from fastapi.responses import HTMLResponse, RedirectResponse +from gently.settings import settings + def create_router(server) -> APIRouter: router = APIRouter() @@ -16,7 +18,9 @@ async def index(request: Request): chat window's "Sign in" affordance), not a gate on the page itself. """ return server.templates.TemplateResponse( - request, "index.html", {"active_section": "embryos", "is_live": True} + request, + "index.html", + {"active_section": "embryos", "is_live": True, "ux_v2": settings.ui.ux_v2}, ) # Standalone URLs redirect to SPA with hash fragment for tab routing diff --git a/gently/ui/web/routes/roles.py b/gently/ui/web/routes/roles.py new file mode 100644 index 00000000..057a97e4 --- /dev/null +++ b/gently/ui/web/routes/roles.py @@ -0,0 +1,30 @@ +"""Embryo Role Registry route. + +Returns the static REGISTRY of embryo roles from ``gently.harness.roles``. +Never raises a 500 — the registry is global and read-only. +""" + +from fastapi import APIRouter + +from gently.harness.roles import REGISTRY + + +def create_router(server) -> APIRouter: # noqa: ARG001 (server not needed; registry is global) + router = APIRouter() + + @router.get("/api/roles") + async def get_roles(): + roles = [ + { + "name": role.name, + "description": role.description, + "role_class": role.role_class, + "ui_color": role.ui_color, + "ui_icon": role.ui_icon, + "default_cadence_seconds": role.default_cadence_seconds, + } + for role in REGISTRY.values() + ] + return {"roles": roles} + + return router diff --git a/gently/ui/web/routes/sessions.py b/gently/ui/web/routes/sessions.py index ccbc3a93..505e7f0d 100644 --- a/gently/ui/web/routes/sessions.py +++ b/gently/ui/web/routes/sessions.py @@ -204,6 +204,29 @@ async def resume_session(session_id: str): "rehydrated_projections": rehydrated, } + @router.get("/api/sessions/{session_id}/plans") + async def get_session_plans(session_id: str): + """Plan items linked to a session, via the context store.""" + cs = getattr(server, "context_store", None) + if cs is None: + return {"plans": []} + try: + items = cs.get_plan_items_for_session(session_id) + except Exception as e: + logger.warning("get_plan_items_for_session failed for %s: %s", session_id, e) + return {"plans": []} + return { + "plans": [ + { + "id": item.id, + "title": item.title, + "campaign_id": item.campaign_id, + "status": item.status.value, + } + for item in items + ] + } + @router.get("/api/sessions/{session_id}") async def get_session(session_id: str): """Get session state for review, from the live FileStore. diff --git a/gently/ui/web/routes/tactic_library.py b/gently/ui/web/routes/tactic_library.py new file mode 100644 index 00000000..5ef12804 --- /dev/null +++ b/gently/ui/web/routes/tactic_library.py @@ -0,0 +1,27 @@ +"""Tactic Library route. + +Returns the saved tactic library from FileContextStore (``server.context_store``). +Never raises a 500 — returns an empty list when the store is absent or has no +saved tactics. +""" + +from fastapi import APIRouter + + +def create_router(server) -> APIRouter: + router = APIRouter() + + @router.get("/api/tactic_library") + async def get_tactic_library(): + cs = getattr(server, "context_store", None) + if cs is None: + return {"tactics": []} + try: + tactics = cs.list_tactics() + except Exception: + tactics = [] + if not tactics: + return {"tactics": []} + return {"tactics": tactics} + + return router diff --git a/gently/ui/web/routes/temperature.py b/gently/ui/web/routes/temperature.py new file mode 100644 index 00000000..90916181 --- /dev/null +++ b/gently/ui/web/routes/temperature.py @@ -0,0 +1,47 @@ +"""Read-only temperature history for the live graph (backfill on mount/reload). + +Live updates ride the TEMPERATURE_UPDATE event channel; this route is backfill only. +Mirrors routes/experiments.py session resolution. +""" + +import urllib.parse + +from fastapi import APIRouter, HTTPException, Request + + +def create_router(server) -> APIRouter: + router = APIRouter() + + def _resolve_session(session_id: str): + store = getattr(server, "gently_store", None) + if store is None: + raise HTTPException(status_code=503, detail="FileStore not configured on viz server") + if session_id == "current": + sessions = store.list_sessions() + if not sessions: + raise HTTPException(status_code=404, detail="No sessions in store") + session_id = sessions[0].get("session_id") + if store._session_dir(session_id) is None: + raise HTTPException(status_code=404, detail=f"Session not found: {session_id}") + return session_id + + @router.get("/api/temperature/{session_id}/history") + async def get_history(session_id: str, request: Request): + # Parse `since` from raw query string using unquote (not unquote_plus) so that + # timezone offsets like +00:00 are preserved. Standard FastAPI query-param + # parsing applies unquote_plus, which converts + to a space. + raw_qs = request.scope.get("query_string", b"").decode() + since = None + for part in raw_qs.split("&"): + if "=" in part: + k, v = part.split("=", 1) + if urllib.parse.unquote(k) == "since": + since = urllib.parse.unquote(v) + break + + real_id = _resolve_session(session_id) + store = server.gently_store + samples = store.read_temperature_log(real_id, since=since) + return {"session_id": real_id, "samples": samples} + + return router diff --git a/gently/ui/web/server.py b/gently/ui/web/server.py index 892669b4..f0194af6 100644 --- a/gently/ui/web/server.py +++ b/gently/ui/web/server.py @@ -680,7 +680,11 @@ async def wait_for_marking(self, session_id: str, timeout: float | None = None) embryos.append( { "embryo_number": m["number"], - "embryo_id": m.get("embryo_id") or f"embryo_{m['number']:03d}", + # Unpadded to match the live convention used everywhere else + # (detection_tools registers embryos as f"embryo_{n}"). A + # zero-padded fallback here produced ids like "embryo_002" + # that never matched the stored "embryo_2". + "embryo_id": m.get("embryo_id") or f"embryo_{m['number']}", "pixel_position": (px, py), "pixel_x": px, "pixel_y": py, @@ -788,13 +792,22 @@ async def on_start(self): import socket sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + # Match uvicorn's own bind semantics. uvicorn sets SO_REUSEADDR before it + # binds, so a bare preflight bind WITHOUT it is *stricter* than the real + # server: when a previous instance has just exited, its browser/websocket + # connections linger in TIME_WAIT holding this local port, and a plain + # bind() fails with EADDRINUSE even though uvicorn would bind fine. That + # false positive was the recurring "port in use" on quick restarts. With + # SO_REUSEADDR the preflight now fails only on a genuine live listener + # (a real second instance) — exactly when uvicorn would also fail. + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) try: sock.bind((self.host, self.port)) except OSError: raise OSError( - f"Port {self.port} is already in use. " - "Is another instance of the agent running? " - "Close it first and try again." + f"Port {self.port} is already in use — another instance may be running. " + f"Free it with: fuser -k {self.port}/tcp " + f"(or: lsof -ti:{self.port} | xargs -r kill), then try again." ) from None finally: sock.close() diff --git a/gently/ui/web/static/css/agent-chat.css b/gently/ui/web/static/css/agent-chat.css index fdaaa8e3..106d79bd 100644 --- a/gently/ui/web/static/css/agent-chat.css +++ b/gently/ui/web/static/css/agent-chat.css @@ -271,6 +271,17 @@ body.chat-docked .agent-chat:not(.open) { .ac-level-warning { color: var(--accent-orange, #fb923c); } .ac-level-success { color: var(--accent-green); } +/* ── Ask pointer (ux_v2) ─────────────────────────────────── */ +/* Compact transcript reference shown instead of full ask cards when the main + stage (AskStage / #ask-stage) owns the answer surface. */ +.ac-ask-pointer { + align-self: center; + font-size: 11.5px; color: var(--text-muted); + text-align: center; font-style: italic; + opacity: 0.75; +} +.ac-ask-pointer-answered { opacity: 0.35; } + /* ── Choice picker ──────────────────────────────────────── */ .ac-choice { display: flex; flex-direction: column; gap: 7px; @@ -441,3 +452,42 @@ body.chat-docked .agent-chat:not(.open) { color: var(--text-muted); cursor: pointer; font-size: 12px; line-height: 1; } .ac-queue-remove:hover { color: var(--color-danger, #f87171); } + +/* ── Rendered markdown (mdToHtml output, ac-md-* classes) ────────────────── + Shared by the chat transcript and the ux_v2 plan-wizard activity feed — the + same renderer feeds both, so these styles cover headings, lists, tables, + code blocks, quotes and links the agent emits. */ +.ac-md { line-height: 1.55; } +.ac-md > :first-child { margin-top: 0; } +.ac-md > :last-child { margin-bottom: 0; } +.ac-md-h1, .ac-md-h2, .ac-md-h3, .ac-md-h4, .ac-md-h5, .ac-md-h6 { + margin: 14px 0 6px; font-weight: 650; line-height: 1.3; letter-spacing: -.01em; color: var(--text); +} +.ac-md-h1 { font-size: 1.25em; } +.ac-md-h2 { font-size: 1.15em; } +.ac-md-h3 { font-size: 1.05em; } +.ac-md-h4, .ac-md-h5, .ac-md-h6 { font-size: 1em; } +.ac-md-p { margin: 7px 0; } +.ac-md-ul, .ac-md-ol { margin: 7px 0; padding-left: 22px; } +.ac-md-li { margin: 3px 0; } +.ac-md-quote { + margin: 8px 0; padding: 4px 12px; border-left: 3px solid var(--border, #e4e9f0); + color: var(--text-muted); font-style: italic; +} +.ac-md-hr { border: 0; border-top: 1px solid var(--border, #e4e9f0); margin: 12px 0; } +.ac-md-link { color: var(--accent, #2f6df6); text-decoration: underline; text-underline-offset: 2px; } +.ac-md-pre { + margin: 8px 0; padding: 10px 12px; border-radius: 8px; overflow-x: auto; + background: var(--bg, #f6f8fb); border: 1px solid var(--border, #e4e9f0); +} +.ac-md-pre .ac-md-code-block, .ac-md-pre code { + font-family: 'JetBrains Mono', ui-monospace, monospace; font-size: 12px; + color: var(--text); background: none; padding: 0; white-space: pre; +} +/* GFM tables — wrapped so a wide table scrolls instead of blowing out the column */ +.ac-md-table-wrap { margin: 9px 0; overflow-x: auto; border: 1px solid var(--border, #e4e9f0); border-radius: 8px; } +.ac-md-table { border-collapse: collapse; width: 100%; font-size: 12.5px; } +.ac-md-table th, .ac-md-table td { padding: 6px 10px; text-align: left; border-bottom: 1px solid var(--border, #e4e9f0); border-right: 1px solid var(--border, #e4e9f0); } +.ac-md-table th:last-child, .ac-md-table td:last-child { border-right: 0; } +.ac-md-table tr:last-child td { border-bottom: 0; } +.ac-md-table thead th { background: var(--bg, #f6f8fb); font-weight: 650; color: var(--text); } diff --git a/gently/ui/web/static/css/ask-stage.css b/gently/ui/web/static/css/ask-stage.css new file mode 100644 index 00000000..dc15c623 --- /dev/null +++ b/gently/ui/web/static/css/ask-stage.css @@ -0,0 +1,56 @@ +/* Main-stage ask surface (ux_v2): the agent's current pending ask, rendered + prominently outside the chat transcript. Reuses the .ac-choice card markup + from agent-chat.css; this file frames the stage container and adds the + shared free-text ("Something else…") escape styling. Only #ask-stage is + gated behind the flag, so loading this CSS unconditionally is harmless. */ + +.ask-stage { margin: 14px 16px 0; } +.ask-stage.hidden { display: none; } + +.ask-stage .ac-choice { + border: 1px solid var(--border, #e4e9f0); + border-radius: 14px; + padding: 16px 18px; + background: var(--surface, #fff); + box-shadow: 0 8px 28px rgba(15, 23, 42, .08); +} +.ask-stage .ac-choice-q { + font-size: 1.02rem; + font-weight: 600; + margin-bottom: 12px; +} + +/* Free-text "Something else…" escape — present on ask cards in BOTH surfaces. */ +.ac-choice-otherwrap { margin-top: 6px; } +.ac-choice-other.hidden, +.ac-choice-otherform.hidden { display: none; } +.ac-choice-otherform { display: flex; gap: 6px; align-items: center; margin-top: 4px; } +.ac-choice-otherinput { + flex: 1; min-width: 0; + padding: 8px 10px; + border: 1px solid var(--border, #cbd5e1); + border-radius: 8px; + font: inherit; + background: var(--surface, #fff); + color: inherit; +} +.ac-choice-otherinput:focus { outline: none; border-color: var(--accent, #2f6df6); } +.ac-choice-othergo { + border: 0; cursor: pointer; + background: var(--accent, #2f6df6); color: #fff; + border-radius: 8px; padding: 8px 12px; line-height: 1; +} + +/* Per-field provenance tag on imaging-spec rows (Phase 3b): shows where an + inferred value came from, e.g. "inferred · medium". */ +.ac-spec-src { + margin-left: 6px; + font-size: 10px; + letter-spacing: .02em; + color: var(--text-muted, #94a3b8); + background: var(--bg-hover, #f1f5f9); + border-radius: 999px; + padding: 1px 7px; + white-space: nowrap; + vertical-align: middle; +} diff --git a/gently/ui/web/static/css/campaigns.css b/gently/ui/web/static/css/campaigns.css index bc4f36c2..b874ab44 100644 --- a/gently/ui/web/static/css/campaigns.css +++ b/gently/ui/web/static/css/campaigns.css @@ -1284,6 +1284,89 @@ font-size: 0.7rem; } +/* Section title with a right-aligned action (e.g. Edit) */ +.detail-section-title--row { + display: flex; + align-items: center; + justify-content: space-between; +} +.detail-section-action { display: inline-flex; } +.spec-edit-btn { + border: 1px solid var(--border); + background: transparent; + color: var(--text-muted); + border-radius: 6px; + padding: 2px 8px; + font: inherit; + font-size: 0.62rem; + letter-spacing: 0.3px; + cursor: pointer; +} +.spec-edit-btn:hover { color: var(--text); border-color: var(--text-muted); } + +/* Editable imaging-spec form */ +.spec-editor { display: flex; flex-direction: column; gap: 6px; } +.spec-edit-row { + display: grid; + grid-template-columns: 38% 1fr; + align-items: center; + gap: 10px; +} +.spec-edit-label { + color: var(--text-muted); + font-size: 0.7rem; +} +.spec-edit-row--empty .spec-edit-label::after { + content: ' • set'; + color: var(--accent-orange, #d98324); + font-size: 0.6rem; + opacity: 0.8; +} +.spec-edit-field { display: flex; align-items: center; gap: 5px; } +.spec-edit-input { + flex: 1; + min-width: 0; + background: var(--bg, #fff); + border: 1px solid var(--border); + border-radius: 6px; + padding: 4px 7px; + color: var(--text); + font-family: 'SF Mono', 'Fira Code', monospace; + font-size: 0.7rem; +} +.spec-edit-input:focus { + outline: none; + border-color: var(--accent, #2f6df6); +} +.spec-edit-row--empty .spec-edit-input { + border-style: dashed; +} +.spec-edit-unit { color: var(--text-muted); font-size: 0.66rem; } +.spec-edit-error { + color: var(--accent-orange, #d98324); + font-size: 0.68rem; + margin-top: 2px; +} +.spec-edit-actions { display: flex; gap: 8px; margin-top: 8px; } +.spec-save-btn, .spec-cancel-btn { + border-radius: 7px; + padding: 5px 14px; + font: inherit; + font-size: 0.72rem; + font-weight: 600; + cursor: pointer; +} +.spec-save-btn { + background: var(--accent, #2f6df6); + color: #fff; + border: 0; +} +.spec-cancel-btn { + background: transparent; + color: var(--text-muted); + border: 1px solid var(--border); +} + /* Dependencies / Dependents chips */ .dep-list { display: flex; @@ -1352,6 +1435,109 @@ font-size: 0.62rem; color: var(--text-muted); } +.detail-session-right { + display: flex; + align-items: center; + gap: 6px; +} +.detail-session-empty { + color: var(--text-muted); + font-size: 0.75rem; + font-style: italic; +} + +/* Session link button (header action) */ +.session-link-btn { + border: 1px solid var(--border); + background: transparent; + color: var(--text-muted); + border-radius: 6px; + padding: 2px 8px; + font: inherit; + font-size: 0.65rem; + cursor: pointer; + white-space: nowrap; + transition: color 0.15s, border-color 0.15s; +} +.session-link-btn:hover { + color: var(--text); + border-color: var(--accent, #2f6df6); +} + +/* Per-session delink button */ +.session-delink-btn { + background: transparent; + border: none; + color: var(--text-muted); + cursor: pointer; + font-size: 1rem; + line-height: 1; + padding: 1px 4px; + border-radius: 4px; + opacity: 0.45; + transition: opacity 0.15s, color 0.15s, background 0.15s; +} +.session-delink-btn:hover { + opacity: 1; + color: var(--accent-orange, #f97316); + background: rgba(249,115,22,0.12); +} + +/* Inline session picker */ +.session-picker { + margin-top: 8px; + padding: 10px; + background: rgba(0,0,0,0.18); + border: 1px solid var(--border); + border-radius: 8px; +} +.session-picker--loading { + font-size: 0.75rem; + color: var(--text-muted); + font-style: italic; +} +.session-picker-select { + width: 100%; + background: var(--bg-dark, #0f172a); + color: var(--text); + border: 1px solid var(--border); + border-radius: 6px; + padding: 6px 8px; + font: inherit; + font-size: 0.75rem; + margin-bottom: 8px; + box-sizing: border-box; +} +.session-picker-actions { + display: flex; + gap: 6px; +} +.session-picker-link-btn { + flex: 1; + background: var(--accent, #2f6df6); + color: #fff; + border: none; + border-radius: 6px; + padding: 5px 12px; + font: inherit; + font-size: 0.72rem; + font-weight: 600; + cursor: pointer; + transition: opacity 0.15s; +} +.session-picker-link-btn:hover { opacity: 0.85; } +.session-picker-cancel-btn { + background: transparent; + color: var(--text-muted); + border: 1px solid var(--border); + border-radius: 6px; + padding: 5px 12px; + font: inherit; + font-size: 0.72rem; + cursor: pointer; + transition: color 0.15s; +} +.session-picker-cancel-btn:hover { color: var(--text); } /* ================================================================ SHARED COMPONENTS diff --git a/gently/ui/web/static/css/experiment.css b/gently/ui/web/static/css/experiment.css index 9b343378..ee40cddd 100644 --- a/gently/ui/web/static/css/experiment.css +++ b/gently/ui/web/static/css/experiment.css @@ -634,3 +634,1205 @@ transform: rotate(45deg); flex-shrink: 0; } + +/* ============================================================ + Operation Spine — data-driven tactic plan renderer + All classes namespaced ops- to avoid collision. + ============================================================ */ + +:root { + --ops-done: #34d399; + --ops-active: #f5a623; + --ops-plan: #5aa9e6; + --ops-live: #22d3ee; + --ops-mono: ui-monospace, SFMono-Regular, Menlo, monospace; +} + +.ops-wrap { + max-width: 760px; + margin: 0 auto; + padding: 8px 4px 48px; +} + +.ops-crumb { + font-family: var(--ops-mono); + font-size: 11px; + letter-spacing: 0.16em; + color: var(--text-muted); + text-transform: uppercase; + margin-bottom: 6px; +} + +.ops-title { + font-size: 22px; + font-weight: 600; + margin: 0 0 4px; + letter-spacing: -0.01em; + color: var(--text); +} + +.ops-meta { + font-family: var(--ops-mono); + font-size: 12px; + color: var(--text-muted); + margin-bottom: 4px; +} + +.ops-legend { + display: flex; + gap: 16px; + font-family: var(--ops-mono); + font-size: 10.5px; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.08em; + margin: 14px 0 20px; +} + +.ops-legend i { + display: inline-block; + width: 8px; + height: 8px; + border-radius: 2px; + margin-right: 6px; + vertical-align: 1px; +} + +.ops-empty { + font-family: var(--ops-mono); + color: var(--text-muted); + font-size: 13px; + border: 1px dashed var(--border); + border-radius: 12px; + padding: 30px; + text-align: center; + margin-top: 18px; +} + +/* ---- Idle CTA — "set up an operation" ---------------------- */ + +.ops-setup-cta { + margin-top: 28px; + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 16px; +} + +.ops-brief-btn { + background: var(--ops-plan, #5aa9e6); + color: #05101e; + border: none; + border-radius: 8px; + padding: 10px 22px; + font: inherit; + font-weight: 700; + font-size: 14px; + letter-spacing: 0.01em; + cursor: pointer; + transition: opacity 0.15s; +} +.ops-brief-btn:hover { opacity: 0.85; } + +.ops-chips-label { + font-family: var(--ops-mono); + font-size: 11px; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--text-muted); +} + +.ops-chips { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.ops-chip { + background: transparent; + color: var(--text-muted); + border: 1px solid var(--border, #30363d); + border-radius: 20px; + padding: 6px 14px; + font: inherit; + font-family: var(--ops-mono); + font-size: 12px; + cursor: pointer; + transition: color 0.15s, border-color 0.15s; +} +.ops-chip:hover { + color: var(--text, #e6edf3); + border-color: var(--ops-plan, #5aa9e6); +} + +/* ---- Spine ------------------------------------------------- */ + +.ops-spine { + position: relative; + margin-left: 14px; + padding-left: 30px; + border-left: 2px solid var(--border); +} + +/* Tactic node */ +.ops-node { + position: relative; + margin-bottom: 14px; +} + +/* Timeline dot — overlaps the spine border-left */ +.ops-node::before { + content: ""; + position: absolute; + left: -39px; + top: 4px; + width: 14px; + height: 14px; + border-radius: 50%; + background: var(--bg-card, #161b22); + border: 2px solid var(--border); +} + +.ops-node.done::before { + border-color: var(--ops-done); + background: var(--ops-done); +} + +.ops-node.active::before { + border-color: var(--ops-active); + background: var(--ops-active); + box-shadow: 0 0 0 5px rgba(245, 166, 35, 0.14); +} + +/* AUDIT: queued = cocked instrument — dashed blue dot (not empty) */ +.ops-node.planned::before { + border-color: var(--ops-plan); + border-style: dashed; + background: var(--bg-card, #161b22); +} + +/* ---- Stage label: "01 · in use" ----------------------------- */ + +.ops-stagelab { + font-family: var(--ops-mono); + font-size: 10px; + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--text-muted); + margin-bottom: 4px; + display: flex; + align-items: center; + gap: 6px; +} + +/* AUDIT: active row's WHOLE LEFT COLUMN amber (dot + seq + "IN USE") */ +.ops-node.active .ops-stagelab { color: var(--ops-active); } +.ops-node.planned .ops-stagelab { color: var(--ops-plan); } + +/* AUDIT: "next" badge on first queued tactic — cocked-instrument marker */ +.ops-next-badge { + display: inline-block; + padding: 1px 7px; + border-radius: 9px; + background: rgba(90, 169, 230, 0.15); + border: 1px solid rgba(90, 169, 230, 0.45); + color: var(--ops-plan); + font-size: 9px; + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; +} + +/* ---- Card --------------------------------------------------- */ + +.ops-card { + background: var(--bg-card, #161b22); + border: 1px solid var(--border, #30363d); + border-radius: 12px; + padding: 13px 16px; +} + +/* AUDIT: colored LEFT EDGE per state */ +/* done — green left edge, transparent bg */ +.ops-node.done .ops-card { + background: transparent; + border-color: rgba(48, 54, 61, 0.5); + padding: 9px 16px; + border-left: 3px solid var(--ops-done); +} + +/* active — amber left edge + tinted gradient + FLATTENED (no card-in-card) */ +.ops-node.active .ops-card { + border-color: rgba(245, 166, 35, 0.3); + border-left: 3px solid var(--ops-active); + background: linear-gradient(180deg, rgba(245, 166, 35, 0.05), transparent), + var(--bg-card, #161b22); +} + +/* planned — blue left edge, dashed border */ +.ops-node.planned .ops-card { + background: transparent; + border-style: dashed; + border-left: 3px solid var(--ops-plan); + border-left-style: solid; + opacity: 0.85; +} + +/* Hairline rule between card header and live readouts (flattened layout) */ +.ops-rule { + border: none; + border-top: 1px solid rgba(245, 166, 35, 0.18); + margin: 10px 0; +} + +/* ---- Card contents ----------------------------------------- */ + +.ops-row { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} + +.ops-tname { + font-size: 14.5px; + font-weight: 600; + color: var(--text); +} + +.ops-node.done .ops-tname { + font-weight: 500; + color: var(--text-muted); +} + +.ops-target { + font-family: var(--ops-mono); + font-size: 12.5px; + color: var(--ops-active); + font-weight: 600; +} + +.ops-tsum { + font-family: var(--ops-mono); + font-size: 11.5px; + color: var(--text-muted); + margin-left: auto; +} + +.ops-desc { + font-family: var(--ops-mono); + font-size: 11px; + color: var(--text-muted); + opacity: 0.75; + margin: 4px 0 0; + line-height: 1.4; +} + +/* ---- Gauge strip — AUDIT: flat on panel face, no card-in-card */ + +.ops-live-strip { + display: flex; + gap: 10px; + flex-wrap: wrap; +} + +.ops-gauge { + flex: 1; + min-width: 140px; + background: var(--bg-hover, #21262d); + border: 1px solid var(--border, #30363d); + border-radius: 9px; + padding: 9px 11px; +} + +.ops-gl { + font-family: var(--ops-mono); + font-size: 9.5px; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--text-muted); +} + +/* AUDIT: mono instrument values */ +.ops-gv { + font-family: var(--ops-mono); + font-size: 18px; + font-weight: 650; + font-variant-numeric: tabular-nums; + color: var(--text); + margin-top: 2px; +} + +.ops-set { color: var(--ops-active); font-size: 13px; } +.ops-u { color: var(--text-muted); font-size: 12px; } + +.ops-gsub { + font-family: var(--ops-mono); + font-size: 11px; + color: var(--text-muted); + margin-top: 6px; +} + +.ops-tempbar { + height: 5px; + border-radius: 3px; + background: var(--bg-hover, #21262d); + margin-top: 8px; + overflow: hidden; +} + +.ops-tempbar > i { + display: block; + height: 100%; + background: linear-gradient(90deg, var(--ops-live), var(--ops-active)); +} + +/* ---- Scripted protocol — phase stepper --------------------- */ + +.ops-phases { + display: flex; + gap: 7px; + flex-wrap: wrap; + margin-top: 8px; +} + +.ops-ph { + flex: 1; + min-width: 110px; + border: 1px solid var(--border, #30363d); + border-radius: 8px; + padding: 9px 10px; + background: var(--bg-hover, #21262d); +} + +.ops-ph.done { border-color: rgba(52, 211, 153, 0.35); background: rgba(52, 211, 153, 0.05); } +.ops-ph.active { border-color: rgba(245, 166, 35, 0.45); background: rgba(245, 166, 35, 0.08); } +.ops-ph.todo { opacity: 0.6; } + +.ops-pht { + font-family: var(--ops-mono); + font-size: 10px; + letter-spacing: 0.08em; + text-transform: uppercase; + display: flex; + gap: 6px; + align-items: center; + color: var(--text-muted); +} + +/* AUDIT: ACTIVE phase is the HEADLINE — largest in its phase */ +.ops-ph.active .ops-pht { + font-size: 12px; + font-weight: 700; + color: var(--ops-active); +} + +.ops-pi { + width: 15px; + height: 15px; + border-radius: 50%; + display: grid; + place-items: center; + font-size: 9px; + font-weight: 700; + flex-shrink: 0; +} + +.ops-ph.done .ops-pi { background: var(--ops-done); color: #062b1d; } +.ops-ph.active .ops-pi { background: var(--ops-active); color: #3a2607; } +.ops-ph.todo .ops-pi { background: var(--bg-hover, #21262d); color: var(--text-muted); border: 1px solid var(--border); } + +.ops-phc { + font-family: var(--ops-mono); + font-size: 11px; + color: var(--text-muted); + margin-top: 4px; +} + +/* AUDIT: active phase count also headline-sized */ +.ops-ph.active .ops-phc { font-size: 13px; font-weight: 600; color: var(--ops-active); } + +.ops-pips { display: flex; gap: 3px; margin-top: 7px; flex-wrap: wrap; } + +.ops-pip { width: 12px; height: 7px; border-radius: 2px; background: var(--bg-hover, #21262d); } +.ops-pip.before { background: var(--ops-plan); } +.ops-pip.during { background: var(--ops-active); } +.ops-pip.after { background: var(--ops-done); } +.ops-pip.pending { border: 1px dashed var(--border); background: transparent; } + +/* ---- Per-embryo cadence strip — standing_timelapse --------- */ + +.ops-cadence-strip { + display: flex; + flex-direction: column; + gap: 5px; + padding: 8px 10px; + background: var(--bg-hover, #21262d); + border: 1px solid var(--border, #30363d); + border-radius: 8px; +} + +.ops-cadence-embryo { + display: flex; + align-items: center; + gap: 10px; + font-family: var(--ops-mono); + font-size: 11.5px; +} + +.ops-cadence-id { + color: var(--text); + font-weight: 600; + min-width: 32px; +} + +.ops-cadence-phase { + padding: 2px 8px; + border-radius: 9px; + font-size: 10px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; +} + +.ops-cadence-phase.normal { background: rgba(139, 148, 158, 0.2); color: var(--text-muted); } +.ops-cadence-phase.fast { background: rgba(251, 146, 60, 0.2); color: var(--accent-orange, #fb923c); } +.ops-cadence-phase.burst { background: rgba(239, 68, 68, 0.2); color: #f87171; } +.ops-cadence-phase.paused { background: rgba(90, 169, 230, 0.15); color: var(--ops-plan); } + +.ops-cadence-val { color: var(--text-muted); margin-left: auto; } + +/* ---- Reactive monitor — armed/watching/fired badge --------- */ + +.ops-monitor-status { + display: inline-block; + margin-top: 8px; + padding: 3px 10px; + border-radius: 9px; + font-family: var(--ops-mono); + font-size: 10px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.1em; +} + +.ops-monitor-armed { background: rgba(90, 169, 230, 0.15); color: var(--ops-plan); border: 1px solid rgba(90, 169, 230, 0.35); } +.ops-monitor-watching { background: rgba(245, 166, 35, 0.12); color: var(--ops-active); border: 1px solid rgba(245, 166, 35, 0.3); } +.ops-monitor-fired { background: rgba(52, 211, 153, 0.12); color: var(--ops-done); border: 1px solid rgba(52, 211, 153, 0.3); } + +/* Planned kind-detail snippets (cadence note, watch hint) */ +.ops-cadence-note, +.ops-monitor-watch { + display: inline-block; + margin-top: 6px; + font-family: var(--ops-mono); + font-size: 11px; + color: var(--text-muted); + opacity: 0.75; +} + +/* ---- Fix #1: bound live.* telemetry facts strip --------------- */ +/* Compact monospace key:value row rendered for active + done tactics + when the live object carries flat keys beyond readouts/phases/target. + Subtle instrument aesthetic — matches the ops-mono system, sits below + the structured readout strip without visual weight. */ + +.ops-livefacts { + display: flex; + flex-wrap: wrap; + gap: 6px 16px; + margin-top: 8px; + padding: 6px 10px; + background: var(--bg-hover, #21262d); + border: 1px solid var(--border, #30363d); + border-radius: 7px; + font-family: var(--ops-mono); + font-size: 11px; + line-height: 1.5; +} + +.ops-lf-pair { + display: inline-flex; + align-items: baseline; + gap: 5px; +} + +.ops-lf-k { + color: var(--text-muted); + font-size: 10px; + letter-spacing: 0.06em; + text-transform: lowercase; +} + +.ops-lf-v { + color: var(--text); + font-weight: 500; + font-variant-numeric: tabular-nums; +} + +/* ---- Fix #5: paused tactic state ----------------------------- */ +/* Muted grey dot + solid grey left edge — visually distinct from + done (green), active (amber), and queued (dashed blue). */ + +.ops-node.paused::before { + border-color: var(--text-muted, #8b949e); + background: var(--bg-card, #161b22); + opacity: 0.7; +} + +.ops-node.paused .ops-stagelab { + color: var(--text-muted, #8b949e); +} + +.ops-node.paused .ops-card { + background: transparent; + border-color: rgba(139, 148, 158, 0.3); + border-left: 3px solid var(--text-muted, #8b949e); + opacity: 0.8; +} + +/* ============================================================ + D2 — Roster Lens (.ops-roster* classes) + Role accent colors are injected at runtime via inline style + from /api/roles — never hardcoded here so the real REGISTRY + palette (magenta test #ff66cc, cyan calibration #00cccc, + teal lineaging #33cc88) drives the rendering. + ============================================================ */ + +/* Sub-label between roster block and spine */ +.ops-section-label { + font-family: var(--ops-mono); + font-size: 9.5px; + letter-spacing: 0.18em; + text-transform: uppercase; + color: var(--text-muted); + opacity: 0.65; + margin: 0 0 10px; +} + +/* Roster container */ +.ops-roster { + margin-bottom: 28px; + border: 1px solid var(--border, #30363d); + border-radius: 12px; + overflow: hidden; +} + +.ops-roster-head { + display: flex; + align-items: center; + gap: 10px; + padding: 9px 16px; + background: var(--bg-hover, #21262d); + border-bottom: 1px solid var(--border, #30363d); +} + +.ops-roster-title { + font-family: var(--ops-mono); + font-size: 10px; + letter-spacing: 0.16em; + text-transform: uppercase; + color: var(--text-muted); +} + +.ops-roster-count { + font-family: var(--ops-mono); + font-size: 10px; + color: var(--text-muted); + margin-left: auto; +} + +/* Class section (SUBJECTS / REFERENCES) */ +.ops-class-section { + border-bottom: 1px solid var(--border, #30363d); +} + +.ops-class-section:last-child { + border-bottom: none; +} + +.ops-class-header { + display: flex; + align-items: center; + gap: 10px; + padding: 7px 16px; + border-bottom: 1px solid var(--border, #30363d); +} + +.ops-class-header.subject { background: rgba(245, 166, 35, 0.04); } +.ops-class-header.reference { background: rgba(139, 148, 158, 0.06); } + +.ops-class-label { + font-family: var(--ops-mono); + font-size: 9px; + font-weight: 700; + letter-spacing: 0.2em; + text-transform: uppercase; +} + +.ops-class-header.subject .ops-class-label { color: var(--ops-active); } +.ops-class-header.reference .ops-class-label { color: var(--text-muted); } + +.ops-class-desc { + font-family: var(--ops-mono); + font-size: 9.5px; + color: var(--text-muted); + opacity: 0.65; +} + +/* Pulsing live dot in the SUBJECTS header */ +.ops-class-live-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--ops-active); + box-shadow: 0 0 0 3px rgba(245, 166, 35, 0.2); + animation: ops-roster-pulse 2s ease-in-out infinite; + flex-shrink: 0; +} + +@keyframes ops-roster-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.5; } +} + +/* Role group within a class section */ +.ops-role-group { + border-bottom: 1px solid rgba(48, 54, 61, 0.5); +} + +.ops-role-group:last-child { + border-bottom: none; +} + +/* Role group header — left edge color injected via inline style */ +.ops-role-header { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 16px; + border-left: 3px solid transparent; +} + +.ops-role-name { + font-family: var(--ops-mono); + font-size: 10px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.12em; + /* color injected via inline style */ +} + +.ops-role-sep { + font-family: var(--ops-mono); + font-size: 10px; + color: var(--text-muted); +} + +.ops-role-count { + font-family: var(--ops-mono); + font-size: 10px; + color: var(--text-muted); +} + +.ops-role-ids { + font-family: var(--ops-mono); + font-size: 10px; + color: var(--text-muted); + margin-left: 2px; +} + +/* Embryo rows — subjects full size, references compact */ +.ops-roster-embryo { + display: flex; + align-items: center; + gap: 10px; + padding: 6px 16px 6px 20px; + border-bottom: 1px solid rgba(48, 54, 61, 0.4); + font-family: var(--ops-mono); + font-size: 11.5px; + transition: background 0.1s; +} + +.ops-roster-embryo:last-child { border-bottom: none; } +.ops-roster-embryo:hover { background: var(--bg-hover, #21262d); } + +.ops-roster-embryo.compact { + padding: 4px 16px 4px 20px; + font-size: 10.5px; + opacity: 0.8; +} + +/* Embryo row fields */ +.ops-rem-id { + color: var(--text); + font-weight: 600; + min-width: 32px; +} + +/* Role chip — background/color/border via inline style from /api/roles */ +.ops-rem-role-chip { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 1px 7px; + border-radius: 9px; + font-size: 9.5px; + font-weight: 700; + text-transform: lowercase; + letter-spacing: 0.04em; + border: 1px solid transparent; + white-space: nowrap; + flex-shrink: 0; +} + +.ops-rem-strain { + color: var(--text-muted); + font-size: 10.5px; + opacity: 0.8; + flex: 0 0 auto; + max-width: 160px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* Cadence phase chip — same visual system as .ops-cadence-phase */ +.ops-rem-phase { + padding: 2px 8px; + border-radius: 9px; + font-size: 10px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + flex-shrink: 0; +} + +.ops-rem-phase.normal { background: rgba(139, 148, 158, 0.2); color: var(--text-muted); } +.ops-rem-phase.fast { background: rgba(251, 146, 60, 0.2); color: var(--accent-orange, #fb923c); } +.ops-rem-phase.burst { background: rgba(239, 68, 68, 0.2); color: #f87171; } +.ops-rem-phase.paused { background: rgba(90, 169, 230, 0.15); color: var(--ops-plan); } + +.ops-rem-tactic { + color: var(--text); + font-size: 11px; + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ops-rem-state { + font-size: 10px; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.08em; + flex-shrink: 0; +} + +.ops-rem-state.active { color: var(--ops-active); } +.ops-rem-state.done { color: var(--ops-done); } + +/* Scope badge on tactic spine nodes */ +.ops-scope-badge { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 9px; + border-radius: 9px; + font-family: var(--ops-mono); + font-size: 10px; + font-weight: 600; + border: 1px solid transparent; + text-transform: lowercase; + letter-spacing: 0.02em; + white-space: nowrap; +} + +/* Global / explicit-embryos scope — static muted style */ +.ops-scope-global { + background: rgba(139, 148, 158, 0.15); + color: var(--text-muted); + border-color: rgba(139, 148, 158, 0.3); +} +/* Role-scoped badges use inline style (color/bg/border from /api/roles) */ + +/* ============================================================ + Expandable tactic detail — click-to-expand for queued/done/paused + (G — spine-cards) + ============================================================ */ + +/* Compact inline scope chip — always visible in collapsed header. + Distinct from .ops-scope-badge (which resolves embryo ids and is full-width). + Role-scoped chips use inline style for color/bg/border from /api/roles. */ +.ops-scope-chip { + display: inline-flex; + align-items: center; + padding: 1px 7px; + border-radius: 9px; + font-family: var(--ops-mono); + font-size: 9.5px; + font-weight: 600; + border: 1px solid transparent; + text-transform: lowercase; + letter-spacing: 0.02em; + white-space: nowrap; + flex-shrink: 0; +} +/* Neutral global/embryos variant */ +.ops-scope-chip.ops-scope-global { + background: rgba(139, 148, 158, 0.12); + color: var(--text-muted); + border-color: rgba(139, 148, 158, 0.25); +} + +/* Cursor affordance on the card itself */ +.ops-card[data-tactic-expand-id] { + cursor: pointer; + transition: border-color 0.15s; +} +.ops-node.planned .ops-card[data-tactic-expand-id]:hover { + border-color: rgba(90, 169, 230, 0.5); +} +.ops-node.done .ops-card[data-tactic-expand-id]:hover { + border-color: rgba(52, 211, 153, 0.4); +} +.ops-node.paused .ops-card[data-tactic-expand-id]:hover { + border-color: rgba(139, 148, 158, 0.45); +} + +/* Chevron toggle — pushes to the right edge of the header row */ +.ops-expand-chevron { + display: inline-flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + margin-left: auto; + flex-shrink: 0; + background: none; + border: none; + color: var(--text-muted); + font-size: 15px; + cursor: pointer; + transition: transform 0.2s ease, color 0.15s; + padding: 0; + line-height: 1; + opacity: 0.6; +} +.ops-expand-chevron.open { + transform: rotate(90deg); + color: var(--text); + opacity: 1; +} +.ops-card[data-tactic-expand-id]:hover .ops-expand-chevron { + opacity: 1; +} + +/* Expand body — hidden/visible via JS toggling .hidden */ +.ops-expand-body { + margin-top: 10px; +} +.ops-expand-body.hidden { + display: none; +} + +/* Hairline rule separating header from expand detail */ +.ops-expand-rule { + border: none; + border-top: 1px solid rgba(139, 148, 158, 0.15); + margin: 8px 0 10px; +} + +/* Key/value rows */ +.ops-expand-row { + display: flex; + align-items: flex-start; + gap: 10px; + margin-bottom: 6px; + font-family: var(--ops-mono); + font-size: 11px; + line-height: 1.45; +} + +.ops-expand-key { + flex: 0 0 76px; + text-transform: uppercase; + font-size: 9px; + letter-spacing: 0.12em; + color: var(--text-muted); + margin-top: 1px; + opacity: 0.7; +} + +.ops-expand-val { + flex: 1; + color: var(--text); + word-break: break-word; +} + +.ops-expand-mono { + font-variant-numeric: tabular-nums; +} + +/* Phase chain: ramp → hold */ +.ops-expand-phases { + display: inline-flex; + align-items: center; + flex-wrap: wrap; + gap: 4px; +} + +.ops-expand-phase { + display: inline-block; + padding: 1px 7px; + border-radius: 6px; + background: rgba(139, 148, 158, 0.1); + border: 1px solid rgba(139, 148, 158, 0.2); + color: var(--text-muted); + font-size: 10px; + text-transform: lowercase; +} + +.ops-expand-arrow { + color: var(--text-muted); + font-size: 10px; + opacity: 0.45; +} + +/* Readouts strip inside the expand body — inherits ops-gauge, slightly muted */ +.ops-expand-readouts { + display: flex; + gap: 8px; + flex-wrap: wrap; + margin-top: 8px; +} +.ops-expand-readouts .ops-gauge { + opacity: 0.85; +} + +/* ============================================================ + Linked-plans panel (ops-lp-*) — F / Task 4 + Session ↔ plan-item link/delink from the Operations view. + Appended below the tactic spine + roster; matches ops-* aesthetic. + ============================================================ */ + +.ops-lp { + margin-top: 28px; + border: 1px solid var(--border, #30363d); + border-radius: 12px; + overflow: hidden; +} + +/* Section header row: "Linked plans" label + "+ link to a plan" button */ +.ops-lp-head { + display: flex; + align-items: center; + gap: 10px; + padding: 9px 16px; + background: var(--bg-hover, #21262d); + border-bottom: 1px solid var(--border, #30363d); +} + +.ops-lp-title { + font-family: var(--ops-mono); + font-size: 10px; + letter-spacing: 0.16em; + text-transform: uppercase; + color: var(--text-muted); + flex: 1; +} + +/* "+ link to a plan" button — ghost style matching session-link-btn */ +.ops-lp-link-btn { + border: 1px solid var(--border, #30363d); + background: transparent; + color: var(--text-muted); + border-radius: 6px; + padding: 2px 9px; + font: inherit; + font-family: var(--ops-mono); + font-size: 10px; + cursor: pointer; + white-space: nowrap; + transition: color 0.15s, border-color 0.15s; +} +.ops-lp-link-btn:hover { + color: var(--text); + border-color: var(--ops-plan, #5aa9e6); +} + +/* Empty state */ +.ops-lp-empty { + padding: 14px 16px; + font-family: var(--ops-mono); + font-size: 11px; + color: var(--text-muted); + font-style: italic; +} + +/* Loading / transient state */ +.ops-lp-loading { + padding: 12px 16px; + font-family: var(--ops-mono); + font-size: 11px; + color: var(--text-muted); + font-style: italic; +} + +/* List of linked plan-item rows */ +.ops-lp-list { + padding: 6px 0; +} + +/* Single linked plan row: title · campaign · status · delink */ +.ops-lp-row { + display: flex; + align-items: center; + gap: 10px; + padding: 7px 14px; + border-bottom: 1px solid rgba(48, 54, 61, 0.5); + font-family: var(--ops-mono); + font-size: 11.5px; + transition: background 0.1s; +} +.ops-lp-row:last-child { border-bottom: none; } +.ops-lp-row:hover { background: var(--bg-hover, #21262d); } + +.ops-lp-row-title { + font-size: 12px; + color: var(--text); + font-weight: 500; + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ops-lp-row-campaign { + font-size: 10px; + color: var(--text-muted); + opacity: 0.8; + flex-shrink: 0; + max-width: 160px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ops-lp-row-status { + font-size: 9.5px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.07em; + padding: 2px 7px; + border-radius: 9px; + flex-shrink: 0; +} + +/* Status colour variants mirroring ops- state palette */ +.ops-lp-status-planned { + background: rgba(90, 169, 230, 0.12); + color: var(--ops-plan, #5aa9e6); + border: 1px solid rgba(90, 169, 230, 0.3); +} +.ops-lp-status-active { + background: rgba(245, 166, 35, 0.12); + color: var(--ops-active, #f5a623); + border: 1px solid rgba(245, 166, 35, 0.3); +} +.ops-lp-status-done { + background: rgba(52, 211, 153, 0.1); + color: var(--ops-done, #34d399); + border: 1px solid rgba(52, 211, 153, 0.25); +} + +/* Per-row delink (×) button */ +.ops-lp-delink { + background: transparent; + border: none; + color: var(--text-muted); + cursor: pointer; + font-size: 1rem; + line-height: 1; + padding: 1px 4px; + border-radius: 4px; + opacity: 0.4; + flex-shrink: 0; + transition: opacity 0.15s, color 0.15s, background 0.15s; +} +.ops-lp-delink:hover { + opacity: 1; + color: var(--accent-orange, #f97316); + background: rgba(249, 115, 22, 0.12); +} + +/* Inline plan-item picker */ +.ops-lp-picker { + margin: 8px 14px 12px; + padding: 10px; + background: rgba(0, 0, 0, 0.18); + border: 1px solid var(--border, #30363d); + border-radius: 8px; +} +.ops-lp-picker--loading { + font-size: 11px; + color: var(--text-muted); + font-style: italic; + font-family: var(--ops-mono); + background: none; + border: none; + padding: 10px 14px 12px; + margin: 0; +} + +.ops-lp-picker-sel { + width: 100%; + background: var(--bg-dark, #0f172a); + color: var(--text); + border: 1px solid var(--border, #30363d); + border-radius: 6px; + padding: 6px 8px; + font: inherit; + font-size: 11.5px; + font-family: var(--ops-mono); + margin-bottom: 8px; + box-sizing: border-box; +} + +.ops-lp-picker-actions { + display: flex; + gap: 6px; +} + +.ops-lp-picker-link-btn { + flex: 1; + background: var(--ops-plan, #5aa9e6); + color: #05101e; + border: none; + border-radius: 6px; + padding: 5px 12px; + font: inherit; + font-family: var(--ops-mono); + font-size: 11px; + font-weight: 700; + cursor: pointer; + transition: opacity 0.15s; +} +.ops-lp-picker-link-btn:hover { opacity: 0.85; } + +.ops-lp-picker-cancel-btn { + background: transparent; + color: var(--text-muted); + border: 1px solid var(--border, #30363d); + border-radius: 6px; + padding: 5px 12px; + font: inherit; + font-family: var(--ops-mono); + font-size: 11px; + cursor: pointer; + transition: color 0.15s; +} +.ops-lp-picker-cancel-btn:hover { color: var(--text); } diff --git a/gently/ui/web/static/css/landing.css b/gently/ui/web/static/css/landing.css new file mode 100644 index 00000000..046675de --- /dev/null +++ b/gently/ui/web/static/css/landing.css @@ -0,0 +1,462 @@ +/* ux_v2 landing — the agent-first welcome that the prototype sketched, ported + into production. A full-bleed overlay shown on first entry that recedes into + the workspace once the user picks a path. Everything is scoped under + body.ux-v2 and the #v2-landing node only renders when the flag is on, so v1 + is byte-for-byte untouched. Visual language mirrors ux-prototype/landing.html + but reuses production's CSS variables (with the prototype hexes as fallback) + so it tracks the app theme. */ + +/* ux_v2 landing fills the gaps in the production token set (main.css defines + --bg-dark/-card/-hover, --border, --text, --text-muted, --accent, --accent-green + but NOT a page-bg alias, a secondary-text, or accent tints). Scope to + body.ux-v2 so v1 is untouched; both themes resolved here so landing.css can + reference these like any real token. dark is the default theme (main.css :root). */ +body.ux-v2 { + --bg: var(--bg-dark); /* page background, theme-aware */ + --text-secondary: var(--text-muted); + --accent-soft: rgba(96,165,250,.15); /* tint of dark --accent #60a5fa */ + --accent-green-soft: rgba(74,222,128,.15); /* tint of dark --accent-green */ + /* one disciplined type scale for the landing/plan surface */ + --v2-fs-body: 14px; + --v2-fs-sm: 13px; + --v2-fs-cap: 12px; + --v2-fs-eyebrow: 11px; +} +body.ux-v2[data-theme="light"] { + --accent-soft: rgba(59,130,246,.10); /* tint of light --accent #3b82f6 */ + --accent-green-soft: rgba(34,197,94,.12); /* tint of light --accent-green */ +} +/* accent-keyed glows can't put var() inside rgba channels, so the dark defaults + live on the elements (re-keyed off the dead #2f6df6 onto the real #60a5fa) and + light overrides ride here next to the tokens. */ +body.ux-v2[data-theme="light"] .v2-landing-orb { box-shadow: 0 6px 22px rgba(59,130,246,.35), inset 0 0 12px rgba(255,255,255,.6); } +body.ux-v2[data-theme="light"] .v2-escape-field input:focus { box-shadow: 0 0 0 4px rgba(59,130,246,.12); } +body.ux-v2[data-theme="light"] .v2-escape-send { box-shadow: 0 6px 16px rgba(59,130,246,.35); } + +.v2-landing { + position: fixed; + inset: 0; + z-index: 200; + display: flex; + align-items: flex-start; /* BOTH screens top-anchored — no discrete switch on swap */ + justify-content: center; + padding: 24px; + overflow: hidden; + background: + radial-gradient(1100px 700px at 78% -8%, var(--accent-soft) 0%, transparent 55%), + radial-gradient(900px 600px at 8% 108%, var(--accent-green-soft) 0%, transparent 55%), + var(--bg); + transition: opacity .5s cubic-bezier(.22,1,.36,1), transform .5s cubic-bezier(.22,1,.36,1), visibility .5s; +} +/* The calm screen "unfolds" into the workspace: fade + slight scale-up, then + the node is pulled from the layout (display:none set by JS after the + transition) so it never traps clicks. */ +.v2-landing.dismissed { + opacity: 0; + visibility: hidden; + transform: scale(1.015); + pointer-events: none; +} +.v2-landing::before { + content: ""; + position: absolute; + inset: -20vmax; + background: radial-gradient(closest-side, var(--accent-soft), transparent 70%); + filter: blur(30px); + animation: v2land-drift 26s cubic-bezier(.22,1,.36,1) infinite alternate; + will-change: transform; + pointer-events: none; +} +@keyframes v2land-drift { + 0% { transform: translate(-6vw,-4vh) scale(1); } + 100% { transform: translate(8vw,6vh) scale(1.15); } +} + +.v2-landing-inner { + position: relative; + z-index: 1; + display: flex; + flex-direction: column; + align-items: center; + max-width: 760px; + width: 100%; + margin-top: 7vh; /* shared anchor for welcome AND plan — orb stays put on swap */ + margin-bottom: 5vh; +} +.v2-landing-rise { animation: v2land-rise .6s cubic-bezier(.22,1,.36,1) backwards; } +.v2-landing-rise[data-i="1"] { animation-delay: .07s; } +.v2-landing-rise[data-i="2"] { animation-delay: .14s; } +.v2-landing-rise[data-i="3"] { animation-delay: .21s; } +@keyframes v2land-rise { from { opacity: 0; transform: translateY(14px); } to { opacity: 1; transform: none; } } + +/* agent presence */ +.v2-landing-agent { display: flex; flex-direction: column; align-items: center; gap: 16px; } +.v2-landing-orb { + width: 52px; height: 52px; border-radius: 50%; + background: radial-gradient(closest-side at 38% 34%, #ffffff, #bcd3ff 40%, var(--accent, #2f6df6) 100%); + box-shadow: 0 6px 22px rgba(96,165,250,.45), inset 0 0 12px rgba(255,255,255,.6); + animation: v2land-breathe 4s ease-in-out infinite; +} +@keyframes v2land-breathe { 0%,100% { transform: scale(1); } 50% { transform: scale(1.06); } } +.v2-landing-say { + font-size: clamp(20px, 3vw, 28px); font-weight: 600; letter-spacing: -.02em; + text-align: center; max-width: 22ch; line-height: 1.25; color: var(--text, #0f172a); +} +.v2-landing-say .dim { color: var(--text-muted, #94a3b8); font-weight: 500; } + +/* choice cards */ +.v2-landing-choices { + display: grid; grid-template-columns: repeat(2, minmax(0,1fr)); gap: 16px; + margin-top: 32px; width: min(720px, 92vw); +} +@media (max-width: 620px) { .v2-landing-choices { grid-template-columns: 1fr; } } +.v2-choice { + text-align: left; cursor: pointer; position: relative; overflow: hidden; + border: 1px solid var(--border, #e4e9f0); background: var(--bg-card, #fff); + border-radius: 18px; padding: 20px; + box-shadow: 0 1px 2px rgba(15,23,42,.04), 0 8px 28px rgba(15,23,42,.06); + font: inherit; color: var(--text, #0f172a); + transition: transform .26s cubic-bezier(.22,1,.36,1), box-shadow .26s cubic-bezier(.22,1,.36,1), border-color .26s; +} +.v2-choice:hover { + transform: translateY(-4px); + border-color: color-mix(in srgb, var(--accent) 45%, var(--border)); + box-shadow: 0 2px 6px rgba(15,23,42,.06), + 0 18px 50px color-mix(in srgb, var(--accent) 16%, transparent); +} +.v2-choice:active { transform: translateY(-1px) scale(.995); } + +/* Visible keyboard focus for every landing/plan control (mouse clicks get no ring) */ +#v2-landing :focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; + border-radius: 12px; /* hug the pill/card corners */ +} +#v2-landing .v2-choice:focus-visible { outline-offset: -2px; } /* inset: the card clips outside outlines */ +#v2-landing .v2-escape-field input:focus-visible { outline-offset: 0; } +.v2-choice-ic { + width: 40px; height: 40px; border-radius: 11px; display: grid; place-items: center; + background: var(--accent-soft, #eaf1ff); color: var(--accent, #2f6df6); margin-bottom: 14px; +} +.v2-choice.alt .v2-choice-ic { background: var(--accent-green-soft, #e7f6ec); color: var(--accent-green, #16a34a); } +.v2-choice h3 { margin: 0 0 6px; font-size: 17px; letter-spacing: -.01em; } +.v2-choice p { margin: 0; color: var(--text-secondary, #475569); font-size: var(--v2-fs-sm); line-height: 1.5; } +.v2-choice-tag { + position: absolute; top: 16px; right: 16px; + font-size: var(--v2-fs-eyebrow); letter-spacing: .08em; text-transform: uppercase; color: var(--text-muted, #94a3b8); + border: 1px solid var(--border, #e4e9f0); border-radius: 999px; padding: 3px 9px; +} +.v2-choice-go { + margin-top: 16px; display: flex; align-items: center; gap: 6px; + color: var(--accent, #2f6df6); font-size: 13px; font-weight: 600; + opacity: 0; transform: translateX(-4px); transition: .26s cubic-bezier(.22,1,.36,1); +} +.v2-choice.alt .v2-choice-go { color: var(--accent-green, #16a34a); } +.v2-choice:hover .v2-choice-go { opacity: 1; transform: none; } + +/* escape hatch — chat is the last resort, an obvious pill */ +.v2-escape { margin-top: 24px; display: flex; flex-direction: column; align-items: center; } +.v2-escape-toggle { + display: inline-flex; align-items: center; gap: 7px; cursor: pointer; font: inherit; font-size: 13px; + background: var(--bg-card, #fff); border: 1px solid var(--border, #e4e9f0); color: var(--text-secondary, #475569); + padding: 11px 16px; border-radius: 999px; + box-shadow: 0 1px 2px rgba(15,23,42,.04), 0 8px 28px rgba(15,23,42,.06); + transition: color .2s, border-color .2s, transform .2s cubic-bezier(.22,1,.36,1); +} +.v2-escape-toggle:hover { color: var(--text, #0f172a); border-color: var(--border-strong); transform: translateY(-1px); } +.v2-escape-toggle .v2-escape-caret { display: inline-block; transition: transform .3s cubic-bezier(.22,1,.36,1); opacity: .55; } +.v2-escape.open .v2-escape-toggle .v2-escape-caret { transform: rotate(180deg); } +.v2-escape-field { + display: flex; align-items: center; gap: 8px; width: min(520px, 90vw); + max-height: 0; opacity: 0; overflow: hidden; + transition: max-height .4s cubic-bezier(.22,1,.36,1), opacity .4s cubic-bezier(.22,1,.36,1), margin .4s cubic-bezier(.22,1,.36,1); +} +.v2-escape.open .v2-escape-field { max-height: 64px; opacity: 1; margin-top: 12px; } +.v2-escape-field input { + flex: 1; min-width: 0; border: 1px solid var(--border, #e4e9f0); background: var(--bg-card, #fff); + border-radius: 12px; padding: 12px 14px; font: inherit; font-size: var(--v2-fs-body); color: var(--text, #0f172a); + outline: none; box-shadow: 0 1px 2px rgba(15,23,42,.04); + transition: border-color .2s, box-shadow .2s; +} +.v2-escape-field input:focus { border-color: var(--accent, #2f6df6); box-shadow: 0 0 0 4px rgba(96,165,250,.18); } +.v2-escape-send { + appearance: none; border: 0; cursor: pointer; flex: none; width: 42px; height: 42px; border-radius: 12px; + background: var(--accent, #2f6df6); color: #fff; display: grid; place-items: center; + box-shadow: 0 6px 16px rgba(96,165,250,.40); transition: transform .2s cubic-bezier(.22,1,.36,1), filter .2s; +} +.v2-escape-send:hover { transform: translateY(-1px); filter: brightness(1.05); } + +/* one-way skip into the workspace (e.g. a reload mid-session) */ +.v2-landing-skip { + margin-top: 24px; background: none; border: 0; cursor: pointer; font: inherit; font-size: var(--v2-fs-cap); + color: var(--text-muted, #94a3b8); padding: 10px 12px; border-radius: 8px; + transition: color .2s; +} +.v2-landing-skip:hover { color: var(--text-secondary, #475569); } + +/* Under ux_v2 the landing IS the welcome moment, so the legacy home hero + (static "Welcome to Gently" + start button) would be a duplicate behind it — + hide it. The recent-* cards and the context surface stay. */ +body.ux-v2 .home-hero { display: none; } + +/* ── Two-screen system: welcome ↔ in-place plan wizard ───────── */ +.v2-landing-inner { max-width: 980px; } /* widen for the plan layout */ +.v2-landing .v2-screen { display: none; width: 100%; } +.v2-landing[data-screen="welcome"] .v2-screen-welcome { + display: flex; flex-direction: column; align-items: center; + max-width: 760px; margin: 0 auto; + animation: v2land-plan-in .42s cubic-bezier(.22,1,.36,1) backwards; +} +.v2-landing[data-screen="plan"] .v2-screen-plan { + display: flex; flex-direction: column; + animation: v2land-plan-in .42s cubic-bezier(.22,1,.36,1) backwards; +} +/* One swap motion shared by both screens: a soft opacity + rise + settle. The + scale .992→1 echoes the dismissed-state scale(1.015) so the surface feels like + one continuous fabric folding, not two slides swapping. */ +@keyframes v2land-plan-in { + from { opacity: 0; transform: translateY(10px) scale(.992); } + to { opacity: 1; transform: none; } +} + +/* plan header */ +.v2-plan-head { display: flex; align-items: center; gap: 12px; margin-bottom: 16px; } +.v2-plan-orb { width: 40px; height: 40px; transition: width .42s cubic-bezier(.22,1,.36,1), height .42s cubic-bezier(.22,1,.36,1); } +.v2-plan-who { font-size: var(--v2-fs-eyebrow); letter-spacing: .08em; text-transform: uppercase; color: var(--text-muted, #94a3b8); } +.v2-plan-title { font-size: 18px; font-weight: 600; letter-spacing: -.01em; color: var(--text, #0f172a); } +.v2-plan-back { + margin-left: auto; background: none; border: 0; cursor: pointer; font: inherit; font-size: 13px; + color: var(--text-muted, #94a3b8); padding: 9px 12px; border-radius: 8px; transition: color .2s, background .2s; +} +.v2-plan-back:hover { color: var(--text, #0f172a); background: rgba(15,23,42,.05); } + +/* plan body: ask stage + assembling plan */ +.v2-plan-wrap { display: grid; grid-template-columns: 1.35fr .9fr; gap: 20px; align-items: start; } +@media (max-width: 720px) { + .v2-plan-wrap { grid-template-columns: 1fr; } + /* single column: THE PLAN sits BELOW the feed — drop the internal scroll + and let the whole plan screen scroll as one document instead. The + descendant selector outranks the plain `.v2-plan-main { max-height }` + rule that appears later in the file (equal specificity → source order), + so the cap is genuinely lifted here, not silently re-applied. */ + .v2-landing[data-screen="plan"] .v2-plan-main { height: auto; max-height: none; overflow-y: visible; padding-right: 0; } + .v2-landing[data-screen="plan"] { overflow-y: auto; } +} +.v2-plan-main { min-height: 220px; } +.v2-plan-ask:empty { display: none; } +.v2-plan-thinking { display: flex; align-items: center; gap: 9px; color: var(--text-muted, #94a3b8); font-size: var(--v2-fs-sm); padding: 20px 4px; } +.v2-plan-thinking.hidden { display: none; } +.v2-typing { display: inline-flex; gap: 5px; align-items: center; } +.v2-typing i { width: 7px; height: 7px; border-radius: 50%; background: var(--accent, #2f6df6); opacity: .4; animation: v2-blink 1.1s infinite; } +.v2-typing i:nth-child(2) { animation-delay: .15s; } +.v2-typing i:nth-child(3) { animation-delay: .3s; } +@keyframes v2-blink { 0%,100% { opacity: .25; transform: translateY(0); } 50% { opacity: 1; transform: translateY(-3px); } } + +.v2-plan-side { + background: var(--bg-card, #fff); border: 1px solid var(--border, #e4e9f0); border-radius: 14px; padding: 14px 16px; + box-shadow: 0 1px 2px rgba(15,23,42,.04); +} +.v2-plan-side-h { font-size: var(--v2-fs-eyebrow); letter-spacing: .08em; text-transform: uppercase; color: var(--text-muted, #94a3b8); margin-bottom: 10px; } +.v2-plan-side-empty { color: var(--text-muted, #94a3b8); font-size: var(--v2-fs-sm); font-style: italic; } +.v2-plan-row { display: flex; flex-direction: column; gap: 2px; padding: 9px 0; border-top: 1px dashed var(--border, #e4e9f0); } +.v2-plan-row:first-child { border-top: 0; } +.v2-plan-row .k { font-size: var(--v2-fs-eyebrow); letter-spacing: .08em; text-transform: uppercase; color: var(--text-muted, #94a3b8); } +.v2-plan-row .v { font-size: var(--v2-fs-body); color: var(--text, #0f172a); font-weight: 600; } + +/* plan footer: "Open conversation" (quiet, left), a spacer, then "Continue in + workspace" demoted to a text link (right). The agent's recommended option in + the ask card is the real primary action now — the footer no longer competes. */ +.v2-plan-foot { display: flex; align-items: center; gap: 10px; margin-top: 20px; padding-top: 16px; border-top: 1px solid var(--border, #e4e9f0); } +.v2-plan-foot-spacer { flex: 1; } +.v2-plan-chat { + background: none; border: 1px solid var(--border, #e4e9f0); color: var(--text-secondary, #475569); + border-radius: 999px; padding: 11px 16px; font: inherit; font-size: var(--v2-fs-sm); cursor: pointer; transition: border-color .2s, color .2s; +} +.v2-plan-chat:hover { border-color: var(--border-strong); color: var(--text, #0f172a); } +.v2-plan-skip { + background: none; border: 0; cursor: pointer; font: inherit; font-size: var(--v2-fs-sm); + color: var(--text-muted, #94a3b8); padding: 11px 10px; border-radius: 8px; transition: color .2s; +} +.v2-plan-skip:hover { color: var(--text-secondary, #475569); } +.v2-plan-export { + background: none; border: 1px solid var(--border, #e4e9f0); color: var(--text-secondary, #475569); + border-radius: 999px; padding: 11px 16px; font: inherit; font-size: var(--v2-fs-sm); + font-weight: 600; cursor: pointer; transition: border-color .2s, color .2s, background .2s; +} +.v2-plan-export:hover { border-color: var(--border-strong); color: var(--text, #0f172a); background: var(--bg-hover); } +.v2-plan-export:disabled { opacity: .6; cursor: default; } +.v2-plan-export[hidden] { display: none; } + +/* ── Plan-ready state: the design is done, signpost the finish line ───────── */ +.v2-screen-plan.ready .v2-plan-orb { + background: radial-gradient(closest-side at 38% 34%, #ffffff, #c8f0d4 40%, var(--accent-green, #16a34a) 100%); +} +.v2-screen-plan.ready .v2-plan-who { color: var(--accent-green, #16a34a); } +.v2-screen-plan.ready .v2-plan-foot { border-top-color: color-mix(in srgb, var(--accent-green) 35%, var(--border)); } +/* promote "open the workspace" from a quiet skip link to the primary action */ +.v2-screen-plan.ready #v2-plan-continue { + background: var(--accent-green, #16a34a); color: #fff; + border-radius: 999px; padding: 11px 20px; font-weight: 600; +} +.v2-screen-plan.ready #v2-plan-continue:hover { + color: #fff; background: color-mix(in srgb, var(--accent-green) 88%, #000); +} + +/* ── Agent-activity feed: claude.ai-style collapsible tool cards ──────────── */ +/* Both screens share the .v2-landing-inner top anchor (no per-screen align flip — + that was the welcome→plan lurch). The feed is a fixed-height viewport (height + set above) so the streaming activity column scrolls on its own without ever + reflowing the anchored header/footer around it. Short feeds stay compact + (min-height above); long feeds cap at 66vh and scroll internally. The header + never moves because the inner is top-anchored — only the footer rides down as + the feed grows, up to the cap. */ +.v2-plan-main { max-height: 66vh; overflow-y: auto; padding-right: 4px; } + +.v2-plan-activity { display: flex; flex-direction: column; gap: 8px; margin-bottom: 12px; } +.v2-plan-activity:empty { display: none; margin: 0; } + +/* Paginated feed — one agent step (turn) per page, flipped with ‹ Prev / Next ›. + The pager bar / dots reuse .v2-plan-pager / .v2-plan-dots styling. */ +.v2-feed-pages { display: flex; flex-direction: column; } +.v2-act-page { display: none; flex-direction: column; gap: 8px; } +.v2-act-page.active { display: flex; } +/* beat .v2-plan-pager/.v2-plan-dots { display:flex } so [hidden] actually hides */ +.v2-plan-pager[hidden], .v2-plan-dots[hidden] { display: none; } +.v2-feed-pager-bar { margin: 0 0 4px; } +.v2-feed-dots { margin-top: 10px; } +/* the current question, pinned below the paged feed, set off by a divider — + only once there are steps above it (no stray line on the first choice card) */ +#v2-plan-activity:has(.v2-act-page) + .v2-plan-ask:not(:empty) { + margin-top: 14px; padding-top: 14px; border-top: 1px solid var(--border, #e4e9f0); +} + +/* agent prose between tool calls */ +.v2-act-text { font-size: var(--v2-fs-sm); line-height: 1.55; color: var(--text-secondary, #475569); white-space: pre-wrap; } + +/* collapsed-by-default tool card; the header toggles .open to reveal the body */ +.v2-act-tool { border: 1px solid var(--border, #e4e9f0); border-radius: 11px; background: var(--bg-card, #fff); overflow: hidden; } +.v2-act-tool-head { + display: flex; align-items: center; gap: 8px; width: 100%; + background: none; border: 0; cursor: pointer; text-align: left; font: inherit; + padding: 11px 12px; color: var(--text, #0f172a); +} +.v2-act-tool-head:hover { background: var(--bg-hover, #f1f5f9); } +.v2-act-ic { width: 16px; flex: none; text-align: center; font-size: 12px; } +.v2-act-tool.done .v2-act-ic { color: var(--accent-green, #16a34a); } +.v2-act-tool.err .v2-act-ic { color: #ea580c; } +body.ux-v2[data-theme="dark"] .v2-act-tool.err .v2-act-ic { color: #fb923c; } +.v2-act-spin { + display: inline-block; width: 11px; height: 11px; border-radius: 50%; + border: 2px solid var(--border, #e4e9f0); border-top-color: var(--accent, #2f6df6); + animation: v2-act-spin .7s linear infinite; +} +@keyframes v2-act-spin { to { transform: rotate(360deg); } } +.v2-act-label { font-size: var(--v2-fs-sm); font-weight: 600; flex: none; } +.v2-act-summary { font-size: var(--v2-fs-cap); color: var(--text-muted, #94a3b8); flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.v2-act-chev { flex: none; color: var(--text-muted, #94a3b8); transition: transform .2s; font-size: 13px; } +.v2-act-tool.open .v2-act-chev { transform: rotate(90deg); } +/* animatable collapse: grid-template-rows 0fr→1fr eases in step with the chevron + (display:none isn't animatable). Needs exactly ONE min-height:0 child — landing.js + wraps the blocks in a single inner div for this. */ +.v2-act-tool-body { + display: grid; grid-template-rows: 0fr; opacity: 0; + padding: 0 12px 0 37px; + transition: grid-template-rows .26s cubic-bezier(.22,1,.36,1), + opacity .26s cubic-bezier(.22,1,.36,1), + padding-bottom .26s cubic-bezier(.22,1,.36,1); +} +.v2-act-tool-body > * { min-height: 0; overflow: hidden; } +.v2-act-tool.open .v2-act-tool-body { grid-template-rows: 1fr; opacity: 1; padding-bottom: 11px; } +.v2-act-tool.open .v2-act-summary { white-space: normal; } +.v2-act-block-label { font-size: var(--v2-fs-eyebrow); letter-spacing: .08em; text-transform: uppercase; color: var(--text-muted, #94a3b8); margin-top: 8px; } +.v2-act-block { + font: 12px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; + background: var(--bg-hover); border: 1px solid var(--border, #e4e9f0); border-radius: 8px; + padding: 8px 10px; margin-top: 4px; white-space: pre-wrap; word-break: break-word; + color: var(--text-secondary, #475569); max-height: 220px; overflow: auto; +} + +/* error + fallback states */ +.v2-plan-error { + font-size: var(--v2-fs-sm); color: #b91c1c; + background: rgba(239,68,68,.10); border: 1px solid rgba(239,68,68,.35); + border-radius: 11px; padding: 11px 13px; +} +body.ux-v2[data-theme="dark"] .v2-plan-error { + color: #fca5a5; background: rgba(239,68,68,.14); border-color: rgba(239,68,68,.40); +} +.v2-plan-error.hidden { display: none; } +.v2-plan-fallback { font-size: 13px; color: var(--text-muted, #94a3b8); padding: 8px 2px; } +.v2-plan-fallback a { color: var(--accent, #2f6df6); cursor: pointer; } + +/* plan-panel: phases + tasks (real plan), and a free-text-answer row variant */ +.v2-plan-phase { margin-top: 12px; } +.v2-plan-phase:first-child { margin-top: 0; } +.v2-plan-phase-h { + font-size: var(--v2-fs-eyebrow); font-weight: 700; letter-spacing: .06em; + text-transform: uppercase; color: var(--text-secondary, #475569); margin-bottom: 6px; +} +/* a plan item: "P.I" number · type-color dot · title · optional duration. + the type dot encodes the item kind (imaging/genetics/analysis/…) at a glance. */ +.v2-plan-task { + display: grid; grid-template-columns: auto 8px 1fr auto; align-items: baseline; + gap: 9px; font-size: var(--v2-fs-cap); color: var(--text-secondary, #475569); + padding: 6px 0; border-top: 1px solid color-mix(in srgb, var(--border, #e4e9f0) 55%, transparent); +} +.v2-plan-phase .v2-plan-task:first-child { border-top: 0; } +.v2-task-num { + font: 600 11px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; + color: var(--text-muted, #94a3b8); font-variant-numeric: tabular-nums; +} +.v2-task-dot { width: 8px; height: 8px; border-radius: 50%; align-self: center; background: var(--text-muted, #94a3b8); } +.v2-task-ttl { min-width: 0; color: var(--text, #0f172a); line-height: 1.45; } +.v2-task-days { + font-size: 10.5px; font-weight: 600; color: var(--text-muted, #94a3b8); + font-variant-numeric: tabular-nums; white-space: nowrap; +} +.v2-plan-task.type-imaging .v2-task-dot { background: var(--accent, #2f6df6); } +.v2-plan-task.type-genetics .v2-task-dot { background: #8b5cf6; } +.v2-plan-task.type-analysis .v2-task-dot { background: var(--accent-green, #16a34a); } +.v2-plan-task.type-bench .v2-task-dot { background: #d97706; } +/* decision points read as gates — a rotated square, not a round bead */ +.v2-plan-task.type-decision_point .v2-task-dot { background: #e11d48; border-radius: 1px; transform: rotate(45deg); } +.v2-plan-title-row { font-size: var(--v2-fs-sm); font-weight: 600; letter-spacing: -.01em; color: var(--text, #0f172a); margin-bottom: 8px; } +.v2-plan-row.v2-plan-row-freetext .v { font-style: italic; } +.v2-plan-task-empty { grid-column: 1 / -1; color: var(--text-muted, #94a3b8); font-style: italic; } + +/* THE PLAN pager: ‹ Prev · "Phase · i of N" · Next › + dots, one phase per page */ +.v2-plan-pager { display: flex; align-items: center; gap: 8px; margin: 2px 0 12px; } +.v2-plan-pager-btn { + flex: none; background: none; border: 0; cursor: pointer; font: inherit; + font-size: var(--v2-fs-cap); font-weight: 600; color: var(--accent, #2f6df6); + padding: 5px 7px; border-radius: 7px; transition: background .15s, color .15s, opacity .15s; +} +.v2-plan-pager-btn:hover:not(:disabled) { background: var(--accent-soft); } +.v2-plan-pager-btn:disabled { color: var(--text-muted, #94a3b8); opacity: .45; cursor: default; } +.v2-plan-pager-pos { + flex: 1; min-width: 0; text-align: center; font-size: var(--v2-fs-cap); font-weight: 600; + color: var(--text, #0f172a); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} +.v2-plan-dots { display: flex; gap: 6px; justify-content: center; margin-top: 12px; } +.v2-plan-dot { + width: 7px; height: 7px; padding: 0; border: 0; border-radius: 50%; cursor: pointer; + background: var(--border, #e4e9f0); transition: background .2s, transform .2s; +} +.v2-plan-dot:hover { transform: scale(1.25); } +.v2-plan-dot.active { background: var(--accent, #2f6df6); } + +@media (prefers-reduced-motion: reduce) { + .v2-landing, .v2-landing::before, .v2-landing-rise, .v2-landing-orb, .v2-plan-orb, + .v2-landing[data-screen="plan"] .v2-screen-plan, + .v2-landing[data-screen="welcome"] .v2-screen-welcome, + .v2-typing i, .v2-act-spin, .v2-act-chev, + .v2-act-tool-body, .v2-act-tool-head, + .v2-choice, .v2-escape-field, .v2-escape-toggle, + .v2-plan-pager-btn, .v2-plan-dot { + animation: none !important; + transition-duration: .12s !important; + } + /* keep the collapsible usable without the height tween */ + .v2-act-tool-body { transition: none !important; } + .v2-act-tool.open .v2-act-tool-body { grid-template-rows: 1fr; opacity: 1; } +} diff --git a/gently/ui/web/static/css/main.css b/gently/ui/web/static/css/main.css index 10bc6f62..2c7751aa 100644 --- a/gently/ui/web/static/css/main.css +++ b/gently/ui/web/static/css/main.css @@ -23,6 +23,13 @@ --accent-pink: #f472b6; --accent-cyan: #22d3ee; + /* Temperature graph colors (root-level so the graph is portable outside + .devices-container-map — e.g. when reused in manual mode) */ + --temp-setpoint-color: #f59e0b; + --temp-water-color: #22d3ee; + --temp-grid-line-color: rgba(212, 221, 232, 0.20); + --temp-grid-label-color: #6a778a; + /* Gradients for modern feel */ --gradient-primary: linear-gradient(135deg, #60a5fa 0%, #c084fc 100%); --gradient-success: linear-gradient(135deg, #4ade80 0%, #22d3ee 100%); @@ -63,6 +70,12 @@ --accent-pink: #ec4899; --accent-cyan: #06b6d4; + /* Temperature graph colors (root-level; see dark block) */ + --temp-setpoint-color: #f59e0b; + --temp-water-color: #0e7490; + --temp-grid-line-color: rgba(29, 43, 58, 0.22); + --temp-grid-label-color: #6b7280; + /* Gradients */ --gradient-primary: linear-gradient(135deg, #3b82f6 0%, #a855f7 100%); --gradient-success: linear-gradient(135deg, #22c55e 0%, #06b6d4 100%); @@ -9072,7 +9085,7 @@ body.modal-open { .settings-content { flex: 1; max-width: 720px; - padding: 2rem 2.5rem; + padding: 2rem 2.5rem 0; overflow-y: auto; } @@ -9194,6 +9207,46 @@ body.modal-open { color: var(--text-muted); } +/* Thermalizer / server-backed settings controls */ +.th-note { margin-bottom: 0.9rem; } +.th-ro { font-size: 0.8rem; font-weight: 400; color: var(--text-muted); } +.th-status { + font-size: 0.85rem; color: var(--text); font-variant-numeric: tabular-nums; + padding: 0.5rem 0.7rem; background: var(--bg-hover); + border: 1px solid var(--border); border-radius: 6px; +} +.th-actions { display: flex; gap: 0.6rem; } +.settings-btn { + background: var(--bg-hover); border: 1px solid var(--border); color: var(--text); + font: 600 0.82rem/1 'Inter Tight', system-ui, sans-serif; + padding: 0.55rem 0.9rem; border-radius: 7px; cursor: pointer; + transition: background 0.13s, border-color 0.13s, color 0.13s, opacity 0.13s; +} +.settings-btn:hover:not(:disabled) { border-color: var(--accent); color: var(--accent); } +.settings-btn:disabled { opacity: 0.5; cursor: progress; } +.settings-btn-primary { background: var(--accent); border-color: var(--accent); color: #fff; } +.settings-btn-primary:hover:not(:disabled) { background: var(--accent-hover); border-color: var(--accent-hover); color: #fff; } +.settings-result { font-size: 0.8rem; margin-top: 0.6rem; min-height: 1.1em; } +.settings-result.is-ok { color: var(--accent-green); } +.settings-result.is-err { color: var(--accent-orange); } +.settings-pre { + font: 0.78rem/1.5 ui-monospace, SFMono-Regular, monospace; + color: var(--text); background: var(--bg-dark); border: 1px solid var(--border); + border-radius: 8px; padding: 0.8rem 1rem; overflow-x: auto; white-space: pre; + max-height: 460px; overflow-y: auto; +} +.settings-defaults-bar { display: flex; gap: 0.5rem; margin-left: auto; flex-wrap: wrap; } +.settings-subhead { + font-size: 0.72rem; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; + color: var(--text-muted); margin: 1.4rem 0 0.6rem; padding-bottom: 0.35rem; + border-bottom: 1px solid var(--border); +} +.settings-subhead:first-child { margin-top: 0.2rem; } +.settings-pre code, .settings-hint code { + font: 0.85em ui-monospace, SFMono-Regular, monospace; + background: var(--bg-hover); padding: 0.05rem 0.3rem; border-radius: 4px; +} + /* Range slider */ .settings-range { -webkit-appearance: none; @@ -9237,6 +9290,13 @@ body.modal-open { position: sticky; bottom: 0; padding: 0.75rem 0; + display: flex; + align-items: center; + gap: 0.75rem; + flex-wrap: wrap; + background: var(--bg-dark); + border-top: 1px solid var(--border); + z-index: 5; } .settings-save-status { @@ -9628,6 +9688,15 @@ body.modal-open { .devices-status-led.paused::before{ background: var(--map-accent); } .devices-status-led.stale::before { background: var(--map-warm); } .devices-status-led.error::before { background: #f87171; } +/* Status sub-label (e.g. the live "Δt 0.20s"). The value updates every frame; + tabular figures + a reserved, right-aligned width keep its box from changing + size, so the rapidly-changing digits can't reflow and jitter the LIVE badge + and room-light button beside it (the cluster is right-anchored in the bar). */ +.devices-status-meta { + font-variant-numeric: tabular-nums; + min-width: 3.4rem; + text-align: right; +} /* --- Room-light toggle (header) -------------------------------------- */ .devices-room-light { @@ -9736,6 +9805,63 @@ body.modal-open { .devices-temp-set:hover:not(:disabled) { border-color: var(--map-accent); color: var(--map-ink); } .devices-temp-set:disabled { opacity: 0.5; cursor: default; } +/* --- Temperature trajectory graph ------------------------------------- */ +/* SVG chart card mounted at the bottom of #devices-content. */ +.devices-temp-graph { + padding: 0.6rem 1rem 0.5rem; + border-top: 1px solid var(--map-overlay-edge); + min-height: 2rem; /* keeps the section visible even when empty */ +} +/* Solid line: water temperature */ +.temp-water { + stroke: var(--temp-water-color); + stroke-width: 1.5; + stroke-linejoin: round; + stroke-linecap: round; +} +/* Dashed line: setpoint (stepped) */ +.temp-setpoint { + stroke: var(--temp-setpoint-color); + stroke-width: 1.5; + stroke-dasharray: 5 3; + stroke-linejoin: round; + stroke-linecap: round; + opacity: 0.85; +} +/* Y-axis reference gridlines */ +.temp-grid-line { + stroke: var(--temp-grid-line-color); + stroke-width: 0.75; +} +/* Y-axis tick labels */ +.temp-grid-label { + fill: var(--temp-grid-label-color); + font-size: 8px; + font-family: ui-monospace, monospace; + text-anchor: end; + dominant-baseline: middle; +} +/* Calm empty state — never shows mock data */ +.temp-graph-empty { + padding: 1rem 0; + text-align: center; + color: var(--map-ink-mute); + font-size: 0.78rem; +} +/* Last-sample readout above the SVG */ +.temp-graph-readout { + font-size: 0.68rem; + font-weight: 600; + font-variant-numeric: tabular-nums; + letter-spacing: 0.04em; + color: var(--map-ink-mute); + margin-bottom: 0.2rem; + padding: 0 2px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + /* --- Containers ------------------------------------------------------- */ .devices-view { display: flex; flex-direction: column; flex: 1; min-height: 0; } .devices-view-details { gap: 1rem; } @@ -10149,6 +10275,72 @@ body.modal-open { color: var(--map-paper); border-color: var(--map-accent); } + +/* ---- Top-right rail: the XY stage readout (Detect/mark moved to Operate) ---- */ +.devices-map-rail { + position: absolute; + top: 0.85rem; + right: 0.85rem; + width: 230px; + display: flex; + flex-direction: column; + gap: 0.5rem; + z-index: 4; +} +/* Readout drops its own absolute positioning inside the rail and fills it. */ +.devices-map-rail .devices-map-readout { + position: static; + top: auto; + right: auto; + width: 100%; + min-width: 0; +} + + +.devices-camera-head-btns { + display: inline-flex; + align-items: center; + gap: 0.3rem; +} +.devices-camera-expand { + background: transparent; + border: 1px solid var(--map-overlay-edge); + color: var(--map-ink); + font-family: inherit; + font-size: 0.72rem; + line-height: 1; + padding: 0.16rem 0.4rem; + border-radius: 999px; + cursor: pointer; + transition: background 0.15s, border-color 0.15s, color 0.15s; +} +.devices-camera-expand:hover { + background: rgba(255,255,255,0.06); + border-color: var(--map-accent); + color: var(--map-accent); +} +.devices-camera-expand.active { + background: var(--map-accent); + color: var(--map-paper); + border-color: var(--map-accent); +} +/* Enlarged bottom-camera view — centred over the map, toggled by the + expand button. Reuses the same streaming ; sized by height so the + whole panel (header + stage + foot) stays within the map area. */ +.devices-camera-panel.expanded { + bottom: auto; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + width: auto; + max-width: calc(100% - 2rem); + z-index: 9; +} +.devices-camera-panel.expanded .devices-camera-stage { + height: min(64vh, 640px); + width: auto; + max-width: 100%; +} .devices-camera-stage { position: relative; width: 100%; @@ -10480,3 +10672,838 @@ body.modal-open { opacity: 0.5; cursor: not-allowed; } + +/* ========================================================================= + Manual view — lightsheet live panel + control rail + ========================================================================= */ + +/* Two-column layout: image stage (flex-grow) + fixed control rail */ +.ls-layout { + display: flex; + flex: 1; + min-height: 0; + gap: 1rem; + font-family: 'Inter Tight', system-ui, sans-serif; +} + +/* Left column — live image stage */ +.ls-image-col { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 0.45rem; +} + +.ls-stage-head { + display: flex; + align-items: center; + gap: 0.6rem; + flex-wrap: wrap; +} + +/* The stage itself reuses .devices-camera-stage aspect + overflow */ +.ls-stage { + flex: 1; + min-height: 280px; + /* override aspect-ratio: let the flex column stretch it */ + aspect-ratio: unset; +} + +/* Right column — control rail. + Two-column masonry so every control stays visible without scrolling. + multicol packs the varying-height cards tighter than a grid would (no + row-alignment gaps); each .ls-group sets break-inside to stay whole. + overflow-y:auto is a safety net only — the default (collapsed) control + set fits with no scrollbar; it appears solely if Timelapse is expanded. */ +.ls-rail { + width: 380px; + flex-shrink: 0; + columns: 2; + column-gap: 0.7rem; + overflow-y: auto; + padding: 0.1rem 0 0.5rem; +} + +/* Control group block */ +.ls-group { + display: flex; + flex-direction: column; + gap: 0.32rem; + padding: 0.55rem 0.65rem; + background: var(--map-overlay-bg); + border: 1px solid var(--map-overlay-edge); + border-radius: 5px; + /* multicol: keep each card intact and space them (rail gap is gone) */ + break-inside: avoid; + margin-bottom: 0.7rem; +} + +/* Stream group: the live toggle, relocated from the viewer header to a + prominent full-width button at the top of the rail. column-span makes it + a header across both columns, whose top lines up with the image stage. */ +.ls-group-stream { + column-span: all; + /* no card frame — this is just the toggle, not a boxed control group */ + background: transparent; + border: none; + padding: 0; + /* top margin ≈ the viewer's header row height so the button lines up + with the top of the image-stage box (not the "LIGHTSHEET" label row). */ + margin-top: 1.05rem; + margin-bottom: 0.85rem; +} +/* Slim outline "● LIVE" toggle. Muted when off; glows green while streaming + (the .active class is toggled on by applyLightsheetState). */ +.ls-stream-btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.45rem; + width: 100%; + padding: 0.34rem 0.6rem; + background: transparent; + border: 1px solid var(--map-overlay-edge); + color: var(--map-ink-mute); + font-family: inherit; + font-size: 0.66rem; + font-weight: 600; + letter-spacing: 0.14em; + text-transform: uppercase; + border-radius: 5px; + cursor: pointer; + transition: background 0.12s, border-color 0.12s, color 0.12s; +} +.ls-stream-btn::before { + content: ''; + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--map-ink-mute); + flex: none; + transition: background 0.12s, box-shadow 0.12s; +} +.ls-stream-btn:hover { border-color: var(--map-accent); color: var(--map-ink); } +.ls-stream-btn.active { + border-color: #4ade80; + color: #4ade80; + background: rgba(74, 222, 128, 0.10); +} +.ls-stream-btn.active::before { + background: #4ade80; + box-shadow: 0 0 8px rgba(74, 222, 128, 0.7); +} + +.ls-label { + font-size: 0.6rem; + font-weight: 600; + letter-spacing: 0.18em; + text-transform: uppercase; + color: var(--map-ink-mute); +} + +/* Generic inline row */ +.ls-row { + display: flex; + align-items: center; + gap: 0.4rem; +} + +/* Slider + number inline */ +.ls-slider-row { + display: flex; + align-items: center; + gap: 0.35rem; +} + +.ls-slider { + flex: 1; + min-width: 0; + height: 3px; + accent-color: var(--map-accent); + cursor: pointer; +} + +.ls-number { + width: 64px; + background: var(--map-paper-2); + border: 1px solid var(--map-overlay-edge); + color: var(--map-ink); + border-radius: 4px; + padding: 0.2rem 0.35rem; + font-family: 'JetBrains Mono', monospace; + font-size: 0.7rem; + font-variant-numeric: tabular-nums; +} + +.ls-number-sm { + width: 50px; +} + +.ls-number:focus { + outline: none; + border-color: var(--map-accent); +} + +.ls-unit { + font-family: 'Inter Tight', system-ui, sans-serif; + font-size: 0.62rem; + color: var(--map-ink-mute); + flex-shrink: 0; +} + +/* Illumination button rows */ +.ls-btn-row { + display: flex; + gap: 0.35rem; + flex-wrap: wrap; +} + +.ls-illum-btn { + flex: 1; + /* Reserve enough width that labels wrap to a new row instead of + truncating to an ellipsis in the narrower 2-column rail. */ + min-width: 4.25rem; + background: transparent; + border: 1px solid var(--map-overlay-edge); + color: var(--map-ink); + font-family: inherit; + font-size: 0.6rem; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; + padding: 0.28rem 0.4rem; + border-radius: 4px; + cursor: pointer; + transition: background 0.12s, border-color 0.12s, color 0.12s; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.ls-illum-btn:hover { + background: rgba(255,255,255,0.06); + border-color: var(--map-accent); + color: var(--map-accent); +} + +.ls-illum-btn--active { + background: rgba(34, 211, 238, 0.14); + border-color: var(--map-accent); + color: var(--map-accent); +} + +/* Laser-off indicator */ +.ls-laser-indicator { + font-size: 0.6rem; + color: var(--map-ink-mute); + display: flex; + align-items: center; + gap: 0.35rem; + margin-top: 0.1rem; +} + +.ls-laser-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--map-ink-mute); + flex-shrink: 0; +} + +.ls-laser-dot--on { + background: var(--map-accent); +} + +/* Laser preset row (B2) */ +.ls-laser-row { + display: flex; + align-items: center; + gap: 0.4rem; + margin-top: 0.2rem; +} + +.ls-laser-label { + font-size: 0.6rem; + font-weight: 600; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--map-ink-mute); + flex-shrink: 0; +} + +.ls-laser-select { + flex: 1; + min-width: 0; + background: var(--map-paper-2); + border: 1px solid var(--map-overlay-edge); + color: var(--map-ink); + font-family: inherit; + font-size: 0.62rem; + border-radius: 4px; + padding: 0.18rem 0.3rem; + cursor: pointer; + transition: border-color 0.12s; +} + +.ls-laser-select:focus { + outline: none; + border-color: var(--map-accent); +} + +/* Temperature setpoint button */ +.ls-set-btn { + background: transparent; + border: 1px solid var(--map-overlay-edge); + color: var(--map-ink); + font-family: inherit; + font-size: 0.6rem; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + padding: 0.2rem 0.55rem; + border-radius: 4px; + cursor: pointer; + transition: background 0.12s, border-color 0.12s; +} + +.ls-set-btn:hover { + background: rgba(255,255,255,0.06); + border-color: var(--map-accent); +} + +/* Temperature mini-graph */ +.ls-tempgraph { + min-height: 48px; + margin-top: 0.25rem; +} + +/* Acquire buttons */ +.ls-acquire-btn { + flex: 1; + background: transparent; + border: 1px solid var(--map-overlay-edge); + color: var(--map-ink); + font-family: inherit; + font-size: 0.62rem; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; + padding: 0.35rem 0.5rem; + border-radius: 4px; + cursor: pointer; + transition: background 0.12s, border-color 0.12s, color 0.12s; +} + +.ls-acquire-btn:hover:not(:disabled) { + background: rgba(255,255,255,0.06); + border-color: var(--map-accent); + color: var(--map-accent); +} + +.ls-acquire-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* Last-captured ref card */ +.ls-lastcap { + margin-top: 0.35rem; + padding: 0.35rem 0.45rem; + background: var(--map-paper-2); + border: 1px solid var(--map-overlay-edge); + border-radius: 4px; + display: flex; + flex-direction: column; + gap: 0.2rem; +} + +.ls-lastcap[hidden] { display: none; } + +.ls-lastcap-label { + font-size: 0.58rem; + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--map-ink-mute); +} + +.ls-lastcap-ref { + font-family: 'JetBrains Mono', monospace; + font-size: 0.65rem; + color: var(--map-ink); + word-break: break-all; +} + +/* ── Timelapse collapsible panel (B2 Task 3) ─────────────────────────────── */ + +/* Clickable header button (also carries .ls-label for font/color) */ +.ls-collapsible-head { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + background: transparent; + border: none; + padding: 0; + cursor: pointer; + text-align: left; +} + +/* ▶/▼ caret — rotates when expanded */ +.ls-collapsible-arrow { + font-size: 0.5rem; + color: var(--map-ink-mute); + flex-shrink: 0; + transition: transform 0.15s; +} + +.ls-collapsible-head[aria-expanded="true"] .ls-collapsible-arrow { + transform: rotate(90deg); +} + +/* Collapsible body — hidden via [hidden] attribute; explicit rule prevents + display overrides from fighting the attribute (mirrors .ls-lastcap[hidden]) */ +.ls-collapsible-body { + display: flex; + flex-direction: column; + gap: 0.32rem; + margin-top: 0.35rem; +} + +.ls-collapsible-body[hidden] { display: none; } + +/* Sub-rows: like .ls-row but slightly indented for the body interior */ +.ls-sub-row { + display: flex; + align-items: center; + gap: 0.4rem; + padding-left: 0.25rem; +} + +/* Sub-label: like .ls-label but fixed-width to align inputs column */ +.ls-sub-label { + font-size: 0.58rem; + font-weight: 600; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--map-ink-mute); + min-width: 4.5rem; + flex-shrink: 0; +} + +/* Volume-Geometry inset sub-group */ +.ls-sub-section { + display: flex; + flex-direction: column; + gap: 0.28rem; + padding: 0.4rem 0.5rem; + background: var(--map-paper-2); + border: 1px solid var(--map-overlay-edge); + border-radius: 4px; + margin: 0.15rem 0; +} + +/* "VOLUME GEOMETRY" sub-header */ +.ls-sub-section-label { + font-size: 0.52rem; + font-weight: 600; + letter-spacing: 0.2em; + text-transform: uppercase; + color: var(--map-ink-mute); + margin-bottom: 0.1rem; +} + +/* Start-result / status line — mono, muted */ +.ls-tl-status { + font-family: 'JetBrains Mono', monospace; + font-size: 0.62rem; + color: var(--map-ink-mute); + padding: 0.2rem 0.25rem; + word-break: break-all; +} + +.ls-tl-status[hidden] { display: none; } + +/* ── Timelapse accordion (active/inactive section states — B3 UX) ─────────── */ + +/* Wrapper for each accordion section */ +.ls-acc-section { + display: flex; + flex-direction: column; + border-bottom: 1px solid var(--map-rule-soft); + padding-bottom: 0.12rem; + margin-bottom: 0.08rem; +} +.ls-acc-section:last-of-type { + border-bottom: none; + margin-bottom: 0; +} + +/* Section header button */ +.ls-acc-head { + display: flex; + align-items: center; + gap: 0.32rem; + background: transparent; + border: none; + padding: 0.26rem 0; + cursor: pointer; + width: 100%; + text-align: left; + font-family: inherit; +} + +/* Status dot — hollow/muted when inactive */ +.ls-acc-dot { + width: 5px; + height: 5px; + border-radius: 50%; + border: 1px solid var(--map-ink-mute); + background: transparent; + flex-shrink: 0; + transition: background 0.15s, border-color 0.15s; +} + +/* Section label */ +.ls-acc-title { + font-size: 0.575rem; + font-weight: 600; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--map-ink-mute); + flex-shrink: 0; + transition: color 0.15s; +} + +/* One-line summary — shown only when section is touched */ +.ls-acc-summary { + flex: 1; + font-size: 0.57rem; + font-family: 'JetBrains Mono', monospace; + letter-spacing: 0.02em; + color: var(--map-ink-mute); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; +} +.ls-acc-summary[hidden] { display: none; } + +/* Caret arrow */ +.ls-acc-arrow { + font-size: 0.44rem; + color: var(--map-ink-mute); + flex-shrink: 0; + margin-left: auto; + transition: transform 0.15s, color 0.15s; +} +.ls-acc-head[aria-expanded="true"] .ls-acc-arrow { + transform: rotate(90deg); +} + +/* ── Active / touched state ────────────────────────────────────────────────── */ +.ls-acc-head.is-active .ls-acc-dot { + background: var(--map-accent); + border-color: var(--map-accent); +} +.ls-acc-head.is-active .ls-acc-title { + color: var(--map-accent); +} +.ls-acc-head.is-active .ls-acc-summary { + color: var(--map-accent); + opacity: 0.85; +} +.ls-acc-head.is-active .ls-acc-arrow { + color: var(--map-accent); +} + +/* Section body */ +.ls-acc-body { + display: flex; + flex-direction: column; + gap: 0.26rem; + padding: 0.18rem 0 0.32rem 0.5rem; +} +.ls-acc-body[hidden] { display: none; } + +/* Outer timelapse panel header dot — signals any section is set */ +.ls-tl-outer-dot { + width: 5px; + height: 5px; + border-radius: 50%; + border: 1px solid transparent; + background: transparent; + flex-shrink: 0; + transition: background 0.15s, border-color 0.15s; +} +.ls-tl-outer-dot.is-active { + background: var(--map-accent); + border-color: var(--map-accent); +} + +/* Start button reads "ready" (accent) once any section is configured */ +.ls-tl-start-btn { + transition: border-color 0.15s, color 0.15s, background 0.15s; +} +.ls-tl-start-btn.is-ready { + border-color: var(--map-accent); + color: var(--map-accent); + background: rgba(34, 211, 238, 0.08); +} + +/* Submit row — give the button its full row width */ +.ls-tl-submit-row { + margin-top: 0.3rem; +} + +/* ========================================== + Gallery Tab + ========================================== */ + +.gallery-tab-container { + display: flex; + flex-direction: column; + height: 100%; + overflow: hidden; + padding: 1.25rem 1.5rem 0; + box-sizing: border-box; +} + +.gallery-tab-header { + display: flex; + align-items: center; + gap: 1rem; + margin-bottom: 1rem; + flex-shrink: 0; +} + +.gallery-tab-title { + font-size: 1.35rem; + font-weight: 300; + letter-spacing: -0.01em; + color: var(--text); + margin: 0; + line-height: 1.2; + flex-shrink: 0; +} + +.gallery-tab-title-script { + font-family: 'Georgia', serif; + font-style: italic; + color: var(--text-muted); +} + +.gallery-tab-title-em { + font-style: normal; + font-weight: 500; + color: var(--accent); +} + +.gallery-tab-controls { + display: flex; + align-items: center; + gap: 0.5rem; + margin-left: auto; +} + +.gallery-filter-select { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 6px; + color: var(--text); + font-size: 0.8rem; + padding: 0.3rem 0.6rem; + cursor: pointer; + transition: border-color 0.15s; +} + +.gallery-filter-select:hover, +.gallery-filter-select:focus { + border-color: var(--accent); + outline: none; +} + +.gallery-refresh-btn { + background: transparent; + border: 1px solid var(--border); + border-radius: 6px; + color: var(--text-muted); + font-size: 1rem; + width: 2rem; + height: 2rem; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: color 0.15s, border-color 0.15s; +} + +.gallery-refresh-btn:hover { + color: var(--accent); + border-color: var(--accent); +} + +.gallery-tab-body { + flex: 1; + overflow-y: auto; + padding-bottom: 1.5rem; +} + +.gallery-tab-empty, +.gallery-tab-loading, +.gallery-tab-error { + display: flex; + align-items: center; + justify-content: center; + height: 200px; + color: var(--text-muted); + font-size: 0.9rem; +} + +.gallery-tab-error { + color: var(--accent-red, #ef4444); +} + +.gallery-tab-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); + gap: 0.75rem; +} + +.gallery-tab-item { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 8px; + overflow: hidden; + cursor: pointer; + transition: border-color 0.15s, transform 0.15s, box-shadow 0.15s; +} + +.gallery-tab-item:hover { + border-color: var(--accent); + transform: translateY(-2px); + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25); +} + +.gallery-tab-thumb { + display: block; + width: 100%; + aspect-ratio: 1; + object-fit: contain; + background: var(--bg-dark); +} + +.gallery-tab-thumb-placeholder { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + aspect-ratio: 1; + background: var(--bg-dark); + color: var(--text-muted); + font-size: 0.7rem; + text-align: center; + padding: 0.5rem; + box-sizing: border-box; +} + +.gallery-tab-item-info { + padding: 0.4rem 0.5rem 0.45rem; + border-top: 1px solid var(--border); +} + +.gallery-tab-item-type { + font-size: 0.72rem; + font-weight: 600; + color: var(--accent); + text-transform: lowercase; + letter-spacing: 0.02em; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.gallery-tab-item-embryo { + font-size: 0.68rem; + color: var(--text-muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + margin-top: 0.1rem; +} + +.gallery-tab-item-ts { + font-size: 0.62rem; + color: var(--text-muted); + opacity: 0.65; + margin-top: 0.1rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* ========================================== + Gently Toast — volume acquired / burst acquired + ========================================== */ + +.gently-toast { + position: fixed; + bottom: 24px; + left: 50%; + transform: translateX(-50%) translateY(20px); + background: var(--bg-card); + border: 1px solid var(--accent-green); + border-radius: 10px; + padding: 0.6rem 1rem 0.6rem 1.1rem; + display: flex; + align-items: center; + gap: 0.65rem; + z-index: 10010; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.35); + opacity: 0; + transition: opacity 0.25s ease, transform 0.25s ease; + pointer-events: auto; +} + +.gently-toast.visible { + opacity: 1; + transform: translateX(-50%) translateY(0); +} + +.gently-toast-msg { + font-size: 0.85rem; + font-weight: 500; + color: var(--text); + white-space: nowrap; +} + +.gently-toast-action { + background: transparent; + border: 1px solid var(--accent); + border-radius: 5px; + color: var(--accent); + font-size: 0.78rem; + padding: 0.2rem 0.55rem; + cursor: pointer; + transition: background 0.15s, color 0.15s; + white-space: nowrap; +} + +.gently-toast-action:hover { + background: var(--accent); + color: var(--bg-dark); +} + +.gently-toast-dismiss { + background: transparent; + border: none; + color: var(--text-muted); + font-size: 1.1rem; + line-height: 1; + cursor: pointer; + padding: 0 0.1rem; + opacity: 0.7; + transition: opacity 0.15s; + margin-left: 0.2rem; +} +} diff --git a/gently/ui/web/static/css/notebook.css b/gently/ui/web/static/css/notebook.css new file mode 100644 index 00000000..fa4cebab --- /dev/null +++ b/gently/ui/web/static/css/notebook.css @@ -0,0 +1,104 @@ +/* ── Notebook tab (LIBRARY) ────────────────────────────────────────────── + The reading room for the shared lab notebook: thread rail + kind filter + + note cards. Tokens reuse the app theme (--accent, --accent-green, --border …). */ + +.nb-container { max-width: 1100px; margin: 0 auto; padding: 24px 28px; } + +.nb-header { + display: flex; align-items: center; justify-content: space-between; + gap: 16px; margin-bottom: 18px; flex-wrap: wrap; +} +.nb-title { font-size: 22px; font-weight: 600; letter-spacing: -.01em; color: var(--text, #0f172a); margin: 0; } + +.nb-kinds { display: flex; gap: 6px; } +.nb-kind { + background: none; border: 1px solid var(--border, #e4e9f0); color: var(--text-secondary, #475569); + border-radius: 999px; padding: 6px 13px; font: inherit; font-size: 13px; cursor: pointer; + transition: border-color .15s, color .15s, background .15s; +} +.nb-kind:hover { color: var(--text, #0f172a); border-color: var(--border-strong, #cbd5e1); } +.nb-kind.active { background: var(--accent, #2f6df6); border-color: var(--accent, #2f6df6); color: #fff; } + +/* Ask the notebook */ +.nb-ask { display: flex; gap: 9px; margin-bottom: 14px; } +.nb-ask-input { + flex: 1; border: 1px solid var(--border, #e4e9f0); border-radius: 11px; + padding: 11px 14px; font: inherit; font-size: 14px; color: var(--text, #0f172a); + background: var(--bg-card, #fff); +} +.nb-ask-input:focus { outline: none; border-color: var(--accent, #2f6df6); box-shadow: 0 0 0 4px rgba(96, 165, 250, .15); } +.nb-ask-go { + flex: none; border: 0; border-radius: 11px; padding: 11px 20px; font: inherit; font-size: 14px; + font-weight: 600; color: #fff; background: var(--accent, #2f6df6); cursor: pointer; transition: background .15s; +} +.nb-ask-go:hover { background: color-mix(in srgb, var(--accent) 88%, #000); } + +.nb-ask-result { + border: 1px solid var(--border, #e4e9f0); border-radius: 13px; padding: 16px 18px; margin-bottom: 18px; + background: var(--accent-soft, #f5f8ff); +} +.nb-ask-result[hidden] { display: none; } +.nb-ask-loading, .nb-ask-empty { color: var(--text-muted, #94a3b8); font-size: 14px; font-style: italic; } +.nb-ask-cov { + display: inline-block; font-size: 11px; font-weight: 700; letter-spacing: .04em; text-transform: uppercase; + padding: 2px 9px; border-radius: 999px; margin-bottom: 10px; color: #fff; background: var(--text-muted, #94a3b8); +} +.nb-cov-covered { background: var(--accent-green, #16a34a); } +.nb-cov-partial { background: #d97706; } +.nb-cov-not_in_notebook { background: var(--text-muted, #94a3b8); } +.nb-ask-answer { font-size: 14.5px; line-height: 1.55; color: var(--text, #0f172a); } +.nb-ask-h { + font-size: var(--v2-fs-eyebrow, 11px); font-weight: 700; letter-spacing: .06em; text-transform: uppercase; + color: var(--text-secondary, #475569); margin: 14px 0 6px; +} +.nb-ask-points { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 7px; } +.nb-ask-point { font-size: 13.5px; line-height: 1.45; color: var(--text, #0f172a); display: flex; flex-wrap: wrap; align-items: baseline; gap: 6px; } +.nb-cite { + font: 600 11px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace; + color: var(--accent, #2f6df6); background: var(--bg-card, #fff); + border: 1px solid var(--border, #e4e9f0); border-radius: 6px; padding: 1px 6px; +} +.nb-ask-next { margin: 0; padding-left: 18px; font-size: 13.5px; line-height: 1.5; color: var(--text-secondary, #475569); } +.nb-ask-next li { margin: 2px 0; } + +.nb-body-wrap { display: grid; grid-template-columns: 220px 1fr; gap: 22px; align-items: start; } +@media (max-width: 760px) { .nb-body-wrap { grid-template-columns: 1fr; } } + +/* thread rail (the inquiry spine) */ +.nb-threads { display: flex; flex-direction: column; gap: 4px; position: sticky; top: 12px; } +.nb-thread { + text-align: left; background: none; border: 0; cursor: pointer; font: inherit; font-size: 13px; + color: var(--text-secondary, #475569); padding: 8px 11px; border-radius: 9px; + transition: background .15s, color .15s; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; +} +.nb-thread:hover { background: var(--bg-hover, #f1f5f9); color: var(--text, #0f172a); } +.nb-thread.active { background: var(--accent-soft, #eaf1ff); color: var(--accent, #2f6df6); font-weight: 600; } + +/* notes */ +.nb-notes { display: flex; flex-direction: column; gap: 12px; } +.nb-empty { color: var(--text-muted, #94a3b8); font-size: 14px; font-style: italic; padding: 28px 4px; } + +.nb-card { + border: 1px solid var(--border, #e4e9f0); border-radius: 13px; padding: 14px 16px; + background: var(--bg-card, #fff); box-shadow: 0 1px 2px rgba(15, 23, 42, .04); +} +.nb-card-head { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; } +.nb-badge { + font-size: 11px; font-weight: 700; letter-spacing: .04em; text-transform: uppercase; + padding: 2px 9px; border-radius: 999px; color: #fff; background: var(--text-muted, #94a3b8); +} +.nb-badge.nb-k-obs { background: var(--accent, #2f6df6); } +.nb-badge.nb-k-find { background: var(--accent-green, #16a34a); } +.nb-badge.nb-k-q { background: #d97706; } +.nb-author { font-size: 12px; color: var(--text-secondary, #475569); } +.nb-status { + margin-left: auto; font-size: 11px; color: var(--text-muted, #94a3b8); + text-transform: uppercase; letter-spacing: .05em; +} +.nb-body-text { font-size: 14px; line-height: 1.5; color: var(--text, #0f172a); white-space: pre-wrap; } + +.nb-chips { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; } +.nb-chip { + font-size: 11.5px; color: var(--text-secondary, #475569); + background: var(--bg-hover, #f1f5f9); border-radius: 7px; padding: 2px 8px; +} diff --git a/gently/ui/web/static/css/operate.css b/gently/ui/web/static/css/operate.css new file mode 100644 index 00000000..cd28b6a7 --- /dev/null +++ b/gently/ui/web/static/css/operate.css @@ -0,0 +1,277 @@ +/* Operate view — "The Operator Spine": one guided surface for the bottom-cam → + SPIM workflow. Four regions: a thin header (stepper + always-on safety status), + a left spine (dish mini-map + embryo worklist), one swapping live viewport with + overlaid instruments, and a right rail that shows ONLY the active step's + controls. Theme-aware via the global tokens. */ + +.operate { + display: grid; + grid-template-columns: 264px minmax(0, 1fr) 248px; + grid-template-rows: auto minmax(0, 1fr); + grid-template-areas: + "head head head" + "spine stage rail"; + gap: 0.75rem; + height: calc(100vh - 165px); + min-height: 520px; + font-family: 'Inter Tight', system-ui, sans-serif; + color: var(--text); + --accent-red: #ef4444; +} + +/* ── HEADER ───────────────────────────────────────────────────────────── */ +.op-head { + grid-area: head; + display: flex; + flex-direction: column; + gap: 0.4rem; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 10px; + padding: 0.6rem 0.85rem; +} +.op-stepper { + display: flex; + align-items: center; + gap: 0.45rem; + flex-wrap: wrap; + font-size: 0.8rem; +} +.op-phase { + font-size: 0.62rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.12em; + color: var(--text-muted); +} +.op-phase b { font-weight: 700; color: var(--text); margin-left: 0.35rem; } +.op-phase-div { width: 1px; height: 16px; background: var(--border-strong); margin: 0 0.3rem; } +.op-arrow { color: var(--text-muted); font-size: 0.7rem; } +.op-node { + font-size: 0.78rem; + font-weight: 600; + color: var(--text-muted); + padding: 0.1rem 0.5rem; + border-radius: 999px; + border: 1px solid transparent; + white-space: nowrap; +} +.op-node.is-done { color: var(--accent-green); } +.op-node.is-done::before { content: "✓ "; } +.op-node.is-active { color: var(--text); border-color: var(--accent); background: color-mix(in srgb, var(--accent) 14%, transparent); } +.op-node.is-locked { opacity: 0.4; } +/* the Run node is a re-entry point to the chooser once embryos are marked */ +.op-stepper .op-node[data-node="run"]:not(.is-locked) { cursor: pointer; } +.op-stepper .op-node[data-node="run"]:not(.is-locked):hover { color: var(--accent); } + +.op-status { + display: flex; + align-items: center; + gap: 1.1rem; + flex-wrap: wrap; + padding-top: 0.4rem; + border-top: 1px solid var(--border); +} +.op-stat { display: inline-flex; align-items: baseline; gap: 0.4rem; font-size: 0.72rem; } +.op-stat i { font-style: normal; font-size: 0.6rem; font-weight: 700; letter-spacing: 0.1em; color: var(--text-muted); } +.op-stat b { font-weight: 600; color: var(--text); font-variant-numeric: tabular-nums; } +.op-stat.is-down b { color: var(--accent-orange); } +.op-stat.is-emitting b { color: var(--accent-red); } +.op-stat.is-emitting i { color: var(--accent-red); } + +/* ── LEFT SPINE ───────────────────────────────────────────────────────── */ +.op-spine { + grid-area: spine; + display: flex; + flex-direction: column; + gap: 0.6rem; + min-height: 0; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 10px; + padding: 0.7rem; +} +.op-minimap-wrap { + aspect-ratio: 1 / 1; + background: var(--img-bg); + border: 1px solid var(--border); + border-radius: 8px; + overflow: hidden; + flex: 0 0 auto; +} +.op-minimap { width: 100%; height: 100%; display: block; } +.op-board-head { + display: flex; justify-content: space-between; align-items: baseline; + font-size: 0.62rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.1em; + color: var(--text-muted); +} +.op-board-count { font-weight: 600; color: var(--text); } +.op-board { flex: 1 1 auto; min-height: 0; overflow-y: auto; display: flex; flex-direction: column; gap: 0.25rem; } +.op-brow { + display: flex; align-items: center; gap: 0.5rem; + padding: 0.4rem 0.45rem; + border: 1px solid transparent; + border-radius: 7px; + cursor: pointer; + transition: background 0.12s, border-color 0.12s; +} +.op-brow:hover { background: var(--bg-hover); } +.op-brow.is-sel { background: var(--bg-hover); border-color: var(--accent); } +.op-bdot { + flex: 0 0 auto; width: 20px; height: 20px; border-radius: 50%; + display: inline-flex; align-items: center; justify-content: center; + font-size: 0.66rem; font-weight: 700; color: #fff; background: var(--text-muted); +} +.op-bmeta { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: 0.15rem; } +.op-blabel { font-size: 0.74rem; font-weight: 600; } +/* 4-node progress track: centered · lowered · focused · imaged */ +.op-track { display: inline-flex; align-items: center; gap: 3px; } +.op-tnode { width: 7px; height: 7px; border-radius: 50%; background: var(--border-strong); } +.op-tnode.on-centered { background: var(--accent); } +.op-tnode.on-lowering { background: var(--accent); } +.op-tnode.on-focused { background: var(--accent-orange); } +.op-tnode.on-imaged { background: var(--accent-green); } +.op-bstate { flex: 0 0 auto; font-size: 0.58rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: var(--text-muted); } +.op-survey-btn { flex: 0 0 auto; } + +/* ── CENTER STAGE ─────────────────────────────────────────────────────── */ +.op-stage { grid-area: stage; position: relative; min-width: 0; min-height: 0; } +.op-badge { + position: absolute; top: 0.6rem; left: 0.6rem; z-index: 3; + font-size: 0.64rem; font-weight: 600; letter-spacing: 0.04em; + color: var(--text); background: color-mix(in srgb, var(--bg-card) 82%, transparent); + border: 1px solid var(--border); border-radius: 999px; padding: 0.2rem 0.6rem; + -webkit-backdrop-filter: blur(6px); backdrop-filter: blur(6px); +} +.op-cam { + position: relative; width: 100%; height: 100%; + background: var(--img-bg); + border: 1px solid var(--border); + border-radius: 10px; + overflow: hidden; +} +.op-cam-img { width: 100%; height: 100%; object-fit: contain; display: block; opacity: 0; transition: opacity 0.2s; } +.op-cam-img.has-frame { opacity: 1; } +.op-mark-canvas { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; } +.op-cam.is-marking { cursor: crosshair; box-shadow: inset 0 0 0 2px var(--accent-green); } +.op-cam.is-marking .op-mark-canvas { pointer-events: auto; } +.op-cam-ph { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; color: var(--text-muted); font-size: 0.82rem; letter-spacing: 0.04em; } + +/* ── RIGHT RAIL ───────────────────────────────────────────────────────── */ +.op-rail { + grid-area: rail; + display: flex; flex-direction: column; gap: 0.7rem; + min-height: 0; overflow-y: auto; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 10px; + padding: 0.85rem 0.8rem; +} +.op-rail-head { font-size: 0.82rem; font-weight: 600; color: var(--text); } +/* progressive disclosure: only the active step's group renders */ +.op-group { display: none; flex-direction: column; gap: 0.7rem; } +.op-rail[data-active="a1"] .op-group[data-step="a1"], +.op-rail[data-active="a2"] .op-group[data-step="a2"], +.op-rail[data-active="b1"] .op-group[data-step="b1"], +.op-rail[data-active="b2"] .op-group[data-step="b2"], +.op-rail[data-active="b3"] .op-group[data-step="b3"], +.op-rail[data-active="bc"] .op-group[data-step="bc"], +.op-rail[data-active="b4"] .op-group[data-step="b4"], +.op-rail[data-active="b5"] .op-group[data-step="b5"], +.op-rail[data-active="c0"] .op-group[data-step="c0"], +.op-rail[data-active="running"] .op-group[data-step="running"] { display: flex; } + +/* ── Run chooser (c0) ── */ +.op-rolechips { display: flex; flex-wrap: wrap; gap: 0.3rem; } +.op-rolechip { + font-size: 0.66rem; font-weight: 600; padding: 0.2rem 0.5rem; border-radius: 999px; + border: 1px solid var(--border); background: var(--bg-hover); color: var(--text); cursor: pointer; + transition: background 0.12s, border-color 0.12s, color 0.12s; +} +.op-rolechip.is-reference { background: color-mix(in srgb, var(--accent-cyan) 18%, transparent); border-color: var(--accent-cyan); color: var(--accent-cyan); } +.op-modes { display: flex; flex-direction: column; gap: 0.3rem; } +.op-mode { + display: flex; align-items: baseline; gap: 0.45rem; + padding: 0.4rem 0.5rem; border: 1px solid var(--border); border-radius: 7px; cursor: pointer; + font-size: 0.78rem; transition: border-color 0.12s, background 0.12s; +} +.op-mode:hover { background: var(--bg-hover); } +.op-mode:has(input:checked) { border-color: var(--accent); background: color-mix(in srgb, var(--accent) 10%, transparent); } +.op-mode input { margin: 0; accent-color: var(--accent); } +.op-mode span { font-weight: 600; } +.op-mode small { color: var(--text-muted); font-size: 0.66rem; margin-left: auto; } +.op-modepanel { display: none; flex-direction: column; gap: 0.45rem; padding: 0.5rem; border: 1px solid var(--border); border-radius: 7px; background: var(--bg-dark); } +.op-modepanel.is-shown { display: flex; } +.op-tl-row { display: flex; align-items: center; gap: 0.5rem; font-size: 0.74rem; color: var(--text-muted); } +.op-tl-row label { flex: 0 0 52px; } +.op-num { width: 70px; background: var(--bg-card); border: 1px solid var(--border); color: var(--text); border-radius: 5px; padding: 0.25rem 0.4rem; font: inherit; font-size: 0.74rem; } +.op-sel-s { flex: 1; background: var(--bg-card); border: 1px solid var(--border); color: var(--text); border-radius: 5px; padding: 0.25rem 0.4rem; font: inherit; font-size: 0.74rem; } +.op-lib-list, .op-plan-list { display: flex; flex-direction: column; gap: 0.25rem; max-height: 160px; overflow-y: auto; } +.op-libitem, .op-planitem { + text-align: left; background: var(--bg-card); border: 1px solid var(--border); color: var(--text); + border-radius: 6px; padding: 0.35rem 0.5rem; cursor: pointer; font: inherit; font-size: 0.74rem; + transition: border-color 0.12s, background 0.12s; +} +.op-libitem:hover, .op-planitem:hover { border-color: var(--accent); } +.op-libitem.is-sel, .op-planitem.is-sel { border-color: var(--accent); background: color-mix(in srgb, var(--accent) 12%, transparent); } +.op-libitem small, .op-planitem small { display: block; color: var(--text-muted); font-size: 0.64rem; } + +/* ── Run-spine (running) ── */ +.op-runspine { display: flex; flex-direction: column; gap: 0.4rem; max-height: 50vh; overflow-y: auto; } +.op-tcard { border: 1px solid var(--border); border-left-width: 3px; border-radius: 7px; padding: 0.5rem 0.6rem; background: var(--bg-dark); } +.op-tcard.st-active { border-left-color: var(--accent-green); } +.op-tcard.st-planned { border-left-color: var(--text-muted); } +.op-tcard.st-paused { border-left-color: var(--accent-orange); } +.op-tcard.st-done { border-left-color: var(--accent); } +.op-tcard-head { display: flex; justify-content: space-between; align-items: baseline; gap: 0.5rem; } +.op-tcard-name { font-size: 0.78rem; font-weight: 600; } +.op-tcard-state { font-size: 0.6rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; color: var(--text-muted); } +.op-tcard-kind { font-size: 0.66rem; color: var(--text-muted); } +.op-tcard-meta { font-size: 0.68rem; color: var(--text-muted); font-variant-numeric: tabular-nums; margin-top: 0.2rem; } +.op-run-actions { display: flex; gap: 0.5rem; } +.op-run-actions .op-btn { flex: 1; } + +.op-field { display: flex; flex-direction: column; gap: 0.4rem; } +.op-label { font-size: 0.64rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.08em; color: var(--text-muted); } +.op-cap { font-size: 0.66rem; color: var(--text-muted); font-variant-numeric: tabular-nums; text-transform: none; letter-spacing: 0; } +.op-hud-line { font-size: 0.8rem; color: var(--text); font-variant-numeric: tabular-nums; } +.op-hud-line b { font-weight: 700; } +.op-hint { font-size: 0.72rem; color: var(--text-muted); line-height: 1.4; } +.op-sep { color: var(--text-muted); margin: 0 0.35rem; } + +.op-btn { + appearance: none; background: var(--bg-hover); border: 1px solid var(--border); color: var(--text); + font: 600 0.8rem/1 'Inter Tight', system-ui, sans-serif; + padding: 0.55rem 0.75rem; border-radius: 7px; cursor: pointer; + transition: background 0.13s, border-color 0.13s, color 0.13s, opacity 0.13s; +} +.op-btn:hover:not(:disabled) { border-color: var(--accent); color: var(--accent); } +.op-btn:disabled { opacity: 0.4; cursor: not-allowed; } +.op-btn-primary { background: var(--accent); border-color: var(--accent); color: #fff; } +.op-btn-primary:hover:not(:disabled) { background: var(--accent-hover); border-color: var(--accent-hover); color: #fff; } +.op-btn-ghost { background: transparent; } +.op-btn-toggle[aria-pressed="true"] { background: var(--accent-orange); border-color: var(--accent-orange); color: #2a1503; } +.op-btn-on { background: var(--accent-green); border-color: var(--accent-green); color: #06281a; } + +.op-nudge { display: flex; gap: 0.35rem; } +.op-nudge-2 { flex-wrap: wrap; } +.op-nbtn { + flex: 1; min-width: 44px; background: var(--bg-hover); border: 1px solid var(--border); color: var(--text); + font: 600 0.74rem/1 'Inter Tight', system-ui, sans-serif; font-variant-numeric: tabular-nums; + padding: 0.42rem 0.3rem; border-radius: 6px; cursor: pointer; + transition: background 0.12s, border-color 0.12s, color 0.12s, opacity 0.12s; +} +.op-nbtn:hover:not(:disabled) { border-color: var(--accent); color: var(--accent); } +.op-nbtn:disabled { opacity: 0.35; cursor: not-allowed; } +.op-spim-controls { display: flex; gap: 0.5rem; } +.op-spim-controls .op-btn { flex: 1; } +.op-micro { display: flex; align-items: center; gap: 0.4rem; font-size: 0.74rem; color: var(--text-muted); font-variant-numeric: tabular-nums; } +.op-micro b { color: var(--text); min-width: 2.4em; text-align: center; } +.op-micro .op-nbtn { flex: 0 0 auto; min-width: 26px; width: 26px; } + +.op-empty { color: var(--text-muted); font-size: 0.78rem; padding: 0.5rem 0.2rem; } + +@media (max-width: 1040px) { + .operate { grid-template-columns: 1fr; grid-template-rows: auto auto 56vh auto; + grid-template-areas: "head" "spine" "stage" "rail"; height: auto; } +} diff --git a/gently/ui/web/static/css/shell.css b/gently/ui/web/static/css/shell.css new file mode 100644 index 00000000..c38ae89a --- /dev/null +++ b/gently/ui/web/static/css/shell.css @@ -0,0 +1,105 @@ +/* ux_v2 shell chrome: grouped left-rail nav + session-context strip. + Everything is scoped under body.ux-v2 so the v1 dashboard is byte-for-byte + untouched — no consolidation of the existing duplicate .tab rulesets here + (that cleanup is deferred to the final phase). */ + +/* Replace the flat 8-tab bar with the rail. */ +body.ux-v2 .tabs { display: none; } + +/* ── Left rail ─────────────────────────────────────────────── */ +body.ux-v2 .v2-rail { + flex: 0 0 212px; + display: flex; + flex-direction: column; + gap: 2px; + padding: 14px 10px; + border-right: 1px solid var(--border, #e4e9f0); + background: var(--bg-card, #fff); + overflow-y: auto; + animation: v2-rise .45s ease backwards; +} +body.ux-v2 .v2-nav-group { margin-bottom: 6px; } +body.ux-v2 .v2-nav-label { + font-size: 10px; letter-spacing: .1em; text-transform: uppercase; + color: var(--text-muted, #94a3b8); padding: 10px 10px 4px; +} +body.ux-v2 .v2-nav-item { + display: flex; align-items: center; gap: 8px; width: 100%; + background: none; border: 0; cursor: pointer; text-align: left; + padding: 8px 10px; border-radius: 8px; + font: inherit; font-size: 13.5px; + color: var(--text-secondary, #475569); + transition: background .15s, color .15s; +} +body.ux-v2 .v2-nav-item:hover { background: var(--bg-hover, #f1f5f9); color: var(--text, #0f172a); } +body.ux-v2 .v2-nav-item.active { + background: var(--accent-soft, #eaf1ff); + color: var(--accent, #2f6df6); + font-weight: 600; +} +body.ux-v2 .v2-rail-chat { + margin-top: auto; + display: flex; align-items: center; gap: 9px; + background: none; border: 1px solid var(--border, #e4e9f0); border-radius: 10px; + padding: 9px 12px; cursor: pointer; + font: inherit; font-size: 13px; + color: var(--text-secondary, #475569); + transition: border-color .15s, color .15s; +} +body.ux-v2 .v2-rail-chat:hover { border-color: var(--accent, #2f6df6); color: var(--accent, #2f6df6); } +body.ux-v2 .v2-rail-orb { + width: 18px; height: 18px; border-radius: 50%; flex: none; + background: radial-gradient(closest-side at 38% 34%, #fff, #bcd3ff 42%, var(--accent, #2f6df6) 100%); +} + +/* ── Session-context strip (top of main) ───────────────────── */ +body.ux-v2 .v2-strip { + flex: none; + display: flex; align-items: center; gap: 12px; + padding: 9px 16px; + border-bottom: 1px solid var(--border, #e4e9f0); + background: var(--bg-card, #fff); + font-size: 12.5px; color: var(--text-muted, #94a3b8); + animation: v2-rise .45s ease backwards .05s; +} +body.ux-v2 .v2-strip-live { + display: inline-flex; align-items: center; gap: 6px; + font-size: 10.5px; font-weight: 700; letter-spacing: .08em; color: #ef4444; +} +body.ux-v2 .v2-strip-dot { + width: 8px; height: 8px; border-radius: 50%; background: #ef4444; +} +body.ux-v2 .v2-strip-status { margin-left: auto; font-variant-numeric: tabular-nums; } + +body.ux-v2 .app-main { animation: v2-rise .5s ease backwards .1s; } + +@keyframes v2-rise { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } } +@media (prefers-reduced-motion: reduce) { + body.ux-v2 .v2-rail, body.ux-v2 .v2-strip, body.ux-v2 .app-main { animation: none; } +} + +/* ── Shared-visibility surface (the agent's view) ──────────── */ +body.ux-v2 .cx-surface { + margin: 0 0 16px; + border: 1px solid var(--border, #e4e9f0); + border-radius: 14px; + background: var(--bg-card, #fff); + padding: 14px 16px; +} +body.ux-v2 .cx-surface.hidden { display: none; } +body.ux-v2 .cx-title { font-size: 11px; letter-spacing: .1em; text-transform: uppercase; color: var(--text-muted, #94a3b8); margin-bottom: 8px; } +body.ux-v2 .cx-lens { margin-bottom: 10px; } +body.ux-v2 .cx-lens-h { font-size: 11px; font-weight: 600; color: var(--text-secondary, #475569); margin: 6px 0 4px; } +body.ux-v2 .cx-item { display: flex; align-items: center; gap: 9px; padding: 5px 0; flex-wrap: wrap; } +body.ux-v2 .cx-text { flex: 1; min-width: 0; font-size: 13px; color: var(--text, #0f172a); } +body.ux-v2 .cx-dot { width: 7px; height: 7px; border-radius: 50%; flex: none; } +body.ux-v2 .cx-dot.cx-q { background: #d97706; } +body.ux-v2 .cx-dot.cx-w { background: var(--accent, #2f6df6); } +body.ux-v2 .cx-dot.cx-e { background: var(--accent-green, #16a34a); } +body.ux-v2 .cx-act { flex: none; border: 1px solid var(--border, #e4e9f0); background: none; color: var(--text-secondary, #475569); border-radius: 8px; padding: 3px 10px; font: inherit; font-size: 12px; cursor: pointer; } +body.ux-v2 .cx-act:hover { border-color: var(--accent, #2f6df6); color: var(--accent, #2f6df6); } +body.ux-v2 .cx-answer { display: flex; gap: 6px; align-items: center; flex: 1 0 100%; margin-top: 4px; } +body.ux-v2 .cx-answer.hidden { display: none; } +body.ux-v2 .cx-answer-input { flex: 1; min-width: 0; border: 1px solid var(--border, #cbd5e1); border-radius: 8px; padding: 6px 9px; font: inherit; font-size: 12px; } +body.ux-v2 .cx-answer-go { border: 0; background: var(--accent, #2f6df6); color: #fff; border-radius: 8px; padding: 6px 10px; cursor: pointer; } +body.ux-v2 .cx-empty { font-size: 12.5px; color: var(--text-muted, #94a3b8); font-style: italic; padding: 2px 0 4px; } diff --git a/gently/ui/web/static/js/agent-chat.js b/gently/ui/web/static/js/agent-chat.js index 63339b92..6e772194 100644 Binary files a/gently/ui/web/static/js/agent-chat.js and b/gently/ui/web/static/js/agent-chat.js differ diff --git a/gently/ui/web/static/js/app.js b/gently/ui/web/static/js/app.js index d1e75a72..6f51da38 100644 --- a/gently/ui/web/static/js/app.js +++ b/gently/ui/web/static/js/app.js @@ -60,6 +60,8 @@ function updateCalibrationCount() { function switchTab(tabName) { if (!tabName) return; state.tab = tabName; + // ux_v2 grouped rail mirrors the active tab off this single chokepoint. + if (typeof ClientEventBus !== 'undefined') ClientEventBus.emit('TAB_CHANGED', tabName); // Update tab styling document.querySelectorAll('.tab').forEach(t => t.classList.remove('active')); @@ -98,6 +100,16 @@ function switchTab(tabName) { ExperimentOverview.init(); } + // Lazy-init Notebook tab + if (tabName === TABS.NOTEBOOK && typeof NotebookApp !== 'undefined') { + NotebookApp.init(); + } + + // Lazy-init Gallery tab + if (tabName === TABS.GALLERY && typeof GalleryTab !== 'undefined') { + GalleryTab.init(); + } + // Update statusbar for context updateStatusbar(); } @@ -538,11 +550,13 @@ function fetchDeviceStatus() { .then(r => r.json()) .then(data => { _microscopeConnected = data.microscope; - _setBadge('status-microscope-badge', data.microscope, 'Online', 'Offline'); - updateTopLevelDot(); + ConnectionStatus.setMicroscope(data.microscope); }) .catch(() => { - _setBadge('status-microscope-badge', false, '', '--'); + // Transient poll failure: keep the last-known badge. The next + // successful poll re-renders via the store if the value changed + // (writing '--' here could stick, since the store only re-renders + // on an actual change, not on an unchanged success). }); } @@ -555,23 +569,26 @@ function _setBadge(id, isOn, onText, offText) { } function updateGentlyStatus(connected) { - _setBadge('status-gently-badge', connected, 'Online', 'Offline'); - updateTopLevelDot(); + // Feed the single source of truth; the header re-renders via the + // ConnectionStatus subscriber (renderConnectionUI). + ConnectionStatus.setGently(connected); } -function updateTopLevelDot() { +// Single renderer for the header connection UI, driven by a ConnectionStatus +// snapshot. Subscribed once at startup, so the pill, both popover badges, and +// the dot always reflect the same shared state. +function renderConnectionUI(s) { + _setBadge('status-gently-badge', s.gentlyConnected, 'Online', 'Offline'); + _setBadge('status-microscope-badge', s.microscopeConnected, 'Online', 'Offline'); const dot = document.getElementById('status-dot'); const text = document.getElementById('status-text'); if (!dot || !text) return; - const gentlyUp = state.connected; - const scopeUp = _microscopeConnected; - dot.classList.remove('connected', 'partial'); - if (gentlyUp && scopeUp) { + if (s.gentlyConnected && s.microscopeConnected) { dot.classList.add('connected'); text.textContent = 'Connected'; - } else if (gentlyUp) { + } else if (s.gentlyConnected) { dot.classList.add('partial'); text.textContent = 'Online'; } else { @@ -579,6 +596,11 @@ function updateTopLevelDot() { } } +// Back-compat shim: any legacy caller re-renders from the current snapshot. +function updateTopLevelDot() { + renderConnectionUI(ConnectionStatus.get()); +} + document.addEventListener('DOMContentLoaded', () => { // Initialize presence manager (before WebSocket so ID is ready) PresenceManager.init(); @@ -620,6 +642,11 @@ document.addEventListener('DOMContentLoaded', () => { } }); + // Connection status: one source of truth, three writers (this /ws, the + // device-status poll, and the agent /ws/agent). Subscribe the header + // renderer BEFORE connecting so the first handshake renders correctly. + ConnectionStatus.subscribe(renderConnectionUI); + // Start WebSocket connection connectWebSocket(); @@ -634,7 +661,7 @@ document.addEventListener('DOMContentLoaded', () => { const hash = window.location.hash.slice(1); // remove # if (hash) { const [tab, param] = hash.split(':'); - if (tab === TABS.HOME || tab === TABS.PLANS || tab === TABS.SESSIONS || tab === TABS.EMBRYOS || tab === TABS.CALIBRATION || tab === TABS.EVENTS || tab === TABS.EXPERIMENT) { + if (tab === TABS.HOME || tab === TABS.PLANS || tab === TABS.SESSIONS || tab === TABS.EMBRYOS || tab === TABS.CALIBRATION || tab === TABS.EVENTS || tab === TABS.EXPERIMENT || tab === TABS.NOTEBOOK || tab === TABS.GALLERY) { switchTab(tab); if (tab === TABS.PLANS && param && typeof openCampaign === 'function') { setTimeout(() => openCampaign(param), 200); diff --git a/gently/ui/web/static/js/ask-stage.js b/gently/ui/web/static/js/ask-stage.js new file mode 100644 index 00000000..b5e63db1 --- /dev/null +++ b/gently/ui/web/static/js/ask-stage.js @@ -0,0 +1,53 @@ +/** + * AskStage (ux_v2) — renders the agent's CURRENT pending ask prominently on the + * main stage, in addition to the chat transcript. One payload, two renderers: + * it reuses AgentChat.buildAskCard so the stage and the transcript can't drift, + * and answering from either surface clears both (via the ASK_CLEARED event that + * AgentChat fires off the CHOICE lifecycle — not stream_end, which arrives only + * after an in-turn answer and never for a cancelled turn). + * + * No-ops unless #ask-stage is present (gated behind GENTLY_UX_V2 in the + * template), so it never affects the v1 dashboard. + */ +const AskStage = (() => { + let stageEl = null; + let current = null; // { reqId, data, isWake } + + function clear() { + current = null; + if (stageEl) { stageEl.innerHTML = ''; stageEl.classList.add('hidden'); } + } + + function render() { + if (!stageEl || !current || typeof AgentChat === 'undefined' || !AgentChat.buildAskCard) return; + const hasControl = AgentChat.hasControl ? AgentChat.hasControl() : true; + const card = AgentChat.buildAskCard(current.data, { + reqId: current.reqId, + isWake: current.isWake, + hasControl, + onPick: (sel) => AgentChat.answerChoice(current.reqId, sel), + }); + stageEl.innerHTML = ''; + stageEl.appendChild(card); + stageEl.classList.remove('hidden'); + } + + function init() { + stageEl = document.getElementById('ask-stage'); + if (!stageEl || typeof ClientEventBus === 'undefined') return; // ux_v2 off → no-op + + ClientEventBus.on('AGENT_ASK', ({ request_id, choice_data, origin }) => { + current = { reqId: request_id, data: choice_data || {}, isWake: origin === 'wake' }; + render(); + }); + ClientEventBus.on('ASK_CLEARED', ({ request_id }) => { + if (!current) return; + if (request_id === '*' || request_id === current.reqId) clear(); + }); + // Re-render read-only / actionable when control changes hands mid-ask. + ClientEventBus.on('AGENT_CONTROL', () => { if (current) render(); }); + } + + document.addEventListener('DOMContentLoaded', init); + return { clear }; +})(); diff --git a/gently/ui/web/static/js/campaigns.js b/gently/ui/web/static/js/campaigns.js index 36d29623..d42cdbd4 100644 --- a/gently/ui/web/static/js/campaigns.js +++ b/gently/ui/web/static/js/campaigns.js @@ -40,6 +40,18 @@ const SPEC_UNITS = { laser_power_pct: '%', interval_s: 's', estimated_duration_h: ' hrs', estimated_days: ' days', }; +// Imaging-spec fields the inspector lets you edit/fill inline (ordered for the form). +// Empty ones still show \u2014 that's how you fill a TBD value like laser power. +const IMAGING_SPEC_FIELDS = [ + 'strain', 'genotype', 'reporter', 'sample_prep', 'temperature_c', 'num_embryos', + 'num_slices', 'exposure_ms', 'laser_wavelength_nm', 'laser_power_pct', 'interval_s', + 'target_window', 'start_stage', 'stop_condition', 'estimated_duration_h', + 'success_criteria', 'comparison_to', +]; +const SPEC_NUMERIC = new Set([ + 'temperature_c', 'num_embryos', 'num_slices', 'exposure_ms', 'laser_wavelength_nm', + 'laser_power_pct', 'interval_s', 'estimated_duration_h', +]); // ── State ──────────────────────────────────────────────── const state = { @@ -52,6 +64,11 @@ const state = { versions: [], // snapshots list viewingSnapshotId: null, allItemsFlat: {}, // id → item for quick lookup + editingSpec: false, // inspector imaging-spec edit mode + _inspectorData: null, // last item-detail payload (for re-render on edit toggle) + _specError: '', // inline save error in the spec editor + _sessionPickerOpen: false, // whether the session link picker is visible + _availableSessions: null, // null = not yet loaded, [] = loaded (for link picker) }; // ── DOM refs (cached on init) ──────────────────────────── @@ -117,11 +134,19 @@ function boot() { case 'select-item': selectItem(id); break; case 'open-campaign': openCampaign(id); break; case 'navigate-item': e.stopPropagation(); navigateToItem(id); break; + case 'run-item': e.stopPropagation(); runPlanItem(id); break; case 'filter-type': applyTypeFilter(el.dataset.filterType); break; case 'view-version': viewVersion(el.dataset.versionId, el.dataset.isCurrent === 'true'); break; case 'back-to-current': backToCurrent(); break; case 'scroll-to': e.stopPropagation(); scrollCanvasTo(el.dataset.target); break; case 'toggle-phase': toggleNavPhase(el); break; + case 'spec-edit': e.stopPropagation(); startSpecEdit(); break; + case 'spec-cancel': e.stopPropagation(); cancelSpecEdit(); break; + case 'spec-save': e.stopPropagation(); saveSpecEdit(); break; + case 'session-picker-open': e.stopPropagation(); openSessionPicker(); break; + case 'session-picker-cancel': e.stopPropagation(); cancelSessionPicker(); break; + case 'session-picker-link': e.stopPropagation(); submitSessionLink(); break; + case 'session-delink': e.stopPropagation(); handleSessionDelink(el.dataset.sessionId); break; } }); @@ -131,6 +156,13 @@ function boot() { // Plan view switcher setupPlanViewSwitcher(); + // Live refresh: re-fetch the active campaign when the plan changes (item status, + // session link, new item, progress). The store emits PLAN_UPDATED, the server + // broadcasts it to /ws, and websocket.js re-emits it on the client bus. + if (typeof ClientEventBus !== 'undefined') { + ClientEventBus.on('PLAN_UPDATED', () => scheduleCampaignRefresh()); + } + // Load campaigns — auto-selects first, or the specified one const initialId = window.INITIAL_CAMPAIGN_ID; if (initialId) { @@ -267,6 +299,26 @@ async function openCampaign(campaignId) { renderAll(); } +// Live refresh of the open campaign (debounced) — re-fetch its tree and re-render, +// preserving the selected item so the inspector reflects the change without a reload. +let _planRefreshTimer = null; +function scheduleCampaignRefresh() { + if (_planRefreshTimer) clearTimeout(_planRefreshTimer); + _planRefreshTimer = setTimeout(() => { + _planRefreshTimer = null; + refreshActiveCampaign().catch(() => {}); + }, 400); +} +async function refreshActiveCampaign() { + if (!state.activeCampaignId) return; + const keep = state.selectedItemId; + await loadDocument(state.activeCampaignId); + if (!state.docData) return; + renderAll(); + // Don't clobber an in-progress spec edit with a re-fetch. + if (keep && !state.editingSpec) selectItem(keep).catch(() => {}); +} + // Handle browser back/forward window.addEventListener('popstate', e => { const s = e.state; @@ -579,6 +631,14 @@ function renderVersionHistory() { // ══════════════════════════════════════════════════════════ async function selectItem(itemId) { + // A re-fetch of the same item (e.g. after saving) keeps view mode; switching + // to a different item always lands in read-only. + if (itemId !== state.selectedItemId) { + state.editingSpec = false; + state._specError = ''; + state._sessionPickerOpen = false; + state._availableSessions = null; + } state.selectedItemId = itemId; // Highlight in document @@ -617,10 +677,17 @@ async function selectItem(itemId) { } function renderInspector(data) { + state._inspectorData = data; const item = data.item; const deps = data.dependencies || []; const dnts = data.dependents || []; - const sessions = data.sessions || []; + // Build a metadata map from the campaign-level sessions included in the payload, + // then derive the per-item sessions list from item.session_ids (the true source + // of truth). data.sessions is the campaign pool; we only show sessions that are + // actually linked to THIS item. + const _sessionMeta = {}; + (data.sessions || []).forEach(s => { _sessionMeta[s.session_id || s.id] = s; }); + const sessions = (item.session_ids || []).map(sid => _sessionMeta[sid] || { session_id: sid, id: sid }); if ($inspectorTitle) $inspectorTitle.textContent = item.title; if ($inspectorStatus) { @@ -639,6 +706,19 @@ function renderInspector(data) { ${item.id}
`; + // Run affordance — only for an actionable imaging item. Routes through the + // agent (it applies this item's spec via execute_plan_item), in keeping with + // the agent-first paradigm. + if (item.type === 'imaging' && item.status === 'planned') { + html += `
+ + Hands it to the agent to apply the spec and start +
`; + } + // Description if (item.description) { html += section('Description', `
${esc(item.description)}
`); @@ -649,9 +729,20 @@ function renderInspector(data) { html += section('Outcome', `
${esc(item.outcome)}
`); } - // Imaging spec - if (item.imaging_spec) { - html += section('Imaging Specification', `${renderSpecTable(item.imaging_spec)}
`); + // Imaging spec — view, or edit/fill inline (the laser-power loop). Shown for any + // imaging item even when no spec is set yet, so empty fields can be filled. + if (item.type === 'imaging' || item.imaging_spec) { + const spec = item.imaging_spec || {}; + if (state.editingSpec) { + html += section('Imaging Specification', renderSpecEditor(spec)); + } else { + const rows = renderSpecTable(spec); + const content = rows + ? `${rows}
` + : '
No parameters set yet
'; + const editBtn = ''; + html += section('Imaging Specification', content, editBtn); + } } // Bench spec @@ -709,24 +800,235 @@ function renderInspector(data) { html += section('References', refHtml); } - // Sessions + // Sessions — item-scoped (item.session_ids), with link/delink controls. + const _linkBtn = ``; + let sessHtml = ''; if (sessions.length > 0) { - let sessHtml = ''; sessions.forEach(s => { + const sid = s.session_id || s.id || ''; + const name = s.name || s.planned_intent || sid || 'Session'; sessHtml += `
- ${esc(s.planned_intent || s.id || 'Session')} - ${s.created_at ? `${formatDate(s.created_at)}` : ''} + ${esc(name)} + + ${s.created_at ? `${formatDate(s.created_at)}` : ''} + +
`; }); - html += section('Sessions', sessHtml); } else { - html += section('Sessions', - '
No linked sessions
'); + sessHtml = '
No linked sessions
'; + } + // Inline link picker — rendered when openSessionPicker() has set state flag + loaded data. + if (state._sessionPickerOpen) { + if (state._availableSessions === null) { + // Still loading — show spinner text; will re-render once fetch completes. + sessHtml += `
Loading sessions…
`; + } else { + const _linkedIds = new Set(item.session_ids || []); + const _available = state._availableSessions.filter(s => !_linkedIds.has(s.session_id)); + const _opts = _available.length === 0 + ? `` + : _available.map(s => ``).join(''); + sessHtml += `
+ +
+ + +
+
`; + } } + html += section('Sessions', sessHtml, _linkBtn); if ($inspectorBody) $inspectorBody.innerHTML = html; } +// Editable imaging-spec form. Lists every fillable field — empty ones included, +// flagged — so a TBD value (e.g. laser power) is obvious and one click away. +function renderSpecEditor(spec) { + let rows = ''; + for (const key of IMAGING_SPEC_FIELDS) { + const label = SPEC_LABELS[key] || key; + const val = spec[key]; + const has = val != null && val !== ''; + const numeric = SPEC_NUMERIC.has(key); + const unit = SPEC_UNITS[key] + ? `${esc(SPEC_UNITS[key].trim())}` : ''; + const rowCls = has ? 'spec-edit-row' : 'spec-edit-row spec-edit-row--empty'; + rows += `
+ + + ${unit} + +
`; + } + const err = state._specError + ? `
${esc(state._specError)}
` : ''; + return `
+ ${rows} + ${err} +
+ + +
+
`; +} + +function startSpecEdit() { + if (!state._inspectorData) return; + state.editingSpec = true; + state._specError = ''; + renderInspector(state._inspectorData); +} + +function cancelSpecEdit() { + state.editingSpec = false; + state._specError = ''; + if (state._inspectorData) renderInspector(state._inspectorData); +} + +// Collect changed/filled fields and PATCH them. The store fires PLAN_UPDATED, +// which live-refreshes the plan; we also re-fetch the inspector for immediacy. +async function saveSpecEdit() { + const data = state._inspectorData; + const item = data && data.item; + const campaignId = state.activeCampaignId; + if (!item || !campaignId) return; + + const orig = item.imaging_spec || {}; + const specPatch = {}; + document.querySelectorAll('#inspector-body [data-spec-key]').forEach(inp => { + const key = inp.dataset.specKey; + const raw = inp.value.trim(); + const hadVal = orig[key] != null && orig[key] !== ''; + if (raw === '') { + if (hadVal) specPatch[key] = ''; // cleared an existing value → unset + return; // stayed empty → skip + } + let v = raw; + if (SPEC_NUMERIC.has(key)) { + const n = Number(raw); + if (!Number.isNaN(n)) v = n; + } + if (String(orig[key] ?? '') !== String(v)) specPatch[key] = v; + }); + + state.editingSpec = false; + state._specError = ''; + if (Object.keys(specPatch).length === 0) { + selectItem(item.id).catch(() => {}); // nothing changed — just leave edit mode + return; + } + + try { + const res = await fetch( + `/api/campaigns/${encodeURIComponent(campaignId)}/items/${encodeURIComponent(item.id)}`, + { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ spec: specPatch }), + }, + ); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + selectItem(item.id).catch(() => {}); // refresh inspector now; PLAN_UPDATED refreshes the plan + } catch (err) { + console.error('Failed to save spec:', err); + state.editingSpec = true; + state._specError = 'Could not save — try again.'; + renderInspector(data); + } +} + +// ── Session link / delink ───────────────────────────────────────────────────── + +// Open the inline session picker. Fetches /api/sessions and re-renders with the +// picker shown. Two-phase: immediate re-render with loading state, then again +// once the fetch resolves (mirrors the pattern of selectItem loading state). +async function openSessionPicker() { + state._sessionPickerOpen = true; + state._availableSessions = null; // triggers loading display + if (state._inspectorData) renderInspector(state._inspectorData); + try { + const res = await fetch('/api/sessions'); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const body = await res.json(); + state._availableSessions = body.sessions || []; + } catch (err) { + console.error('Failed to load sessions for picker:', err); + state._availableSessions = []; + } + if (state._sessionPickerOpen && state._inspectorData) { + renderInspector(state._inspectorData); + } +} + +function cancelSessionPicker() { + state._sessionPickerOpen = false; + state._availableSessions = null; + if (state._inspectorData) renderInspector(state._inspectorData); +} + +// Read the picker ' : ''} + `).join(''); + const wHtml = watchpoints.map(it => ` +
+ + ${esc(it.target)}${it.condition ? ' — ' + esc(it.condition) : ''} + ${hc ? '' : ''} +
`).join(''); + const eHtml = expectations.map(it => ` +
+ + ${esc(it.target)}${it.prediction ? ': ' + esc(it.prediction) : ''} + ${hc ? '' : ''} +
`).join(''); + + // kind → existing cx-dot color: observation=blue, finding=green, question=amber + const dotFor = (k) => k === 'finding' ? 'cx-e' : (k === 'question' ? 'cx-q' : 'cx-w'); + const nHtml = notes.map(n => ` +
+ + ${esc(n.title || n.body)} +
`).join(''); + + el.innerHTML = '
Agent’s view
' + + section('Open questions', qHtml) + section('Watching', wHtml) + + section('Expectations', eHtml) + section('From the notebook', nHtml); + wire(); + } + + function wire() { + el.querySelectorAll('.cx-item').forEach(item => { + const kind = item.dataset.kind, id = item.dataset.id; + const actBtn = item.querySelector('.cx-act'); + if (!actBtn) return; + const act = actBtn.dataset.act; + if (act === 'answer') { + const box = item.querySelector('.cx-answer'); + const input = item.querySelector('.cx-answer-input'); + const submit = () => resolve(kind, id, { resolution: input.value.trim() }); + actBtn.addEventListener('click', () => { box.classList.toggle('hidden'); if (!box.classList.contains('hidden')) input.focus(); }); + item.querySelector('.cx-answer-go').addEventListener('click', submit); + input.addEventListener('keydown', e => { if (e.key === 'Enter') { e.preventDefault(); submit(); } }); + } else if (act === 'resolve') { + actBtn.addEventListener('click', () => resolve(kind, id, {})); + } else if (act === 'confirm') { + actBtn.addEventListener('click', () => resolve(kind, id, { status: 'confirmed' })); + } + }); + el.querySelectorAll('.cx-note').forEach(row => { + row.style.cursor = 'pointer'; + row.addEventListener('click', () => { + if (typeof switchTab === 'function') switchTab('notebook'); + }); + }); + } + + async function resolve(kind, id, body) { + try { + await fetch(`/api/context/${kind}/${encodeURIComponent(id)}/resolve`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body || {}), + }); + } catch (e) { /* ignore; surface stays as-is */ } + fetchAndRender(); // CONTEXT_UPDATED also re-fetches for every client + } + + function init() { + el = document.getElementById('context-surface'); + if (!el) return; // ux_v2 off → no-op + if (typeof ClientEventBus !== 'undefined') { + ClientEventBus.on('CONTEXT_UPDATED', () => fetchAndRender()); + ClientEventBus.on('AGENT_CONTROL', () => fetchAndRender()); // re-render with/without resolve controls + } + fetchAndRender(); + } + + document.addEventListener('DOMContentLoaded', init); + return { refresh: fetchAndRender }; +})(); diff --git a/gently/ui/web/static/js/control-auth.js b/gently/ui/web/static/js/control-auth.js new file mode 100644 index 00000000..359a1e4e --- /dev/null +++ b/gently/ui/web/static/js/control-auth.js @@ -0,0 +1,47 @@ +// control-auth.js — friendly, actionable hint when a control action is denied. +// +// Control routes (POST/PUT/DELETE that move hardware or mutate state) return 403 +// when the session lacks the control role — e.g. account mode with no operator +// logged in. Without this, the only signal is a bare 403 in the console and a +// button that silently does nothing. This installs a single global fetch wrapper +// that surfaces a throttled "Control required — Log in" toast instead. It only +// reads res.status (never consumes the body), so callers behave unchanged. +(function () { + if (window.__gentlyControlAuthPatched) return; + window.__gentlyControlAuthPatched = true; + + const origFetch = window.fetch.bind(window); + let lastHintAt = 0; + + function isControlRequest(input, init) { + let method = (init && init.method) || (typeof input === 'object' && input && input.method) || 'GET'; + method = String(method).toUpperCase(); + if (method === 'GET' || method === 'HEAD' || method === 'OPTIONS') return false; + const url = typeof input === 'string' ? input : (input && input.url) || ''; + return url.includes('/api/'); + } + + function showLoginHint() { + const now = Date.now(); + if (now - lastHintAt < 4000) return; // throttle so repeated clicks don't stack + lastHintAt = now; + const msg = 'Control required — you are in view-only mode.'; + if (typeof showGentlyToast === 'function') { + showGentlyToast(msg, 'Log in', () => { window.location.href = '/login'; }, 7000); + } else { + console.warn(msg + ' Log in at /login to drive hardware.'); + } + } + + window.fetch = async function (input, init) { + const res = await origFetch(input, init); + try { + if (res.status === 403 && isControlRequest(input, init)) { + showLoginHint(); + } + } catch (_) { + // Never let the hint interfere with the actual response. + } + return res; + }; +})(); diff --git a/gently/ui/web/static/js/devices.js b/gently/ui/web/static/js/devices.js index caa1bf48..47bf1605 100644 --- a/gently/ui/web/static/js/devices.js +++ b/gently/ui/web/static/js/devices.js @@ -14,7 +14,7 @@ */ const DevicesManager = (function () { const STALE_AFTER_MS = 4000; - const VIEWS = ['map', 'details']; + const VIEWS = ['operate', 'map', 'details', 'optical3d', 'manual']; const SVG_NS = 'http://www.w3.org/2000/svg'; // Status / details DOM @@ -53,7 +53,7 @@ const DevicesManager = (function () { let _selectedEmbryoId = null; // Bottom-camera panel DOM + state - let _camPanel, _camToggle, _camImg, _camPlaceholder, _camLed, _camMeta; + let _camPanel, _camToggle, _camExpand, _camImg, _camPlaceholder, _camLed, _camMeta; let _camStage, _camCrosshair, _camCrosshairGroup; let _camStreaming = false; let _camLastFrameTs = 0; @@ -72,6 +72,57 @@ const DevicesManager = (function () { const _CAM_ZOOM_MAX = 8; const _CAM_ZOOM_STEP = 1.15; // multiplicative per wheel notch + // Lightsheet live panel DOM + state (Manual view) + let _lsToggle, _lsImg, _lsPlaceholder, _lsLed, _lsMeta, _lsStage; + let _lsStreaming = false; + let _lsLastFrameTs = 0; + let _lsHasFrame = false; + let _lsStaleTimer = null; + const _LS_FPS_WINDOW = 12; + let _lsFrameTimes = []; + // Render throttle: decouple paint rate from frame-arrival rate. We keep + // only the latest frame, coalesce paints to one per animation frame, and + // hold a single decode in flight at a time. This stops a fast stream from + // swapping .src 100+ times/sec, which churns GPU texture uploads and + // can hang an older display driver (Video TDR). See handleLightsheetFrame. + let _lsPendingPayload = null; + let _lsRenderScheduled = false; + let _lsDecoding = false; + + // Lightsheet zoom / pan (mirrors camera zoom/pan) + let _lsZoom = 1; + let _lsTx = 0; + let _lsTy = 0; + let _lsPanLast = null; + + // Lightsheet live params — debounced POST to /api/devices/lightsheet/live/params + let _lsGalvo = 0; + let _lsPiezo = 0; + let _lsExposure = 20; // matches device-layer _ls_params default (20 ms) + let _lsSide = 'A'; // SPIM side — 'A' (HamCam1) or 'B' (HamCam2 if present) + let _lsParamTimer = null; + + // Lightsheet control inputs (rail) + let _lsGalvoSlider, _lsGalvoNum, _lsPiezoSlider, _lsPiezoNum, _lsExposureNum; + let _lsLedToggle, _lsRoomLightBtn; + let _lsLedIsOpen = false; // LED toggle state: false = Closed (safe default) + let _lsLaserToggle; + let _lsLaserOn = false; // Laser toggle state: false = OFF (entry-safe default) + let _lsSnapVolBtn, _lsBurstBtn, _lsLastcap, _lsLastcapRef; + let _lsLaserStatus; // span inside .ls-laser-indicator — driven by actual laser/off calls + let _lsLaserPreset; // — shown only when camera_b present + let _lsTempInput, _lsTempSet; + + // Timelapse form DOM refs (Manual view — #devices-tl-group) + let _tlToggle, _tlBody; + let _tlInterval, _tlStop, _tlCondRow, _tlCondLabel, _tlCondVal; + let _tlEmbryos, _tlMode; + let _tlSlices, _tlExposure, _tlGalvoAmp, _tlGalvoCtr, _tlPiezoAmp, _tlPiezoCtr, _tlLaser; + let _tlStart, _tlStatus, _tlStatusText; + // Accordion active-state per section: { sched, targets, geom } + let _tlTouched = { sched: false, targets: false, geom: false }; + // Room-light toggle (header). Drives the SwitchBot Bot that switches the // diSPIM room light. State is the bot's cached on/off; hidden until the // device layer reports the accessory is configured. @@ -143,6 +194,7 @@ const DevicesManager = (function () { _camPanel = document.getElementById('devices-camera-panel'); _camToggle = document.getElementById('devices-camera-toggle'); + _camExpand = document.getElementById('devices-camera-expand'); _camImg = document.getElementById('devices-camera-img'); _camPlaceholder = document.getElementById('devices-camera-placeholder'); _camStage = _camPanel ? _camPanel.querySelector('.devices-camera-stage') : null; @@ -151,6 +203,52 @@ const DevicesManager = (function () { _camLed = document.getElementById('devices-camera-led'); _camMeta = document.getElementById('devices-camera-meta'); + // Manual / lightsheet panel + _lsToggle = document.getElementById('devices-ls-toggle'); + _lsImg = document.getElementById('devices-ls-img'); + _lsPlaceholder = document.getElementById('devices-ls-placeholder'); + _lsStage = document.getElementById('devices-ls-stage'); + _lsLed = document.getElementById('devices-ls-led'); + _lsMeta = document.getElementById('devices-ls-meta'); + _lsGalvoSlider = document.getElementById('devices-ls-galvo-slider'); + _lsGalvoNum = document.getElementById('devices-ls-galvo'); + _lsPiezoSlider = document.getElementById('devices-ls-piezo-slider'); + _lsPiezoNum = document.getElementById('devices-ls-piezo'); + _lsExposureNum = document.getElementById('devices-ls-exposure'); + _lsLedToggle = document.getElementById('devices-ls-led-toggle'); + _lsRoomLightBtn = document.getElementById('devices-ls-room-light-btn'); + _lsLaserToggle = document.getElementById('devices-ls-laser-toggle'); + _lsSnapVolBtn = document.getElementById('devices-ls-snap-volume'); + _lsBurstBtn = document.getElementById('devices-ls-burst'); + _lsLastcap = document.getElementById('devices-ls-lastcap'); + _lsLastcapRef = document.getElementById('devices-ls-lastcap-ref'); + _lsLaserStatus = document.getElementById('devices-ls-laser-status'); + _lsLaserPreset = document.getElementById('devices-laser-preset'); + _lsSideSelect = document.getElementById('devices-ls-side'); + _lsTempInput = document.getElementById('devices-ls-temp-input'); + _lsTempSet = document.getElementById('devices-ls-temp-set'); + + // Timelapse form + _tlToggle = document.getElementById('devices-tl-toggle'); + _tlBody = document.getElementById('devices-tl-body'); + _tlInterval = document.getElementById('devices-tl-interval'); + _tlStop = document.getElementById('devices-tl-stop'); + _tlCondRow = document.getElementById('devices-tl-cond-row'); + _tlCondLabel = document.getElementById('devices-tl-cond-label'); + _tlCondVal = document.getElementById('devices-tl-cond-val'); + _tlEmbryos = document.getElementById('devices-tl-embryos'); + _tlMode = document.getElementById('devices-tl-mode'); + _tlSlices = document.getElementById('devices-tl-slices'); + _tlExposure = document.getElementById('devices-tl-exposure'); + _tlGalvoAmp = document.getElementById('devices-tl-galvo-amp'); + _tlGalvoCtr = document.getElementById('devices-tl-galvo-ctr'); + _tlPiezoAmp = document.getElementById('devices-tl-piezo-amp'); + _tlPiezoCtr = document.getElementById('devices-tl-piezo-ctr'); + _tlLaser = document.getElementById('devices-tl-laser'); + _tlStart = document.getElementById('devices-tl-start'); + _tlStatus = document.getElementById('devices-tl-status'); + _tlStatusText = document.getElementById('devices-tl-status-text'); + _roomLightToggle = document.getElementById('devices-room-light-toggle'); _roomLightLabel = document.getElementById('devices-room-light-label'); @@ -319,8 +417,11 @@ const DevicesManager = (function () { function handleEmbryosUpdate(payload) { _embryos = (payload && Array.isArray(payload.embryos)) ? payload.embryos : []; - if (!_viewBox) { - computeViewBox(); + // Recompute the frame so newly-arrived embryos are always in view. + // computeViewBox() returns true when the bounds shifted (incl. the + // first-ever compute); a wider frame needs a full redraw so grid/zones/ + // axes track the new bounds, otherwise just repaint the embryo layer. + if (computeViewBox()) { renderMap(); } else { renderEmbryos(); @@ -405,6 +506,18 @@ const DevicesManager = (function () { xMin = Math.min(xMin, _lastXY.X); xMax = Math.max(xMax, _lastXY.X); yMin = Math.min(yMin, _lastXY.Y); yMax = Math.max(yMax, _lastXY.Y); } + // Always frame the marked embryos — they can sit well outside the fence + // box / current stage position (e.g. SAM detections hundreds of µm away), + // and omitting them clips them to the map edge (looks like a wrong + // position even though their coords are correct). + if (_embryos && _embryos.length) { + _embryos.forEach(emb => { + const xy = embryoResolvedXY(emb); + if (!xy) return; + xMin = Math.min(xMin, xy.x); xMax = Math.max(xMax, xy.x); + yMin = Math.min(yMin, xy.y); yMax = Math.max(yMax, xy.y); + }); + } if (!isFinite(xMin) || !isFinite(yMin)) { xMin = -100; xMax = 100; yMin = -100; yMax = 100; } @@ -1273,9 +1386,21 @@ const DevicesManager = (function () { } } + function toggleCameraExpand() { + if (!_camPanel) return; + const expanded = _camPanel.classList.toggle('expanded'); + if (_camExpand) { + _camExpand.classList.toggle('active', expanded); + _camExpand.setAttribute('aria-pressed', expanded ? 'true' : 'false'); + _camExpand.title = expanded ? 'Shrink view' : 'Enlarge view'; + _camExpand.textContent = expanded ? '⤡' : '⤢'; + } + } + function setupCameraWiring() { if (!_camToggle) return; _camToggle.addEventListener('click', toggleCameraStream); + if (_camExpand) _camExpand.addEventListener('click', toggleCameraExpand); applyCameraState(false); if (typeof ClientEventBus !== 'undefined') { ClientEventBus.on('BOTTOM_CAMERA_FRAME', handleCameraFrame); @@ -1292,6 +1417,798 @@ const DevicesManager = (function () { } } + // ===================================================================== + // Lightsheet live panel (Manual view) + // ===================================================================== + + /** Gate ALL lasers off via the Laser "ALL OFF" config-group preset. + * Updates the indicator span from the actual API result (not a static label). + * Fire-and-forget safe — failure shows a warning, never throws. */ + async function setLaserOff() { + cacheDom(); + try { + const res = await fetch('/api/devices/laser/off', { method: 'POST' }); + if (_lsLaserStatus) { + _lsLaserStatus.textContent = res.ok ? 'OFF (brightfield)' : 'warning: state unknown'; + } + if (res.ok) _setLaserToggleState(false); + } catch (err) { + if (_lsLaserStatus) _lsLaserStatus.textContent = 'warning: state unknown'; + console.debug('laser off call failed:', err); + } + } + + /** Fetch laser config-group presets and populate the #devices-laser-preset select. + * Selects "ALL OFF" by default (entry safety preset). + * Wires the change handler to POST the selected preset. + * Fire-and-forget safe — failure leaves the fallback "ALL OFF" option in place. */ + async function populateLaserPresets() { + cacheDom(); + if (!_lsLaserPreset) return; + try { + const res = await fetch('/api/devices/laser/configs'); + if (!res.ok) return; + const data = await res.json(); + // data may be an array of preset names or {configs: [...]} + const presets = Array.isArray(data) ? data : (data.configs || []); + if (!presets.length) return; + // Rebuild option list + _lsLaserPreset.innerHTML = ''; + for (const name of presets) { + const opt = document.createElement('option'); + opt.value = name; + opt.textContent = name; + _lsLaserPreset.appendChild(opt); + } + // Default to "ALL OFF" — entry safety state + if (presets.includes('ALL OFF')) _lsLaserPreset.value = 'ALL OFF'; + // Wire change handler — only POST if laser is currently ON; if OFF, it's + // just a selection that will be activated when the toggle is pressed. + _lsLaserPreset.onchange = () => { if (_lsLaserOn) setLaserPreset(_lsLaserPreset.value); }; + } catch (err) { + console.debug('laser preset fetch failed:', err); + } + } + + /** Fetch SPIM camera roles and show the Side A/B selector if camera_b is present. + * Called on manual-view entry. Hides the selector on single-camera rigs. + * Fire-and-forget safe — failure leaves the selector hidden (safe default). */ + async function populateCameraRoles() { + cacheDom(); + const group = document.getElementById('devices-ls-side-group'); + try { + const res = await fetch('/api/devices/cameras'); + if (!res.ok) return; + const data = await res.json(); + // data may be {cameras: [...]} or a raw array + const cameras = Array.isArray(data) ? data : (data.cameras || []); + const hasSideB = cameras.includes('B'); + if (group) group.style.display = hasSideB ? '' : 'none'; + if (_lsSideSelect && hasSideB) { + _lsSideSelect.onchange = () => { + _lsSide = _lsSideSelect.value; + postLightsheetParams(); + }; + } + } catch (err) { + console.debug('camera roles fetch failed:', err); + } + } + + /** POST a named laser preset to the device layer. + * Updates the status indicator on success. + * Fire-and-forget safe — failure shows a warning, never throws. */ + async function setLaserPreset(config) { + cacheDom(); + if (!config) return; + try { + const res = await fetch('/api/devices/laser/config', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ config }), + }); + if (_lsLaserStatus) { + _lsLaserStatus.textContent = res.ok ? config : 'warning: state unknown'; + } + if (res.ok) _setLaserToggleState(config !== 'ALL OFF'); + if (!res.ok) console.debug('laser preset set failed:', await res.text()); + } catch (err) { + if (_lsLaserStatus) _lsLaserStatus.textContent = 'warning: state unknown'; + console.debug('laser preset set failed:', err); + } + } + + // ===================================================================== + // Timelapse config form (Manual view) + // ===================================================================== + + // ── Accordion section summary builders ─────────────────────────────────── + + function _tlSchedSummary() { + const interval = (_tlInterval && _tlInterval.value) ? _tlInterval.value : '120'; + const stop = (_tlStop && _tlStop.value) ? _tlStop.value : 'manual'; + const condVal = (_tlCondVal && _tlCondVal.value) ? _tlCondVal.value : '10'; + if (stop === 'timepoints') return `${interval} s · ${condVal} frames`; + if (stop === 'duration') return `${interval} s · ${condVal} h`; + return `${interval} s · manual`; + } + + function _tlTargetsSummary() { + const embryos = (_tlEmbryos && _tlEmbryos.value.trim()) + ? _tlEmbryos.value.trim() + : 'all'; + const modeEl = _tlMode; + const modeText = (modeEl && modeEl.value) + ? modeEl.options[modeEl.selectedIndex].text + : 'none'; + return `${embryos} · ${modeText}`; + } + + function _tlGeomSummary() { + const slices = (_tlSlices && _tlSlices.value) ? _tlSlices.value : '50'; + const exposure = (_tlExposure && _tlExposure.value) ? _tlExposure.value : '10'; + const laser = (_tlLaser && _tlLaser.value) ? _tlLaser.value : 'ALL OFF'; + return `${slices} sl · ${exposure} ms · ${laser}`; + } + + /** Update a section's header active state and summary text, then sync the + * outer panel dot and the start button. sec = 'sched'|'targets'|'geom'. */ + function _tlUpdateSection(sec) { + const head = document.getElementById(`devices-tlacc-${sec}-head`); + const summary = document.getElementById(`devices-tlacc-${sec}-sum`); + const touched = _tlTouched[sec]; + + if (head) head.classList.toggle('is-active', touched); + if (summary) { + summary.hidden = !touched; + if (touched) { + if (sec === 'sched') summary.textContent = _tlSchedSummary(); + else if (sec === 'targets') summary.textContent = _tlTargetsSummary(); + else if (sec === 'geom') summary.textContent = _tlGeomSummary(); + } + } + + // Outer panel dot + start button "ready" state + const anyActive = Object.values(_tlTouched).some(Boolean); + const outerDot = document.getElementById('devices-tl-outer-dot'); + if (outerDot) outerDot.classList.toggle('is-active', anyActive); + if (_tlStart) _tlStart.classList.toggle('is-ready', anyActive); + } + + /** Wire the timelapse panel: outer collapsible toggle, accordion section + * toggles, touch listeners, and the submit button. + * Safe to call multiple times (re-assigns handlers idempotently). */ + function initTlForm() { + cacheDom(); + + // Reset touched state on each init (re-entering the manual view = fresh) + _tlTouched = { sched: false, targets: false, geom: false }; + // Clear any leftover active-state visuals from a previous visit + ['sched', 'targets', 'geom'].forEach(sec => _tlUpdateSection(sec)); + + // ── Outer collapsible toggle ────────────────────────────────────────── + if (_tlToggle && _tlBody) { + _tlToggle.onclick = () => { + const open = _tlBody.hidden; + _tlBody.hidden = !open; + _tlToggle.setAttribute('aria-expanded', String(open)); + const arrow = _tlToggle.querySelector('.ls-collapsible-arrow'); + if (arrow) arrow.textContent = open ? '▼' : '▶'; + }; + } + + // ── Accordion section toggles ───────────────────────────────────────── + ['sched', 'targets', 'geom'].forEach(sec => { + const head = document.getElementById(`devices-tlacc-${sec}-head`); + const body = document.getElementById(`devices-tlacc-${sec}-body`); + if (!head || !body) return; + head.onclick = () => { + const open = body.hidden; + body.hidden = !open; + head.setAttribute('aria-expanded', String(open)); + const arrow = head.querySelector('.ls-acc-arrow'); + if (arrow) arrow.textContent = open ? '▼' : '▶'; + }; + }); + + // ── Touch listeners ─────────────────────────────────────────────────── + const markTouched = sec => { + _tlTouched[sec] = true; + _tlUpdateSection(sec); + }; + + // Schedule — interval and stop condition drive summary; cond-row visibility unchanged + [_tlInterval, _tlCondVal].forEach(el => { + if (el) el.addEventListener('input', () => markTouched('sched')); + }); + if (_tlStop) { + _tlStop.addEventListener('change', () => { + const v = _tlStop.value; + const show = v === 'timepoints' || v === 'duration'; + if (_tlCondRow) _tlCondRow.hidden = !show; + if (_tlCondLabel) _tlCondLabel.textContent = v === 'duration' ? 'Hours' : 'Count'; + markTouched('sched'); + }); + } + + // Targets + if (_tlEmbryos) _tlEmbryos.addEventListener('input', () => markTouched('targets')); + if (_tlMode) _tlMode.addEventListener('change', () => markTouched('targets')); + + // Volume geometry + [_tlSlices, _tlExposure, _tlGalvoAmp, _tlGalvoCtr, _tlPiezoAmp, _tlPiezoCtr].forEach(el => { + if (el) el.addEventListener('input', () => markTouched('geom')); + }); + if (_tlLaser) _tlLaser.addEventListener('change', () => markTouched('geom')); + + // ── Submit ──────────────────────────────────────────────────────────── + if (_tlStart) _tlStart.onclick = startTimelapse; + } + + /** Populate timelapse volume-geometry defaults from GET /api/devices/scan_geometry, + * and populate the laser preset select from GET /api/devices/laser/configs. + * Fire-and-forget safe — failure leaves form-coded defaults in place. */ + async function populateTlDefaults() { + cacheDom(); + // Geometry defaults + try { + const res = await fetch('/api/devices/scan_geometry'); + if (res.ok) { + const data = await res.json(); + const scan = data.scan || {}; + if (_tlSlices && scan.num_slices != null) _tlSlices.value = scan.num_slices; + if (_tlExposure && scan.exposure_ms != null) _tlExposure.value = scan.exposure_ms; + if (_tlGalvoAmp && scan.galvo_amplitude_deg != null) _tlGalvoAmp.value = scan.galvo_amplitude_deg; + if (_tlGalvoCtr && scan.galvo_center_deg != null) _tlGalvoCtr.value = scan.galvo_center_deg; + if (_tlPiezoAmp && scan.piezo_amplitude_um != null) _tlPiezoAmp.value = scan.piezo_amplitude_um; + if (_tlPiezoCtr && scan.piezo_center_um != null) _tlPiezoCtr.value = scan.piezo_center_um; + } + } catch (err) { + console.debug('tl scan_geometry fetch failed:', err); + } + // Laser presets — reuse the shared endpoint; mirror populateLaserPresets() + if (!_tlLaser) return; + try { + const res = await fetch('/api/devices/laser/configs'); + if (!res.ok) return; + const data = await res.json(); + const presets = Array.isArray(data) ? data : (data.configs || []); + if (!presets.length) return; + _tlLaser.innerHTML = ''; + for (const name of presets) { + const opt = document.createElement('option'); + opt.value = name; + opt.textContent = name; + _tlLaser.appendChild(opt); + } + if (presets.includes('ALL OFF')) _tlLaser.value = 'ALL OFF'; + } catch (err) { + console.debug('tl laser configs fetch failed:', err); + } + } + + /** Gather form values, POST to /api/devices/timelapse/start, show result. */ + async function startTimelapse() { + cacheDom(); + if (!_tlStart) return; + _tlStart.disabled = true; + + // Build payload + const interval = parseFloat(_tlInterval ? _tlInterval.value : '120') || 120; + const stop_condition = _tlStop ? _tlStop.value : 'manual'; + const condRaw = _tlCondVal ? _tlCondVal.value : ''; + const condition_value = condRaw ? parseInt(condRaw, 10) : null; + const embryoRaw = _tlEmbryos ? _tlEmbryos.value.trim() : ''; + const embryo_ids = embryoRaw + ? embryoRaw.split(',').map(s => s.trim()).filter(Boolean) + : null; + const monitoring_mode = _tlMode ? (_tlMode.value || null) : null; + + const payload = { + interval_seconds: interval, + stop_condition, + embryo_ids, + condition_value, + monitoring_mode, + num_slices: _tlSlices ? parseInt(_tlSlices.value, 10) : 50, + exposure_ms: _tlExposure ? parseFloat(_tlExposure.value) : 10.0, + galvo_amplitude: _tlGalvoAmp ? parseFloat(_tlGalvoAmp.value) : 0.5, + galvo_center: _tlGalvoCtr ? parseFloat(_tlGalvoCtr.value) : 0.0, + piezo_amplitude: _tlPiezoAmp ? parseFloat(_tlPiezoAmp.value) : 25.0, + piezo_center: _tlPiezoCtr ? parseFloat(_tlPiezoCtr.value) : 50.0, + laser_config: _tlLaser ? (_tlLaser.value || null) : null, + }; + + if (_tlStatus) _tlStatus.hidden = false; + if (_tlStatusText) _tlStatusText.textContent = 'Starting…'; + + try { + const res = await fetch('/api/devices/timelapse/start', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + const body = await res.json().catch(() => ({})); + if (res.ok) { + const msg = body.result || 'Timelapse started.'; + if (_tlStatusText) _tlStatusText.textContent = msg; + } else { + const detail = body.detail || `error ${res.status}`; + if (_tlStatusText) _tlStatusText.textContent = `Error: ${detail}`; + console.debug('timelapse start failed:', body); + } + } catch (err) { + if (_tlStatusText) _tlStatusText.textContent = `Network error: ${err.message}`; + console.debug('timelapse start failed:', err); + } finally { + if (_tlStart) _tlStart.disabled = false; + } + } + + async function toggleLightsheetStream() { + if (!_lsToggle) return; + _lsToggle.disabled = true; + try { + const starting = !_lsStreaming; + const endpoint = _lsStreaming + ? '/api/devices/lightsheet/live/stop' + : '/api/devices/lightsheet/live/start'; + const res = await fetch(endpoint, { method: 'POST' }); + if (!res.ok) { + const detail = await res.text(); + console.error('Lightsheet toggle failed:', detail); + if (_lsMeta) _lsMeta.textContent = `error: ${res.status}`; + return; + } + const data = await res.json(); + applyLightsheetState(!!data.streaming); + // Gate lasers off whenever live starts — brightfield-safe by default. + if (starting && data.streaming) setLaserOff(); + } catch (err) { + console.error('Lightsheet toggle failed:', err); + if (_lsMeta) _lsMeta.textContent = `error: ${err}`; + } finally { + _lsToggle.disabled = false; + } + } + + function applyLightsheetState(streaming) { + _lsStreaming = streaming; + if (_lsToggle) { + // Constant "Live" label; the .active class + status dot show whether + // the stream is currently running (muted = off, green glow = live). + _lsToggle.textContent = 'Live'; + _lsToggle.classList.toggle('active', streaming); + } + if (_lsLed) { + _lsLed.classList.toggle('live', streaming); + _lsLed.classList.remove('stale'); + } + if (!streaming) { + _lsHasFrame = false; + _lsFrameTimes = []; + _lsPendingPayload = null; + _lsRenderScheduled = false; + if (_lsImg) _lsImg.classList.remove('has-frame'); + if (_lsPlaceholder) _lsPlaceholder.hidden = false; + if (_lsMeta) _lsMeta.textContent = 'stream off'; + if (_lsStaleTimer) { clearTimeout(_lsStaleTimer); _lsStaleTimer = null; } + resetLightsheetZoom(); + } else { + _lsFrameTimes = []; + if (_lsMeta) _lsMeta.textContent = 'waiting…'; + } + } + + function handleLightsheetFrame(payload) { + if (!payload || !payload.jpeg_b64 || !_lsImg) return; + + // Lightweight bookkeeping runs per arriving frame so the FPS / live + // indicator reflects the true incoming rate. + const now = performance.now(); + _lsLastFrameTs = Date.now(); + _lsFrameTimes.push(now); + if (_lsFrameTimes.length > _LS_FPS_WINDOW) _lsFrameTimes.shift(); + if (_lsLed) { + _lsLed.classList.add('live'); + _lsLed.classList.remove('stale'); + } + if (_lsMeta) { + const shape = payload.shape || []; + const dims = shape.length === 2 ? `${shape[1]}×${shape[0]}` : ''; + const fps = computeLightsheetFps(); + _lsMeta.textContent = dims + ? `${dims} · ${fps != null ? fps.toFixed(1) + ' fps' : '…'}` + : (fps != null ? `${fps.toFixed(1)} fps` : 'live'); + } + scheduleLightsheetStaleCheck(); + + // Expensive path (decode + GPU texture upload) is throttled: keep only + // the newest frame and coalesce paints to one per animation frame. + _lsPendingPayload = payload; + if (!_lsRenderScheduled) { + _lsRenderScheduled = true; + requestAnimationFrame(renderLightsheetFrame); + } + } + + function renderLightsheetFrame() { + _lsRenderScheduled = false; + // One decode in flight at a time; a frame mid-decode means we skip this + // paint and let the decode's completion reschedule if newer data exists. + if (_lsDecoding) return; + const payload = _lsPendingPayload; + _lsPendingPayload = null; + if (!payload || !_lsImg) return; + + _lsDecoding = true; + _lsImg.src = `data:${payload.mime || 'image/jpeg'};base64,${payload.jpeg_b64}`; + + const done = () => { + _lsDecoding = false; + if (!_lsHasFrame) { + _lsHasFrame = true; + _lsImg.classList.add('has-frame'); + if (_lsPlaceholder) _lsPlaceholder.hidden = true; + } + // A newer frame may have landed during decode — paint it next frame. + if (_lsPendingPayload && !_lsRenderScheduled) { + _lsRenderScheduled = true; + requestAnimationFrame(renderLightsheetFrame); + } + }; + + // img.decode() resolves once the bitmap is ready (off the main thread), + // giving real backpressure. Fall back to a direct apply if unsupported. + if (typeof _lsImg.decode === 'function') { + _lsImg.decode().then(done).catch(done); + } else { + done(); + } + } + + function computeLightsheetFps() { + const n = _lsFrameTimes.length; + if (n < 2) return null; + const span = _lsFrameTimes[n - 1] - _lsFrameTimes[0]; + if (span <= 0) return null; + return ((n - 1) * 1000) / span; + } + + function scheduleLightsheetStaleCheck() { + if (_lsStaleTimer) clearTimeout(_lsStaleTimer); + _lsStaleTimer = setTimeout(() => { + const age = (Date.now() - _lsLastFrameTs) / 1000; + if (_lsMeta) _lsMeta.textContent = `last frame ${age.toFixed(1)}s ago`; + if (_lsLed) _lsLed.classList.add('stale'); + }, 1500); + } + + async function syncInitialLightsheetState() { + try { + const res = await fetch('/api/devices/lightsheet/live/status'); + if (!res.ok) return; + const data = await res.json(); + applyLightsheetState(!!data.streaming); + } catch (err) { + console.debug('lightsheet status check failed:', err); + } + } + + // ---- Lightsheet zoom / pan (mirrors camera zoom/pan) ---------------- + function applyLightsheetTransform() { + if (!_lsImg) return; + _lsImg.style.transform = + `translate(${_lsTx}px, ${_lsTy}px) scale(${_lsZoom})`; + } + + function resetLightsheetZoom() { + _lsZoom = 1; + _lsTx = 0; + _lsTy = 0; + applyLightsheetTransform(); + if (_lsStage) _lsStage.classList.remove('camera-zoomed', 'camera-panning'); + } + + function clampLightsheetPan() { + if (!_lsStage) return; + const rect = _lsStage.getBoundingClientRect(); + const maxX = (rect.width * (_lsZoom - 1)) / 2; + const maxY = (rect.height * (_lsZoom - 1)) / 2; + _lsTx = Math.max(-maxX, Math.min(maxX, _lsTx)); + _lsTy = Math.max(-maxY, Math.min(maxY, _lsTy)); + } + + function onLightsheetWheel(event) { + if (!_lsStage) return; + event.preventDefault(); + const rect = _lsStage.getBoundingClientRect(); + const cx = event.clientX - rect.left - rect.width / 2; + const cy = event.clientY - rect.top - rect.height / 2; + const oldZoom = _lsZoom; + const factor = event.deltaY < 0 ? _CAM_ZOOM_STEP : 1 / _CAM_ZOOM_STEP; + const newZoom = Math.max(_CAM_ZOOM_MIN, Math.min(_CAM_ZOOM_MAX, oldZoom * factor)); + if (newZoom === oldZoom) return; + const ratio = newZoom / oldZoom; + _lsTx = cx - (cx - _lsTx) * ratio; + _lsTy = cy - (cy - _lsTy) * ratio; + _lsZoom = newZoom; + if (Math.abs(_lsZoom - 1) < 0.001) { resetLightsheetZoom(); return; } + clampLightsheetPan(); + applyLightsheetTransform(); + _lsStage.classList.add('camera-zoomed'); + } + + function onLightsheetPointerDown(event) { + if (event.button !== 0) return; + if (_lsZoom <= 1) return; + _lsPanLast = { x: event.clientX, y: event.clientY }; + try { _lsStage.setPointerCapture(event.pointerId); } catch (_) {} + _lsStage.classList.add('camera-panning'); + event.preventDefault(); + } + + function onLightsheetPointerMove(event) { + if (!_lsPanLast) return; + _lsTx += event.clientX - _lsPanLast.x; + _lsTy += event.clientY - _lsPanLast.y; + _lsPanLast = { x: event.clientX, y: event.clientY }; + clampLightsheetPan(); + applyLightsheetTransform(); + } + + function onLightsheetPointerEnd(event) { + if (!_lsPanLast) return; + _lsPanLast = null; + try { _lsStage.releasePointerCapture(event.pointerId); } catch (_) {} + if (_lsStage) _lsStage.classList.remove('camera-panning'); + } + + function onLightsheetDoubleClick(event) { + if (_lsZoom !== 1 || _lsTx !== 0 || _lsTy !== 0) { + event.preventDefault(); + resetLightsheetZoom(); + } + } + + // ---- Lightsheet live params (debounced) ----------------------------- + function postLightsheetParams() { + fetch('/api/devices/lightsheet/live/params', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ galvo: _lsGalvo, piezo: _lsPiezo, exposure: _lsExposure, side: _lsSide }), + }).catch(err => console.debug('lightsheet params post failed:', err)); + } + + function scheduleLightsheetParamPost() { + if (_lsParamTimer) clearTimeout(_lsParamTimer); + _lsParamTimer = setTimeout(postLightsheetParams, 120); + } + + function onGalvoInput(src) { + const v = parseFloat(src.value); + if (isNaN(v)) return; + _lsGalvo = v; + // Sync the sibling control + if (src === _lsGalvoSlider && _lsGalvoNum) _lsGalvoNum.value = v; + if (src === _lsGalvoNum && _lsGalvoSlider) _lsGalvoSlider.value = v; + scheduleLightsheetParamPost(); + } + + function onPiezoInput(src) { + const v = parseFloat(src.value); + if (isNaN(v)) return; + _lsPiezo = v; + if (src === _lsPiezoSlider && _lsPiezoNum) _lsPiezoNum.value = v; + if (src === _lsPiezoNum && _lsPiezoSlider) _lsPiezoSlider.value = v; + scheduleLightsheetParamPost(); + } + + function onExposureInput() { + const v = parseFloat(_lsExposureNum && _lsExposureNum.value); + if (isNaN(v) || v < 1) return; + _lsExposure = v; + scheduleLightsheetParamPost(); + } + + // ---- Illumination toggles ------------------------------------------- + async function postLedPreset(preset) { + try { + await fetch('/api/devices/led/set', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ state: preset }), + }); + } catch (err) { console.debug('LED preset failed:', err); } + } + + /** Single LED toggle — mirrors Cam LED / Room Light aria-pressed pattern. + * Flips between Open (active) and Closed (inactive/safe default). */ + async function toggleLedPreset() { + _lsLedIsOpen = !_lsLedIsOpen; + if (_lsLedToggle) { + _lsLedToggle.classList.toggle('ls-illum-btn--active', _lsLedIsOpen); + _lsLedToggle.setAttribute('aria-pressed', _lsLedIsOpen ? 'true' : 'false'); + _lsLedToggle.textContent = _lsLedIsOpen ? 'LED: Open' : 'LED: Closed'; + } + await postLedPreset(_lsLedIsOpen ? 'Open' : 'Closed'); + } + + /** Update laser toggle button + dot to reflect on/off state. + * Called by setLaserOff() and setLaserPreset() after a successful API call. */ + function _setLaserToggleState(on) { + _lsLaserOn = on; + if (_lsLaserToggle) { + _lsLaserToggle.classList.toggle('ls-illum-btn--active', on); + _lsLaserToggle.setAttribute('aria-pressed', on ? 'true' : 'false'); + _lsLaserToggle.textContent = on ? 'Laser: ON' : 'Laser: OFF'; + } + const dot = document.querySelector('.ls-laser-dot'); + if (dot) dot.classList.toggle('ls-laser-dot--on', on); + } + + /** Laser on/off toggle — OFF fires laser/off; ON applies the selected preset. + * If selected preset is "ALL OFF", picks the first non-"ALL OFF" option. + * Entry safety: starts OFF (setLaserOff fires on manual-view entry). */ + async function toggleLaser() { + if (_lsLaserOn) { + await setLaserOff(); + } else { + let config = _lsLaserPreset ? _lsLaserPreset.value : null; + if (!config || config === 'ALL OFF') { + const opts = _lsLaserPreset ? Array.from(_lsLaserPreset.options) : []; + const first = opts.find(o => o.value !== 'ALL OFF'); + if (first) { + config = first.value; + _lsLaserPreset.value = config; + } else { + if (_lsLaserStatus) _lsLaserStatus.textContent = 'select a laser line first'; + return; + } + } + await setLaserPreset(config); + } + } + + async function toggleManualRoomLight() { + const nextState = _roomLightState === 'on' ? 'off' : 'on'; + if (_lsRoomLightBtn) { + _lsRoomLightBtn.classList.toggle('ls-illum-btn--active', nextState === 'on'); + _lsRoomLightBtn.setAttribute('aria-pressed', nextState === 'on' ? 'true' : 'false'); + } + try { + const res = await fetch('/api/devices/room_light/set', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ state: nextState }), + }); + if (res.ok) { + const data = await res.json(); + _roomLightState = data.state || nextState; + if (_lsRoomLightBtn) { + const on = _roomLightState === 'on'; + _lsRoomLightBtn.classList.toggle('ls-illum-btn--active', on); + _lsRoomLightBtn.setAttribute('aria-pressed', on ? 'true' : 'false'); + } + } + } catch (err) { console.debug('manual room light toggle failed:', err); } + } + + // ---- Acquire -------------------------------------------------------- + async function runLightsheetAcquire(mode) { + const btn = mode === 'burst' ? _lsBurstBtn : _lsSnapVolBtn; + if (btn) { btn.disabled = true; btn.textContent = 'acquiring…'; } + try { + let res; + if (mode === 'burst') { + res = await fetch('/api/devices/acquire/burst', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ frames: 10, mode: 'brightfield', + num_slices: 50, exposure_ms: _lsExposure, + laser_config: 'ALL OFF', + piezo_center: _lsPiezo, + galvo_center: _lsGalvo }), + }); + } else { + res = await fetch('/api/devices/acquire/volume', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ num_slices: 50, exposure_ms: _lsExposure, + laser_config: 'ALL OFF', + piezo_center: _lsPiezo, + galvo_center: _lsGalvo }), + }); + } + if (!res.ok) { + console.error('acquire failed:', res.status, await res.text()); + return; + } + const data = await res.json(); + if (_lsLastcap) _lsLastcap.hidden = false; + if (_lsLastcapRef) { + _lsLastcapRef.textContent = data.volume_path || data.path || data.id || 'done'; + } + // Show confirmation toast — no inline preview to keep manual mode uncluttered + if (typeof showGentlyToast === 'function') { + const label = mode === 'burst' ? 'Burst acquired' : 'Volume acquired'; + showGentlyToast(label, 'View in Gallery', () => { + if (typeof switchTab === 'function' && typeof TABS !== 'undefined') { + switchTab(TABS.GALLERY); + } + }); + } + } catch (err) { + console.error('acquire error:', err); + } finally { + if (btn) { + btn.disabled = false; + btn.textContent = mode === 'burst' ? 'Burst' : 'Snap Volume'; + } + } + } + + // ---- Temperature set (rail copy, delegates to same API) ------------- + async function setLightsheetTemperature() { + if (!_lsTempInput) return; + const target = parseFloat(_lsTempInput.value); + if (isNaN(target) || target < 0 || target > 99.9) return; + try { + await fetch('/api/devices/temperature/set', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ target_c: target }), + }); + } catch (err) { console.debug('ls temp set failed:', err); } + } + + function setupManualWiring() { + if (!_lsToggle) return; + _lsToggle.addEventListener('click', toggleLightsheetStream); + applyLightsheetState(false); + + // Param controls — slider ↔ number sync + debounced POST + if (_lsGalvoSlider) _lsGalvoSlider.addEventListener('input', () => onGalvoInput(_lsGalvoSlider)); + if (_lsGalvoNum) _lsGalvoNum.addEventListener('input', () => onGalvoInput(_lsGalvoNum)); + if (_lsPiezoSlider) _lsPiezoSlider.addEventListener('input', () => onPiezoInput(_lsPiezoSlider)); + if (_lsPiezoNum) _lsPiezoNum.addEventListener('input', () => onPiezoInput(_lsPiezoNum)); + if (_lsExposureNum) _lsExposureNum.addEventListener('input', onExposureInput); + + // Illumination + if (_lsLedToggle) _lsLedToggle.addEventListener('click', toggleLedPreset); + if (_lsRoomLightBtn) _lsRoomLightBtn.addEventListener('click', toggleManualRoomLight); + if (_lsLaserToggle) _lsLaserToggle.addEventListener('click', toggleLaser); + + // Acquire + if (_lsSnapVolBtn) _lsSnapVolBtn.addEventListener('click', () => runLightsheetAcquire('volume')); + if (_lsBurstBtn) _lsBurstBtn.addEventListener('click', () => runLightsheetAcquire('burst')); + + // Temperature + if (_lsTempSet) _lsTempSet.addEventListener('click', setLightsheetTemperature); + if (_lsTempInput) { + _lsTempInput.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { e.preventDefault(); setLightsheetTemperature(); } + }); + } + + // Zoom / pan + if (_lsStage) { + _lsStage.addEventListener('wheel', onLightsheetWheel, { passive: false }); + _lsStage.addEventListener('pointerdown', onLightsheetPointerDown); + _lsStage.addEventListener('pointermove', onLightsheetPointerMove); + _lsStage.addEventListener('pointerup', onLightsheetPointerEnd); + _lsStage.addEventListener('pointercancel', onLightsheetPointerEnd); + _lsStage.addEventListener('dblclick', onLightsheetDoubleClick); + } + + // Subscribe to LIGHTSHEET_FRAME events + if (typeof ClientEventBus !== 'undefined') { + ClientEventBus.on('LIGHTSHEET_FRAME', handleLightsheetFrame); + } + + syncInitialLightsheetState(); + } + // ===================================================================== // Room-light toggle // ===================================================================== @@ -1500,6 +2417,35 @@ const DevicesManager = (function () { if (typeof updateViewButtons === 'function') { updateViewButtons('devices-view-switcher', viewName); } + // The 3D optical-space view owns its own WebGL module. Build it lazily + // on first activation (the panel was display:none, so its container had + // no size until now); init() is idempotent and resizes on re-entry. + if (viewName === 'optical3d' && typeof Occupancy3DManager !== 'undefined') { + Occupancy3DManager.init(); + } + // Initialize temperature graph for the active view. The TemperatureGraph + // is a singleton; reinit on view switch ensures only one graph target + // is live at a time. ClientEventBus.off/on in TemperatureGraph.init + // makes re-init safe (idempotent). + if (window.TemperatureGraph) { + if (viewName === 'manual') { + const el = document.getElementById('devices-ls-tempgraph'); + if (el) TemperatureGraph.init(el, 'current'); + } else { + const el = document.getElementById('devices-temp-graph'); + if (el) TemperatureGraph.init(el, 'current'); + } + } + // Entering Manual view — gate lasers off immediately (brightfield-safe). + // populateLaserPresets() runs after setLaserOff() so the select is always + // seeded with the entry-safety state first. + if (viewName === 'manual') { setLaserOff(); populateLaserPresets(); populateCameraRoles(); initTlForm(); populateTlDefaults(); } + // The Operate view owns its own module; activate/deactivate it so its + // camera + SPIM streams only run while it's the visible view. + if (typeof OperateManager !== 'undefined') { + if (viewName === 'operate') OperateManager.activate(); + else OperateManager.deactivate(); + } } function setupViewSwitcher() { @@ -1509,8 +2455,10 @@ const DevicesManager = (function () { document.addEventListener('keydown', (e) => { if (typeof state !== 'undefined' && typeof TABS !== 'undefined' && state.tab !== TABS.DEVICES) return; if (e.target.matches('input, textarea, select, [contenteditable]')) return; - if (e.key === 'm') { e.preventDefault(); switchView('map'); } + if (e.key === 'o') { e.preventDefault(); switchView('operate'); } + else if (e.key === 'm') { e.preventDefault(); switchView('map'); } else if (e.key === 'd') { e.preventDefault(); switchView('details'); } + else if (e.key === 'v') { e.preventDefault(); switchView('optical3d'); } }); } @@ -1545,6 +2493,7 @@ const DevicesManager = (function () { cacheDom(); setupViewSwitcher(); setupCameraWiring(); + setupManualWiring(); setupRoomLight(); setupTemperature(); loadCoverslip(); @@ -1568,13 +2517,14 @@ const DevicesManager = (function () { document.addEventListener('keydown', onMapKeyDown); setStatus('stale', 'waiting', 'no payload yet'); syncInitialCameraState(); - // Stop the camera stream if the tab is closed while it's running, - // so MMCore isn't held by a disconnected browser. + // Stop the camera and lightsheet streams if the tab is closed while + // running, so MMCore isn't held by a disconnected browser. window.addEventListener('beforeunload', () => { if (_camStreaming) { - try { - navigator.sendBeacon('/api/devices/bottom_camera/stream/stop'); - } catch (_) {} + try { navigator.sendBeacon('/api/devices/bottom_camera/stream/stop'); } catch (_) {} + } + if (_lsStreaming) { + try { navigator.sendBeacon('/api/devices/lightsheet/live/stop'); } catch (_) {} } }); } diff --git a/gently/ui/web/static/js/experiment-overview.js b/gently/ui/web/static/js/experiment-overview.js index 33032a40..a5be6444 100644 --- a/gently/ui/web/static/js/experiment-overview.js +++ b/gently/ui/web/static/js/experiment-overview.js @@ -1,161 +1,91 @@ /** - * Experiment Overview Tab — vector-graphics view of the planned timelapse. + * Experiment Overview Tab — vector-graphics view of the live imaging tactics + * (cadence patterns + reactive-monitoring rules) for the running experiment. * - * Data source priority: - * 1. GET /api/experiments/current/strategy — live snapshot from FileStore. - * 2. STUB_STRATEGY below — used when the live fetch - * fails or no session exists. - * - * The render path is data-shape-driven and doesn't care which source the - * snapshot came from — only ``ExperimentOverview.isLive`` differs so the - * header badge can say "live" or "mockup · stubbed data". + * Data source: GET /api/experiments/current/strategy — the live snapshot from + * FileStore. When there is no active experiment (or the fetch isn't ready), the + * view shows a calm empty state; it never renders stubbed/mock data. */ -const STUB_STRATEGY = { - session_id: "20260522_1430_dopaminergic_demo_a3f8e1c2", - session_name: "dopaminergic-reporter demo", - started_at: "2026-05-22T14:30:00", - now_offset_s: 8100, // 2h 15min into the run - horizon_s: 14400, // 4h total view window (past + projected) - base_interval_s: 120, - dose_budget_base_ms: 50000, - per_timepoint_ms: 500, // 50 slices × 10ms - monitoring_modes: [ - { - name: "expression_monitoring", - description: "Anticipating fluorescent-reporter onset on Test embryos: accelerate to 60s on signal, ramp 488 down on saturation.", - applies_to_roles: ["test"], - params: { - fast_interval: 60, - rampdown_step_pct: 1.0, - rampdown_floor_pct: 2.0, - rampdown_ceiling_pct: 6.0 - } - }, - { - name: "pre_terminal_monitoring", - description: "Anticipating organism pre-terminal stage (pretzel): accelerate to 30s on detection.", - applies_to_roles: ["test"], - params: { fast_interval: 30 } - } - ], - triggers: [ - { id: "t1", kind: "interval_rule", label: "signal onset", - when_text: "dopaminergic ≥ WEAK", then_text: "120s → 60s", - applies_to: ["test"], one_time: true }, - { id: "t2", kind: "power_rule", label: "488 ramp down", - when_text: "intensity = SATURATING (×3)", then_text: "488 ↓ 1%/step, floor 2%", - applies_to: ["test"] }, - { id: "t3", kind: "burst", label: "structure-triggered burst", - when_text: "structure_quality = GOOD", then_text: "burst 200 frames @ 20 Hz", - applies_to: ["test"] }, - { id: "t4", kind: "interval_rule", label: "pre-terminal speedup", - when_text: "stage = pretzel", then_text: "60s → 30s", - applies_to: ["test"], one_time: true } - ], - embryos: [ - { - id: "E1", role: "test", color: "#ff66cc", icon: "★", - dose_used_ms: 12500, dose_budget_ms: 50000, - tp_acquired: 25, - stop_condition: "hatching+3 OR 24h duration", - stop_kind: "bounded", - laser_488_pct_now: 3.0, - phases: [ - { mode: "base", start: 0, end: 1800, cadence_s: 120 }, - { mode: "fast", start: 1800, end: 3600, cadence_s: 60 }, - { mode: "burst", start: 3600, end: 3610, frames: 200, hz: 20 }, - { mode: "cooldown", start: 3610, end: 3640, cadence_s: 60 }, - { mode: "fast", start: 3640, end: 8100, cadence_s: 60 } - ], - trigger_events: [ - { trigger_id: "t1", at: 1800 }, - { trigger_id: "t3", at: 3600 }, - { trigger_id: "t2", at: 5400, count: 3 } - ], - power_history_488: [ - { at: 0, pct: 5.0 }, - { at: 5400, pct: 4.0 }, - { at: 5460, pct: 3.0 }, - { at: 8100, pct: 3.0 } - ], - // Future projection at current cadence (60s, fast). Hatching not - // deterministic so projected_end_s is null — render fades to ∞. - projected_cadence_s: 60, - projected_end_s: null - }, - { - id: "E2", role: "test", color: "#ff66cc", icon: "★", - dose_used_ms: 6500, dose_budget_ms: 50000, - tp_acquired: 13, - stop_condition: "hatching+3 OR 24h duration", - stop_kind: "bounded", - laser_488_pct_now: 5.0, - phases: [ - { mode: "base", start: 0, end: 8100, cadence_s: 120 } - ], - trigger_events: [], - power_history_488: [ - { at: 0, pct: 5.0 }, - { at: 8100, pct: 5.0 } - ], - projected_cadence_s: 120, - projected_end_s: null - }, - { - id: "E3", role: "test", color: "#ff66cc", icon: "★", - dose_used_ms: 38000, dose_budget_ms: 50000, - tp_acquired: 76, - stop_condition: "manual", - stop_kind: "open_ended", - laser_488_pct_now: 5.0, - phases: [ - { mode: "base", start: 0, end: 8100, cadence_s: 120 } - ], - trigger_events: [], - power_history_488: [ - { at: 0, pct: 5.0 }, - { at: 8100, pct: 5.0 } - ], - // Projected dose-exhaust horizon = 4.0h from now (warning condition) - projected_cadence_s: 120, - projected_end_s: null, - dose_exhaust_at_s: 12000 // budget will run out at this elapsed time - }, - { - id: "C1", role: "calibration", color: "#22d3ee", icon: "◆", - dose_used_ms: 33500, dose_budget_ms: 500000, // 10× multiplier - tp_acquired: 67, - stop_condition: "manual", - stop_kind: "open_ended", - laser_488_pct_now: 5.0, - phases: [ - { mode: "base", start: 0, end: 8100, cadence_s: 120 } - ], - trigger_events: [], - power_history_488: [ - { at: 0, pct: 5.0 }, - { at: 8100, pct: 5.0 } - ], - projected_cadence_s: 120, - projected_end_s: null - } - ] -}; const ExperimentOverview = { initialized: false, expandedMode: null, activeView: 'overview', // 'overview' | 'rules' - activeStrategy: null, // last fetched/loaded snapshot - isLive: false, // true when activeStrategy came from the API + activeStrategy: null, // last fetched strategy snapshot (rules view) + activePlan: null, // last fetched/loaded operation plan (overview spine) + isLive: false, // true when data came from the API + scenarioMode: false, // true when ?scenario= is active + _subscribed: false, // guard: prevents double-registration across tab re-clicks + _planRefreshTimer: null, // debounce handle for tactic-event-driven refetch + _tempUpdateHandler: null,// stored handler ref so it can be off()'d if needed + _rosterEmbryos: [], // embryos from /api/embryos/positions (D2 roster lens) + _rolesMap: null, // Map(role name → registry obj) from /api/roles (D2 roster lens) + _currentSessionId: null, // session_id from /api/operation_plan/current (always, even idle) + _planPickerOpen: false, // whether the plan-link picker is visible + _pickerItems: null, // flat plan items for the picker (null=not loaded) + _expandedTacticIds: new Set(), // tactic expand-keys for click-to-expand (survives refresh) async init() { console.log('[ExperimentOverview] init() called, view=', this.activeView); - const strategy = await this.loadStrategy(); + + // Scenario dev mode: ?scenario= renders a fixture with no fetch. + // Guard against double-registration when the tab is clicked repeatedly. + const scenarioParam = new URLSearchParams(location.search).get('scenario'); + if (scenarioParam && window.OPERATIONS_SCENARIOS && + Object.prototype.hasOwnProperty.call(window.OPERATIONS_SCENARIOS, scenarioParam)) { + this.scenarioMode = true; + this.activePlan = window.OPERATIONS_SCENARIOS[scenarioParam]; + this.activeStrategy = null; + this.isLive = false; + this.render(null); + this.initialized = true; + return; + } + + this.scenarioMode = false; + // Fetch plan (overview) and strategy (rules) in parallel so tab-switching + // between the two views doesn't require a second round-trip. + // D2: also fetch roster + roles for the roster lens. + const [plan, strategy, rosterEmbryos, rolesMap] = await Promise.all([ + this.loadPlan(), + this.loadStrategy(), + this._loadRoster(), + this._loadRolesMap(), + ]); + this.activePlan = plan; this.activeStrategy = strategy; + this._rosterEmbryos = rosterEmbryos; + this._rolesMap = rolesMap; + this.isLive = plan !== null || strategy !== null; this.render(strategy); this.initialized = true; + + // Subscribe to tactic-state events once per page load. + // Guard: _subscribed prevents double-registration across tab re-clicks. + // Skip entirely in scenario mode — no live backend, no websocket. + if (!this._subscribed) { + this._subscribed = true; + const refresh = () => this._debouncedRefresh(); + // Plan-changing events: re-fetch the whole plan after debounce. + // CONTEXT_UPDATED fires when OperationPlanUpdater patches the plan. + // The tactic-lifecycle events fire on transitions the updater also + // reacts to, so they all funnel into the same debounced refetch. + const TACTIC_EVENTS = [ + 'CONTEXT_UPDATED', + 'TEMP_PROTOCOL_STARTED', 'TEMP_PROTOCOL_COMPLETED', + 'BURST_START', 'BURST_COMPLETE', + 'EMBRYO_CADENCE_CHANGED', 'TEMPERATURE_SETPOINT_CHANGED', + 'POWER_RAMP_STEP', + ]; + TACTIC_EVENTS.forEach(ev => ClientEventBus.on(ev, refresh)); + + // High-frequency temperature binding (~1 Hz). + // Updates the active scripted_protocol tactic's temperature gauge + // IN PLACE — no plan refetch, no full re-render. + this._tempUpdateHandler = (data) => this._handleTempUpdate(data); + ClientEventBus.on('TEMPERATURE_UPDATE', this._tempUpdateHandler); + } }, async loadStrategy() { @@ -164,26 +94,98 @@ const ExperimentOverview = { cache: 'no-store' }); if (!resp.ok) { - console.warn( - '[ExperimentOverview] strategy fetch returned', - resp.status, '- falling back to stub' - ); - this.isLive = false; - return STUB_STRATEGY; + // No active experiment / not ready yet — show the empty state, + // never stubbed data. + console.warn('[ExperimentOverview] strategy fetch returned', resp.status); + return null; } const data = await resp.json(); - this.isLive = true; return data; } catch (e) { - console.warn( - '[ExperimentOverview] strategy fetch error - falling back to stub:', - e - ); - this.isLive = false; - return STUB_STRATEGY; + console.warn('[ExperimentOverview] strategy fetch error:', e); + return null; } }, + // Fetch the agent-authored Operation Plan for the current session. + // Returns the plan object (plan.tactics etc.) or null when unavailable. + // Always captures data.session_id in _currentSessionId so the Linked-plans + // panel can work even when no operation plan is active. + async loadPlan() { + try { + const resp = await fetch('/api/operation_plan/current', { cache: 'no-store' }); + if (!resp.ok) { + console.warn('[ExperimentOverview] plan fetch returned', resp.status); + return null; + } + const data = await resp.json(); + // Capture session_id regardless of plan availability — used by the + // Linked-plans panel which is session-scoped, not plan-scoped. + this._currentSessionId = data.session_id || null; + if (!data.available) return null; + return data.plan || null; + } catch (e) { + console.warn('[ExperimentOverview] plan fetch error:', e); + return null; + } + }, + + // Debounced plan refetch — coalesces rapid tactic-event bursts into a single + // fetch+render. 500 ms window matches experiment-strip.js convention. + // D2: also re-fetches the embryo roster so the lens stays current. + _debouncedRefresh() { + if (this._planRefreshTimer) clearTimeout(this._planRefreshTimer); + this._planRefreshTimer = setTimeout(async () => { + this._planRefreshTimer = null; + const [plan, rosterEmbryos] = await Promise.all([ + this.loadPlan(), + this._loadRoster(), + ]); + this.activePlan = plan; + this._rosterEmbryos = rosterEmbryos; + this.isLive = plan !== null; + this.render(this.activeStrategy); + }, 500); + }, + + // In-place temperature gauge update — called at ~1 Hz by TEMPERATURE_UPDATE. + // Finds the active scripted_protocol tactic's temperature readout in the DOM + // and rewrites only that element's value, never refetching the plan. + // No-op when there is no active scripted_protocol tactic with temperature binding. + _handleTempUpdate(data) { + if (!data || !data.sample) return; + const plan = this.activePlan; + if (!plan || !Array.isArray(plan.tactics)) return; + // Only act when an active scripted_protocol tactic declares temperature binding. + const activeTactic = plan.tactics.find( + t => t.state === 'active' + && t.kind === 'scripted_protocol' + && Array.isArray(t.live_bind) + && t.live_bind.includes('temperature') + ); + if (!activeTactic) return; + + const root = document.getElementById('experiment-overview-root'); + if (!root) return; + // _renderOpsReadout stamps data-livebind="temperature" on the gauge div + // when the readout label normalises to "temperature". + const gauge = root.querySelector('.ops-node.active .ops-gauge[data-livebind="temperature"]'); + if (!gauge) return; + const gv = gauge.querySelector('.ops-gv'); + if (!gv) return; + + const s = data.sample; + const water = s.water_c != null + ? parseFloat(s.water_c).toFixed(1) + '°C' + : '—'; + const sp = s.setpoint_c != null + ? ' → ' + + parseFloat(s.setpoint_c).toFixed(1) + + '°C' + : ''; + gv.innerHTML = water + sp; + }, + setView(view) { if (view === this.activeView) return; this.activeView = view; @@ -193,7 +195,7 @@ const ExperimentOverview = { }); // Re-render against the last fetched strategy (no re-fetch on tab // switch — refresh happens on tab activation in the bootstrap). - this.render(this.activeStrategy || STUB_STRATEGY); + this.render(this.activeStrategy); }, render(s) { @@ -204,13 +206,32 @@ const ExperimentOverview = { } // Tear down any prior ticker before we blow away the SVG it pointed at. this._stopNowTicker(); + // Reset plan-picker state on each full render so the picker doesn't persist + // across tactic-event-driven re-renders. + this._planPickerOpen = false; + this._pickerItems = null; + // Rules view requires the strategy snapshot; show an empty state when absent. + // Overview view uses this.activePlan — the null/empty case is handled inside + // _renderOperationSpine (it renders the idle state). + if (this.activeView === 'rules' && !s) { + root.innerHTML = '
' + + 'No active experiment — rules and monitoring modes will appear here once a run is live.
'; + return; + } try { root.innerHTML = ''; if (this.activeView === 'rules') { this._renderRulesView(root, s); } else { - this._renderOverviewView(root, s); - this._startNowTicker(); + // Operation spine — data-driven tactic plan renderer. + // The swimlane view is retired; this renders this.activePlan. + this._renderOperationSpine(root, this.activePlan); + } + // Kick off the async Linked-plans panel (overview tab only). + // Fire-and-forget: appends a placeholder immediately, fills after fetch. + if (this.activeView === 'overview') { + this._initLinkedPlansPanel(root).catch(e => + console.warn('[ExperimentOverview] linked-plans panel error:', e)); } console.log('[ExperimentOverview] rendered OK, view=', this.activeView); } catch (err) { @@ -222,20 +243,6 @@ const ExperimentOverview = { } }, - // The "now" marker advances with wall-clock time and shows a countdown to - // the next base-interval acquisition. We update only the marker group's - // transform + the chip text, never re-rendering the whole SVG. Tick rate - // is ~4 Hz which keeps the line motion visibly smooth without burning - // cycles. Skipped while the tab is hidden. - _startNowTicker() { - this._stopNowTicker(); - const tick = () => { - if (!this._nowTickerCtx) return; - if (!document.hidden) this._updateNowMarker(); - this._nowTickerHandle = setTimeout(tick, 250); - }; - this._nowTickerHandle = setTimeout(tick, 250); - }, _stopNowTicker() { if (this._nowTickerHandle) { @@ -244,64 +251,256 @@ const ExperimentOverview = { } }, - _updateNowMarker() { - const ctx = this._nowTickerCtx; - if (!ctx || !ctx.marker.isConnected) return; - const elapsedRealS = (Date.now() - ctx.renderedAtMs) / 1000; - const effOffsetS = Math.min( - ctx.renderedOffsetS + elapsedRealS, - ctx.horizonS - ); - const x = ctx.xForT(effOffsetS); - ctx.marker.setAttribute('transform', `translate(${x},0)`); - - // Wall-clock from session-anchored time so the line and the clock - // can't drift apart even if the client clock is wrong. - const wallMs = ctx.startedAtMs + effOffsetS * 1000; - const d = new Date(wallMs); - const hh = String(d.getHours()).padStart(2, '0'); - const mm = String(d.getMinutes()).padStart(2, '0'); - const ss = String(d.getSeconds()).padStart(2, '0'); - - let label = `${hh}:${mm}:${ss}`; - if (ctx.baseIntervalS > 0) { - const nextTickS = Math.ceil(effOffsetS / ctx.baseIntervalS) * ctx.baseIntervalS; - const remainS = Math.max(0, Math.round(nextTickS - effOffsetS)); - const rm = Math.floor(remainS / 60); - const rs = String(remainS % 60).padStart(2, '0'); - label += ` · next ${rm}:${rs}`; + + // ================================================================= + // Linked-plans panel — session ↔ plan items (F / Task 4) + // Symmetric with the Plans-tab Sessions section (campaigns.js). + // Endpoints: GET /api/sessions/{id}/plans + // POST /api/campaigns/{cid}/items/{iid}/sessions + // DELETE .../sessions/{session_id} + // ================================================================= + + // Initialise and append the Linked-plans panel to the ops-wrap in root. + // Async: appends a loading placeholder immediately, fills after fetch. + // No-op when no session_id is known (e.g. store not yet initialised). + async _initLinkedPlansPanel(root) { + if (!this._currentSessionId) return; + const wrap = root.querySelector('.ops-wrap'); + if (!wrap) return; + + // Append placeholder before the async fetch so layout is stable. + const panelEl = document.createElement('div'); + panelEl.className = 'ops-lp'; + panelEl.innerHTML = '
Loading linked plans…
'; + wrap.appendChild(panelEl); + + const sid = this._currentSessionId; + try { + const [linkedData, campaignsData] = await Promise.all([ + fetch(`/api/sessions/${encodeURIComponent(sid)}/plans`, { cache: 'no-store' }) + .then(r => r.ok ? r.json() : { plans: [] }) + .catch(() => ({ plans: [] })), + fetch('/api/campaigns', { cache: 'no-store' }) + .then(r => r.ok ? r.json() : { campaigns: [] }) + .catch(() => ({ campaigns: [] })), + ]); + const plans = linkedData.plans || []; + const campaignNameMap = {}; + this._flattenCampaignNames(campaignsData.campaigns || [], campaignNameMap); + this._fillLinkedPlansPanel(panelEl, plans, campaignNameMap, sid); + } catch (e) { + console.warn('[ExperimentOverview] linked-plans init error:', e); + panelEl.innerHTML = '
Could not load linked plans.
'; } - ctx.chipText.textContent = label; - - // Size the chip to fit; flip to the left of the line if we're near - // the right edge so it stays on-screen. - const textLen = label.length * 6.2 + 12; - const nearEnd = x + textLen + 8 > ctx.laneRight; - if (nearEnd) { - ctx.chipBg.setAttribute('x', -textLen - 4); - ctx.chipBg.setAttribute('width', textLen); - ctx.chipText.setAttribute('x', -textLen + 2); + }, + + // Build a map of campaign_id → display name from the /api/campaigns tree. + _flattenCampaignNames(trees, map) { + const walk = (tree) => { + const c = tree.campaign; + if (c && c.id) map[c.id] = c.description || c.shorthand || c.id; + for (const child of (tree.children || [])) walk(child); + }; + for (const tree of (trees || [])) walk(tree); + }, + + // Build a flat list of {id, title, status, campaign_id, campaign_name} + // from the /api/campaigns tree, for the plan-item picker. + _flattenCampaignItems(trees, campaignNameMap) { + const items = []; + const walk = (tree, inheritedCid, inheritedName) => { + const c = tree.campaign; + const cid = (c && c.id) || inheritedCid; + const name = (c && (c.description || c.shorthand)) || inheritedName || cid; + for (const item of (tree.items || [])) { + items.push({ + id: item.id, + title: item.title || item.id, + status: typeof item.status === 'string' ? item.status + : (item.status && item.status.value) || 'planned', + campaign_id: cid, + campaign_name: name, + }); + } + for (const child of (tree.children || [])) walk(child, cid, name); + }; + for (const tree of (trees || [])) walk(tree, null, null); + return items; + }, + + // Render the linked-plans panel HTML into panelEl and wire button events. + _fillLinkedPlansPanel(panelEl, plans, campaignNameMap, sessionId) { + const ESC = this._opsESC.bind(this); + + // Header: section label + "+ link to a plan" button + let html = `
+ Linked plans + +
`; + + // Linked plan-item rows (title · campaign · status · delink) + if (plans.length > 0) { + html += '
'; + for (const p of plans) { + const cname = campaignNameMap[p.campaign_id] || p.campaign_id || '—'; + const sCls = p.status === 'completed' ? 'done' + : p.status === 'in_progress' ? 'active' : 'planned'; + html += `
+ ${ESC(p.title || p.id)} + ${ESC(cname)} + ${ESC(p.status || 'planned')} + +
`; + } + html += '
'; } else { - ctx.chipBg.setAttribute('x', 4); - ctx.chipBg.setAttribute('width', textLen); - ctx.chipText.setAttribute('x', 10); + html += '
Not linked to any plan
'; + } + + // Inline picker — shown when _planPickerOpen is set + if (this._planPickerOpen) { + if (this._pickerItems === null) { + // Still fetching — show loading state + html += '
Loading plan items…
'; + } else { + const linkedIds = new Set(plans.map(p => p.id)); + const available = this._pickerItems.filter(it => !linkedIds.has(it.id)); + const opts = available.length === 0 + ? '' + : available.map(it => + `` + ).join(''); + html += `
+ +
+ + +
+
`; + } + } + + panelEl.innerHTML = html; + + // Wire events directly on the rendered buttons. + const sid = sessionId; + const linkBtn = panelEl.querySelector('#ops-lp-link-btn'); + if (linkBtn) { + linkBtn.addEventListener('click', () => + this._openPlanPickerInPanel(panelEl, plans, campaignNameMap, sid)); + } + panelEl.querySelectorAll('.ops-lp-delink').forEach(btn => { + btn.addEventListener('click', () => + this._delinkPlanItem(panelEl, btn.dataset.itemId, btn.dataset.campaignId, sid)); + }); + const submitBtn = panelEl.querySelector('#ops-lp-picker-link'); + if (submitBtn) { + submitBtn.addEventListener('click', () => this._submitPlanLink(panelEl, sid)); + } + const cancelBtn = panelEl.querySelector('#ops-lp-picker-cancel'); + if (cancelBtn) { + cancelBtn.addEventListener('click', () => { + this._planPickerOpen = false; + this._pickerItems = null; + this._fillLinkedPlansPanel(panelEl, plans, campaignNameMap, sid); + }); } }, - _renderOverviewView(root, s) { - root.appendChild(this._renderHeader(s)); - root.appendChild(this._renderModes(s)); - root.appendChild(this._renderModeExpanded(s)); - root.appendChild(this._renderSwimlanes(s)); + // Open the inline plan-item picker: set loading state, fetch /api/campaigns, + // flatten to items, re-render with the picker populated. + async _openPlanPickerInPanel(panelEl, plans, campaignNameMap, sessionId) { + this._planPickerOpen = true; + this._pickerItems = null; // show loading + this._fillLinkedPlansPanel(panelEl, plans, campaignNameMap, sessionId); + try { + const res = await fetch('/api/campaigns', { cache: 'no-store' }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const data = await res.json(); + this._pickerItems = this._flattenCampaignItems(data.campaigns || [], campaignNameMap); + } catch (err) { + console.error('[ExperimentOverview] plan picker fetch error:', err); + this._pickerItems = []; + } + if (this._planPickerOpen) { + this._fillLinkedPlansPanel(panelEl, plans, campaignNameMap, sessionId); + } + }, + + // POST the selected plan item → session link, then refetch and re-render. + async _submitPlanLink(panelEl, sessionId) { + const select = panelEl.querySelector('#ops-lp-picker-sel'); + const value = select && select.value; + if (!value || !value.includes('::')) return; + const [campaignId, itemId] = value.split('::', 2); + if (!campaignId || !itemId) return; + + this._planPickerOpen = false; + this._pickerItems = null; + panelEl.innerHTML = '
Linking…
'; + try { + const res = await fetch( + `/api/campaigns/${encodeURIComponent(campaignId)}/items/${encodeURIComponent(itemId)}/sessions`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ session_id: sessionId }), + }, + ); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + } catch (err) { + console.error('[ExperimentOverview] plan link error:', err); + } + await this._refetchLinkedPlans(panelEl, sessionId); + }, + + // DELETE the plan-item → session edge, then refetch and re-render. + async _delinkPlanItem(panelEl, itemId, campaignId, sessionId) { + panelEl.innerHTML = '
Unlinking…
'; + try { + const res = await fetch( + `/api/campaigns/${encodeURIComponent(campaignId)}/items/${encodeURIComponent(itemId)}/sessions/${encodeURIComponent(sessionId)}`, + { method: 'DELETE' }, + ); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + } catch (err) { + console.error('[ExperimentOverview] plan delink error:', err); + } + await this._refetchLinkedPlans(panelEl, sessionId); + }, + + // Refetch linked plans + campaigns after a link/delink op, then re-render. + async _refetchLinkedPlans(panelEl, sessionId) { + try { + const [linkedData, campaignsData] = await Promise.all([ + fetch(`/api/sessions/${encodeURIComponent(sessionId)}/plans`, { cache: 'no-store' }) + .then(r => r.ok ? r.json() : { plans: [] }) + .catch(() => ({ plans: [] })), + fetch('/api/campaigns', { cache: 'no-store' }) + .then(r => r.ok ? r.json() : { campaigns: [] }) + .catch(() => ({ campaigns: [] })), + ]); + const plans = linkedData.plans || []; + const campaignNameMap = {}; + this._flattenCampaignNames(campaignsData.campaigns || [], campaignNameMap); + this._fillLinkedPlansPanel(panelEl, plans, campaignNameMap, sessionId); + } catch (e) { + console.warn('[ExperimentOverview] linked-plans refetch error:', e); + panelEl.innerHTML = '
Could not reload linked plans.
'; + } }, + + _renderRulesView(root, s) { // Compact header echoing the session identity const header = el('div', 'expov-header'); const metaRow = el('div', 'expov-header-row expov-header-row-meta'); metaRow.appendChild(elText('span', 'expov-session-name', s.session_name)); metaRow.appendChild(elText('span', 'expov-session-id', s.session_id)); - metaRow.appendChild(elText('span', 'expov-mockup-badge', 'mockup · stubbed data')); header.appendChild(metaRow); root.appendChild(header); @@ -313,52 +512,6 @@ const ExperimentOverview = { root.appendChild(this._renderRulesTable(s)); }, - // ----------------------------------------------------------------- - // Header — session identification + key metrics strip - // (page-level title lives in .experiment-header-bar above) - // ----------------------------------------------------------------- - _renderHeader(s) { - const elapsedH = Math.floor(s.now_offset_s / 3600); - const elapsedM = Math.floor((s.now_offset_s % 3600) / 60); - const wrap = el('div', 'expov-header'); - - // Session identification — the navbar already carries the id on - // every tab, so we only render a name line when it actually adds - // info (i.e. a human label, not a hash). The data-source badge is - // still useful and gets its own row so it stays visible. - const metaRow = el('div', 'expov-header-row expov-header-row-meta'); - if (s.session_name && s.session_name !== s.session_id) { - metaRow.appendChild(elText('span', 'expov-session-name', s.session_name)); - } - if (this.isLive) { - metaRow.appendChild(elText('span', 'expov-live-badge', 'live')); - } else { - metaRow.appendChild(elText('span', 'expov-mockup-badge', 'mockup · stubbed data')); - } - wrap.appendChild(metaRow); - - // Compact key-metric strip - const roleCounts = {}; - s.embryos.forEach(e => { roleCounts[e.role] = (roleCounts[e.role] || 0) + 1; }); - const roleStr = Object.entries(roleCounts).map(([r, n]) => `${n} ${r}`).join(' · '); - const metricsRow = el('div', 'expov-header-row expov-header-row-metrics'); - const metric = (label, val) => { - const m = el('span', 'expov-metric'); - m.appendChild(elText('span', 'expov-metric-val', val)); - m.appendChild(elText('span', 'expov-metric-lbl', label)); - return m; - }; - metricsRow.appendChild(metric('elapsed', `${elapsedH}h ${elapsedM}m`)); - metricsRow.appendChild(metric('base', `${s.base_interval_s}s`)); - const budgetText = (s.dose_budget_base_ms != null && isFinite(s.dose_budget_base_ms)) - ? `${(s.dose_budget_base_ms / 1000).toFixed(0)}s × role` - : 'no limit'; - metricsRow.appendChild(metric('budget', budgetText)); - metricsRow.appendChild(metric('embryos', `${s.embryos.length} · ${roleStr}`)); - wrap.appendChild(metricsRow); - - return wrap; - }, // ----------------------------------------------------------------- // Monitoring mode chips + expanded panel @@ -424,803 +577,653 @@ const ExperimentOverview = { return name.split('_').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' '); }, - // ----------------------------------------------------------------- - // Legend - // ----------------------------------------------------------------- - _renderLegend() { - const wrap = el('div', 'expov-legend'); - const items = [ - ['base', 'base cadence'], - ['fast', 'fast cadence'], - ['burst', 'burst window'], - ['cooldown', 'cooldown'], - ['paused', 'paused'], - ]; - items.forEach(([cls, label]) => { - const item = el('span', 'expov-legend-item'); - item.appendChild(elClass('span', `expov-legend-swatch ${cls}`)); - item.appendChild(elText('span', '', label)); - wrap.appendChild(item); - }); - const projItem = el('span', 'expov-legend-item'); - projItem.appendChild(elClass('span', 'expov-legend-swatch projected')); - projItem.appendChild(elText('span', '', 'projected')); - wrap.appendChild(projItem); - - const glyphs = [ - ['◇', 'trigger fired'], - ['●', 'now'], - ['■', 'stop condition'], - ['∞', 'open-ended'], - ['▲', 'burst start'], - ['⚠', 'budget warning'] - ]; - glyphs.forEach(([g, label]) => { - const item = el('span', 'expov-legend-item'); - item.appendChild(elText('span', 'expov-legend-glyph', g)); - item.appendChild(elText('span', '', label)); - wrap.appendChild(item); - }); - return wrap; - }, - // ----------------------------------------------------------------- - // Swimlanes SVG — the main visualization - // ----------------------------------------------------------------- - _renderSwimlanes(s) { - const wrap = el('div', 'expov-swimlanes-wrap'); - - // Compact inline legend above the SVG - const legend = el('div', 'expov-mini-legend'); - const swatches = [ - ['base', 'base'], - ['fast', 'fast'], - ['burst', 'burst'], - ['cooldown', 'cooldown'] - ]; - swatches.forEach(([k, label]) => { - const item = el('span', 'expov-mini-legend-item'); - const sw = el('span', `expov-mini-legend-swatch ${k}`); - item.appendChild(sw); - item.appendChild(elText('span', '', label)); - legend.appendChild(item); - }); - const projItem = el('span', 'expov-mini-legend-item'); - projItem.appendChild(elClass('span', 'expov-mini-legend-swatch projected')); - projItem.appendChild(elText('span', '', 'projected')); - legend.appendChild(projItem); - wrap.appendChild(legend); - - // Layout constants (logical pixels in the SVG viewBox) - const LEFT = 180; // label gutter - const RIGHT = 80; // right gutter for stop icon + ∞ - const LANE_W = 900; // lane drawing area - const W = LEFT + LANE_W + RIGHT; - - const ROW_H = 100; // per-embryo row total height - const LANE_H = 28; // cadence lane height - const POWER_H = 22; // power strip height - const DOSE_H = 12; // dose gauge height - const ROW_PAD = 14; // top padding inside row - const TOP_AXIS_H = 36; // top axis area (time labels + wall-clock) - const BOTTOM_PAD = 8; - - const rows = s.embryos.length; - const H = TOP_AXIS_H + rows * ROW_H + BOTTOM_PAD; - - const svg = svgEl('svg', { - class: 'expov-swimlanes-svg', - viewBox: `0 0 ${W} ${H}`, - preserveAspectRatio: 'xMinYMin meet' - }); + // ================================================================= + // Operation Spine — data-driven plan renderer (replaces swimlanes) + // ================================================================= - // Time scale helpers - const xForT = (t) => LEFT + (t / s.horizon_s) * LANE_W; - const nowX = xForT(s.now_offset_s); - - // ----- top axis: hour ticks with wall-clock annotation - const startedAt = new Date(s.started_at); - const axisG = svgEl('g'); - for (let h = 0; h <= Math.ceil(s.horizon_s / 3600); h++) { - const x = xForT(h * 3600); - axisG.appendChild(svgEl('line', { - x1: x, x2: x, y1: TOP_AXIS_H - 6, y2: H - BOTTOM_PAD, - class: 'expov-svg-axis', 'stroke-opacity': h === 0 ? 0.55 : 0.12 - })); - axisG.appendChild(svgEl('text', { - x: x + 4, y: 12, class: 'expov-svg-axis-label' - }, `+${h}h`)); - // Wall-clock subtitle - const wallClock = new Date(startedAt.getTime() + h * 3600 * 1000); - const hh = String(wallClock.getHours()).padStart(2, '0'); - const mm = String(wallClock.getMinutes()).padStart(2, '0'); - axisG.appendChild(svgEl('text', { - x: x + 4, y: 22, - class: 'expov-svg-axis-wallclock' - }, `${hh}:${mm}`)); + // Minimal HTML escaper — values in readouts may contain trusted HTML + // (e.g. 32.0°C) so they are rendered with + // innerHTML; all other user/model strings go through _opsESC. + _opsESC(s) { + return String(s == null ? '' : s) + .replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); + }, + + // Entry point: render the operation spine into `root`. + // plan = the plan object (tactics array) or null for idle/unavailable. + _renderOperationSpine(root, plan) { + const ESC = this._opsESC.bind(this); + + if (!plan || !Array.isArray(plan.tactics) || plan.tactics.length === 0) { + root.innerHTML = ` +
+
Operations
+

No operation running

+
Brief the agent — it will declare a tactic plan and the spine renders live.
+
+ +
— or start from a template
+
+ + + +
+
+
`; + + // Wire CTA buttons — same open+send pattern as landing.js sendFreeform. + function _opsOpenAgent(prompt) { + if (typeof AgentChat === 'undefined' || !AgentChat.togglePanel) return; + AgentChat.togglePanel(true); + if (prompt && AgentChat.runCommand) setTimeout(() => AgentChat.runCommand(prompt), 300); + } + const briefBtn = root.querySelector('[data-ops-brief]'); + if (briefBtn) briefBtn.addEventListener('click', () => _opsOpenAgent('')); + root.querySelectorAll('[data-ops-prompt]').forEach(chip => { + chip.addEventListener('click', () => _opsOpenAgent(chip.dataset.opsPrompt)); + }); + return; } - svg.appendChild(axisG); - - // ----- "now" marker: translatable group containing the vertical line - // and a live clock chip. The chip advances every tick and shows the - // countdown to the next base-interval acquisition window. The group - // gets translated by _tickNow, so we don't rebuild SVG every second. - const nowMarker = svgEl('g', { class: 'expov-svg-now-marker' }); - nowMarker.appendChild(svgEl('line', { - x1: 0, x2: 0, y1: TOP_AXIS_H - 4, y2: H - BOTTOM_PAD, - class: 'expov-svg-now-line' - })); - // Chip sits just below the axis labels (which live at y=12 and y=22) - // so it doesn't sit on top of the "+0h / wallclock" annotation when - // the now-line is near the start of the timeline. - const chipBg = svgEl('rect', { - x: 4, y: TOP_AXIS_H - 6, width: 120, height: 14, rx: 7, - class: 'expov-svg-now-chip-bg' - }); - const chipText = svgEl('text', { - x: 10, y: TOP_AXIS_H + 4, class: 'expov-svg-now-label' - }, ''); - nowMarker.appendChild(chipBg); - nowMarker.appendChild(chipText); - svg.appendChild(nowMarker); - - // Stash the bits the ticker needs to update without re-rendering. - this._nowTickerCtx = { - marker: nowMarker, - chipBg: chipBg, - chipText: chipText, - xForT, - startedAtMs: new Date(s.started_at).getTime(), - renderedAtMs: Date.now(), - renderedOffsetS: s.now_offset_s, - baseIntervalS: s.base_interval_s || 0, - horizonS: s.horizon_s, - laneLeft: LEFT, - laneRight: LEFT + LANE_W, - }; - this._updateNowMarker(); - - // ----- one group per embryo - s.embryos.forEach((emb, i) => { - const rowTop = TOP_AXIS_H + i * ROW_H; - const rowG = svgEl('g'); - rowG.appendChild(this._renderLaneRow(s, emb, { - LEFT, LANE_W, RIGHT, W, ROW_H, LANE_H, POWER_H, DOSE_H, ROW_PAD, - TOP_AXIS_H, rowTop, xForT, nowX - })); - svg.appendChild(rowG); - }); - wrap.appendChild(svg); - return wrap; + const tactics = plan.tactics; + const hasActive = tactics.some(t => t.state === 'active'); + // Index of the first queued (planned) tactic — gets the "next" badge. + const firstPlannedIdx = tactics.findIndex(t => t.state === 'planned'); + + // Roster lens: visible when embryos + role metadata are both available. + // Gracefully absent if either fetch failed or returned nothing (backward compat + // with plans that pre-date D2 — spine renders exactly as before). + const rosterEmbryos = this._rosterEmbryos || []; + const rolesMap = this._rolesMap; + const rosterCtx = (rosterEmbryos.length > 0 && rolesMap && rolesMap.size > 0) + ? { embryos: rosterEmbryos, rolesMap } + : null; + const rosterHtml = rosterCtx + ? ` + ${this._renderRosterLens(rosterEmbryos, rolesMap, plan, ESC)}` + : ''; + const spineLabel = rosterCtx + ? '' + : ''; + + const spineNodes = tactics + .map((t, idx) => this._renderOpsTactic(t, idx, firstPlannedIdx, ESC, rosterCtx, tactics)) + .join(''); + + root.innerHTML = ` +
+
Operations · ${hasActive ? 'live' : 'idle'}
+

${ESC(plan.title || '')}

+
${ESC(plan.session_id || '')}${plan.goal ? ' · ' + ESC(plan.goal) : ''}
+
+ done + in use + queued +
+ ${rosterHtml} + ${spineLabel} +
${spineNodes}
+
`; + this._wireSpineExpand(root); }, - _renderLaneRow(s, emb, dim) { - const { LEFT, LANE_W, W, ROW_H, LANE_H, POWER_H, DOSE_H, ROW_PAD, - rowTop, xForT, nowX } = dim; - const g = svgEl('g'); - - // Hairline divider above each row (except first) - if (rowTop > dim.TOP_AXIS_H) { - g.appendChild(svgEl('line', { - x1: 8, x2: W - 8, y1: rowTop, y2: rowTop, - class: 'expov-svg-row-divider' - })); + // Render a single tactic node. + // rosterCtx = { embryos, rolesMap } | null — when present, adds role-scope badge (D2). + // tactics = full tactics array — used to resolve relation ids to names. + _renderOpsTactic(t, idx, firstPlannedIdx, ESC, rosterCtx = null, tactics = []) { + const STATE_LABEL = { done: 'done', active: 'in use', planned: 'queued', paused: 'paused' }; + const seq = String(t.seq || idx + 1).padStart(2, '0'); + const stateLabel = STATE_LABEL[t.state] || t.state; + // First queued tactic gets a "next" badge — COCKED instrument marker. + const isFirstQueued = t.state === 'planned' && idx === firstPlannedIdx; + const nextBadge = isFirstQueued + ? 'next' + : ''; + + const live = t.live || {}; + const target = live.target || ''; + const summary = live.summary || ''; + const desc = live.desc || ''; + + // Scope chip/badge — compact chip for planned/done/paused (always visible, no embryo + // list needed); full badge with embryo resolution for active state (D2 roster lens). + const isExpandable = (t.state === 'planned' || t.state === 'done' || t.state === 'paused'); + const expandKey = this._tacticExpandKey(t); + const scopeBadge = isExpandable + ? this._renderOpsScopeChip(t.scope, ESC) + : (rosterCtx ? this._renderOpsScopeBadge(t.scope, rosterCtx.embryos, rosterCtx.rolesMap, ESC) : ''); + + // Chevron toggle — only for expandable (planned / done / paused) tactics. + const chevron = isExpandable + ? `` + : ''; + + // Header row: name · target · scope badge · summary · chevron + let inner = ` +
+ ${ESC(t.name)} + ${target ? `${ESC(target)}` : ''} + ${scopeBadge} + ${summary ? `${ESC(summary)}` : ''} + ${chevron} +
+ ${desc ? `
${ESC(desc)}
` : ''}`; + + // AUDIT: FLATTEN the active card — readouts on the panel face, separated by + // a hairline rule. No nested card-in-card boxes. + if (t.state === 'active' && live.readouts && live.readouts.length) { + inner += `
+
+ ${live.readouts.map(r => this._renderOpsReadout(r, ESC)).join('')} +
`; } - // ---- Left label gutter --------------------------------------- - // Single header line + phase pill. Power/dose labels are at the - // y-position of their respective sub-rows, right-aligned in the gutter. - const labelY = rowTop + ROW_PAD + 12; - - // Header line: icon · ID · role (· 10× hint for calibration) - g.appendChild(svgEl('text', { - x: 14, y: labelY + 1, class: 'expov-svg-role-icon', - fill: emb.color - }, emb.icon)); - g.appendChild(svgEl('text', { - x: 32, y: labelY, class: 'expov-svg-label' - }, emb.id)); - // role tag — eyebrow above the id (avoids colliding with long ids) - g.appendChild(svgEl('text', { - x: 14, y: rowTop + ROW_PAD - 1, - class: 'expov-svg-role-tag' - }, emb.role)); - - // Phase pill: current mode at glance - const currentPhase = emb.phases[emb.phases.length - 1]; - const pillY = labelY + 7; - const pillH = 14; - const phaseLabel = currentPhase.mode === 'burst' - ? `BURST · ${currentPhase.hz}Hz` - : `${currentPhase.mode.toUpperCase()} · ${currentPhase.cadence_s}s`; - const pillW = Math.max(70, phaseLabel.length * 6 + 12); - const pillX = 32; - const phaseColors = { - base: '#6b7280', - fast: '#fb923c', - burst: '#ef4444', - cooldown: '#a78bfa', - paused: '#3b82f6' - }; - const pillFill = phaseColors[currentPhase.mode] || '#6b7280'; - g.appendChild(svgEl('rect', { - x: pillX, y: pillY, width: pillW, height: pillH, rx: 7, - fill: pillFill, 'fill-opacity': 0.22, - stroke: pillFill, 'stroke-opacity': 0.65, 'stroke-width': 1 - })); - g.appendChild(svgEl('text', { - x: pillX + pillW / 2, y: pillY + 10, - 'text-anchor': 'middle', - fill: pillFill, 'font-size': 9.5, 'font-weight': 700, - 'font-family': "'JetBrains Mono', monospace" - }, phaseLabel)); - - // Tiny tp annotation under the pill (no stop — that's at lane right edge) - g.appendChild(svgEl('text', { - x: 32, y: pillY + pillH + 12, - class: 'expov-svg-sublabel' - }, `${emb.tp_acquired} tp acquired`)); - - // ---- Cadence lane -------------------------------------------- - const laneY = rowTop + ROW_PAD; - const laneMid = laneY + LANE_H / 2; - const laneBottom = laneY + LANE_H; - - // Phases — solid colored rects, no ticks. Cadence is read from the - // phase pill in the gutter and the optional inline cadence label. - // Min 4px visual width so micro-phases (burst, cooldown) stay visible. - emb.phases.forEach(ph => { - const x0 = xForT(ph.start); - const x1Raw = xForT(ph.end); - const x1 = Math.max(x1Raw, x0 + 4); - const width = x1 - x0; - const cls = `expov-svg-phase-${ph.mode}`; - g.appendChild(svgEl('rect', { - x: x0, y: laneY, width, height: LANE_H, rx: 2, - class: cls - })); - // Cadence text inside the rect is intentionally omitted — the - // gutter pill (currentPhase) and the colored rect (mode) already - // convey it. Keep an inline label only for cooldown, which is a - // transient state the pill won't be showing. - if (ph.mode === 'cooldown' && ph.cadence_s && width >= 42) { - g.appendChild(svgEl('text', { - x: x0 + width / 2, y: laneMid + 3.5, - 'text-anchor': 'middle', - class: 'expov-svg-phase-label' - }, `${ph.cadence_s}s · cool`)); - } - // Burst: keep the bright block + balloon since it's the most - // attention-worthy event in the lane - if (ph.mode === 'burst') { - const bx = (xForT(ph.start) + xForT(ph.end)) / 2; - const balloonY = laneY - 12; - const halfW = 30; - g.appendChild(svgEl('rect', { - x: bx - halfW, y: balloonY - 10, width: halfW * 2, height: 12, rx: 3, - class: 'expov-svg-burst-balloon-bg' - })); - g.appendChild(svgEl('text', { - x: bx, y: balloonY - 1, - 'text-anchor': 'middle', - class: 'expov-svg-burst-label' - }, `${ph.frames}f · ${ph.hz}Hz`)); - g.appendChild(svgEl('line', { - x1: bx, x2: bx, y1: balloonY + 2, y2: laneY, - stroke: '#ef4444', 'stroke-width': 1, 'stroke-opacity': 0.7 - })); - } - }); + // Kind-specific structure for the active state. + if (t.state === 'active') { + inner += this._renderOpsKindActive(t, live, ESC); + } else if (t.state === 'planned') { + inner += this._renderOpsKindPlanned(t, ESC); + inner += this._renderOpsExpandBody(t, ESC, tactics); + } - // ---- Acquisition density heatmap ---------------------------- - // Instead of one hairline per acquisition (reads as a barcode), - // we encode acquisition density as a luminance gradient over - // the past portion of the lane: sparse = lane stays muted, - // dense = a brighter band. The eye reads acquisition rate as - // brightness — no discrete marks, no clutter. - // - // Counts are derived from phase cadence then rescaled to match - // the authoritative `tp_acquired`, so the gradient never lies - // about how many timepoints fired even when the backend phase - // history is stale or incorrect. - const tickEnd = s.now_offset_s; - const ackPhases = emb.phases - .map((ph, i) => ({ ph, i })) - .filter(({ ph }) => ph.mode !== 'burst' && ph.cadence_s); - const predicted = ackPhases.map(({ ph }) => { - const phEnd = Math.min(ph.end ?? tickEnd, tickEnd); - const dur = phEnd - ph.start; - return dur > 0 ? Math.max(1, Math.floor(dur / ph.cadence_s) + 1) : 0; - }); - const predictedTotal = predicted.reduce((a, b) => a + b, 0); - const actualTotal = Number.isFinite(emb.tp_acquired) - ? emb.tp_acquired : predictedTotal; - const scale = predictedTotal > 0 ? actualTotal / predictedTotal : 0; - const acquisitions = []; - ackPhases.forEach(({ ph }, idx) => { - const phEnd = Math.min(ph.end ?? tickEnd, tickEnd); - const dur = phEnd - ph.start; - if (dur <= 0) return; - const n = Math.max(1, Math.round(predicted[idx] * scale)); - for (let j = 0; j < n; j++) { - acquisitions.push(ph.start + (dur * (j + 0.5)) / n); + // Fix #1: surface flat live.* telemetry keys not covered by structured + // readouts/phases. Render for active (in-progress telemetry) and done + // (completion data such as sustained_hz, mp4_path, last_fired). + // Skip planned — no live data is bound yet. + if (t.state === 'active' || t.state === 'done') { + const SKIP = new Set(['readouts', 'phases', 'target', 'summary', 'desc']); + const flatEntries = Object.entries(live).filter(([k]) => !SKIP.has(k)); + if (flatEntries.length) { + const humanKey = k => k.replace(/_/g, ' '); + const pairs = flatEntries.map(([k, v]) => { + const vStr = v == null ? '—' : String(v); + return `${ESC(humanKey(k))}${ESC(vStr)}`; + }).join(''); + inner += `
${pairs}
`; } - }); + } - if (acquisitions.length > 0 && tickEnd > 0) { - // Layer 1 (background): smoothed luminance gradient - // encoding overall acquisition density along the lane. - // The triangular kernel kills aliasing stripes caused by - // evenly-spaced acquisitions falling into bins. - const BINS = 64; - const binSec = tickEnd / BINS; - const raw = new Array(BINS).fill(0); - for (const t of acquisitions) { - const bin = Math.min(BINS - 1, Math.max(0, Math.floor(t / binSec))); - raw[bin] += 1; - } - const kernel = [1, 2, 3, 4, 5, 4, 3, 2, 1]; - const kSum = kernel.reduce((a, b) => a + b, 0); - const kOff = Math.floor(kernel.length / 2); - const density = new Array(BINS).fill(0); - for (let i = 0; i < BINS; i++) { - let acc = 0, w = 0; - for (let k = 0; k < kernel.length; k++) { - const j = i + k - kOff; - if (j < 0 || j >= BINS) continue; - acc += raw[j] * kernel[k]; - w += kernel[k]; - } - density[i] = w > 0 ? acc / w * (kSum / w) : 0; - } - const maxD = Math.max(...density, 1e-6); - const gradId = `expov-density-${(emb.id || 'e').replace(/\W+/g, '_')}-r${rowTop}`; - const grad = svgEl('linearGradient', { - id: gradId, x1: '0%', x2: '100%', y1: '0%', y2: '0%' - }); - for (let i = 0; i < BINS; i++) { - const intensity = density[i] / maxD; - // Lower ceiling than the heatmap-only version (0.22 vs - // 0.38) because the dots above will carry the per-event - // signal; the band just hints at rate. - const alpha = 0.03 + 0.22 * intensity; - grad.appendChild(svgEl('stop', { - offset: `${(i / (BINS - 1)) * 100}%`, - 'stop-color': '#ffffff', - 'stop-opacity': alpha.toFixed(3), - })); - } - g.appendChild(grad); - const pastW = xForT(tickEnd) - LEFT; - if (pastW > 0) { - g.appendChild(svgEl('rect', { - x: LEFT, y: laneY, - width: pastW, height: LANE_H, - fill: `url(#${gradId})`, - rx: 2, - 'pointer-events': 'none' - })); - } - // Layer 2 (foreground): one soft round dot per acquisition - // along the top edge of the lane — keeps the per-event - // temporal discreteness the heatmap alone hides. - const dotY = laneY + 2; - for (const t of acquisitions) { - const tx = xForT(t); - g.appendChild(svgEl('circle', { - cx: tx, cy: dotY, r: 1.3, - class: 'expov-svg-acq-dot' - })); - } + // Expand body for done + paused tactics (appended after live-facts). + if (t.state === 'done' || t.state === 'paused') { + inner += this._renderOpsExpandBody(t, ESC, tactics); } - // ---- Cadence-change markers --------------------------------- - // Where consecutive phases differ in cadence (or mode), drop a - // vertical divider across the lane and a "300→60s · T34" chip - // above so the change is named and time-stamped in the lane. - // Apply the same scale factor used for tick rendering so the - // T# stamps shown on diamonds and cadence chips agree with the - // visible tick density and the authoritative tp_acquired count. - // Otherwise the chip might say "T118" on an embryo where we - // only drew 54 ticks — visually contradictory. - const tpIndexAt = (atS) => { - let count = 0; - for (const ph of emb.phases) { - if (!ph.cadence_s) continue; - if (atS < ph.start) break; - const phEnd = Math.min(atS, ph.end ?? atS); - count += Math.floor((phEnd - ph.start) / ph.cadence_s) + 1; - } - return Math.max(1, Math.round(count * scale)); - }; - // Track placed chip x-positions to avoid stacking chips on top - // of one another (e.g. the burst-balloon already sits there). - const placedChipX = []; - const burstXs = emb.phases - .filter(p => p.mode === 'burst') - .map(p => xForT(p.start)); - const isCollision = (x) => { - const min = 70; // px buffer - if (burstXs.some(bx => Math.abs(bx - x) < min)) return true; - return placedChipX.some(px => Math.abs(px - x) < min); - }; - // Walk phases and detect transitions, but COLLAPSE consecutive - // identical (mode + cadence) phases so a row of redundant phase - // records doesn't generate redundant dividers/chips. - let prevEffective = emb.phases[0]; - for (let i = 1; i < emb.phases.length; i++) { - const curr = emb.phases[i]; - const prev = prevEffective; - const sameCadence = prev.cadence_s === curr.cadence_s; - const sameMode = prev.mode === curr.mode; - if (sameCadence && sameMode) { - continue; // collapse: prevEffective stays the same + const cardExpandAttr = isExpandable + ? ` data-tactic-expand-id="${ESC(expandKey)}"` + : ''; + + return ` +
+
${seq} · ${stateLabel}${nextBadge ? ' ' + nextBadge : ''}
+
${inner}
+
`; + }, + + // ----------------------------------------------------------------- + // Expandable tactic detail — click-to-expand for queued/done/paused + // ----------------------------------------------------------------- + + // Stable key used to persist expand state across re-renders. + _tacticExpandKey(t) { + return String(t.id || t.seq || t.name || ''); + }, + + // Compact inline scope chip — always shown in the collapsed header. + // Works without embryo list; uses this._rolesMap for role color if available. + _renderOpsScopeChip(scope, ESC) { + const rolesMap = this._rolesMap; + if (!scope || scope.mode === 'global') { + return 'global'; + } + if (scope.mode === 'role') { + const role = scope.role || ''; + const roleInfo = rolesMap ? rolesMap.get(role) : null; + const color = (roleInfo && roleInfo.ui_color) || '#8b949e'; + const bg = this._hexToRgba(color, 0.12); + const border = this._hexToRgba(color, 0.35); + return `role: ${ESC(role)}`; + } + if (scope.mode === 'embryos') { + const ids = (scope.embryo_ids || []).join(', '); + return `${ESC(ids) || '—'}`; + } + return ''; + }, + + // Expanded detail body — rationale, scope, structure, relations, live.readouts. + // Rendered hidden by default; _wireSpineExpand toggles the `hidden` class. + _renderOpsExpandBody(t, ESC, tactics) { + const rows = []; + + // Rationale + if (t.rationale) { + rows.push(`
+ rationale + ${ESC(t.rationale)} +
`); + } + + // Scope — resolved readable form + const scope = t.scope; + if (scope) { + let scopeText = 'all embryos'; + if (scope.mode === 'role') { + scopeText = `role: ${scope.role || ''}`; + } else if (scope.mode === 'embryos') { + scopeText = (scope.embryo_ids || []).join(', ') || '—'; } - prevEffective = curr; - if (prev.mode === 'burst' || curr.mode === 'burst') continue; - const cx = xForT(curr.start); - if (cx > xForT(s.now_offset_s)) continue; - // Divider is cheap to keep even on collision; the chip is what - // crowds the space, so we skip just the chip when crowded. - g.appendChild(svgEl('line', { - x1: cx, x2: cx, y1: laneY, y2: laneBottom, - class: 'expov-svg-cadence-divider' - })); - if (isCollision(cx)) continue; - const tp = tpIndexAt(curr.start); - const prevS = prev.cadence_s ?? '?'; - const currS = curr.cadence_s ?? '?'; - const chipText = `${prevS}→${currS}s · T${tp}`; - const chipW = chipText.length * 5.6 + 10; - const chipY = laneY - 13; - g.appendChild(svgEl('rect', { - x: cx - chipW / 2, y: chipY, - width: chipW, height: 12, rx: 3, - class: 'expov-svg-cadence-chip-bg' - })); - g.appendChild(svgEl('text', { - x: cx, y: chipY + 9, - 'text-anchor': 'middle', - class: 'expov-svg-cadence-chip' - }, chipText)); - placedChipX.push(cx); + rows.push(`
+ scope + ${ESC(scopeText)} +
`); } - // ---- Power-change chips ------------------------------------- - // Same visual language as cadence chips, but parked in a row - // above so the two encodings stack neatly when they happen at - // the same trigger. Each chip names the rule outcome - // ("488 ↓ 5%→3%") and the timepoint it landed at. - const placedPowerChipX = []; - const hist488 = emb.power_history_488 || []; - // Walk the history, collect actual transitions (pairs where pct - // changes), then cluster consecutive close ones so a multi-step - // ramp gets a single annotation. - const transitions = []; - for (let k = 0; k < hist488.length - 1; k++) { - const a = hist488[k]; - const b = hist488[k + 1]; - if (a.pct === b.pct) continue; - transitions.push({ from: a, to: b }); + // Structure — kind-specific + const struct = t.structure || {}; + if (t.kind === 'standing_timelapse' && struct.cadence_s != null) { + rows.push(`
+ cadence + ${ESC(struct.cadence_s)}s +
`); } - const CLUSTER_S = 60; - const clusters = []; - for (const tr of transitions) { - const last = clusters[clusters.length - 1]; - if (last && tr.to.at - last[last.length - 1].to.at <= CLUSTER_S) { - last.push(tr); - } else { - clusters.push([tr]); + if (t.kind === 'scripted_protocol') { + const phases = (struct.phases || []); + if (phases.length) { + const pHtml = phases.map(p => + `${ESC(p.name || p.state || '?')}` + ).join(''); + rows.push(`
+ phases + ${pHtml} +
`); } } - for (const cluster of clusters) { - const first = cluster[0]; - const tail = cluster[cluster.length - 1]; - // Anchor the chip at the actual change time, not at any - // trailing anchor record (those can land past `now` and - // get hidden by the past-only guard). - const atS = Math.min(tail.to.at, s.now_offset_s); - const cx = xForT(atS); - const arrow = tail.to.pct < first.from.pct ? '↓' : '↑'; - const chipText = `488 ${arrow} ${first.from.pct}%→${tail.to.pct}% · T${tpIndexAt(atS)}`; - const chipW = chipText.length * 5.6 + 10; - // Sit just above the burst-balloon band (which lives at - // laneY-22..-10) so we stay within this row's vertical - // budget — laneY-40 would have crossed into the row above. - // Burst balloons live at separate x positions on every - // case I've seen, so dropping the burst-collision check - // lets the chip render even when a burst is on the same - // lane elsewhere. - const chipY = laneY - 25; - const crowded = - placedPowerChipX.some(px => Math.abs(px - cx) < 70); - g.appendChild(svgEl('line', { - x1: cx, x2: cx, y1: chipY + 12, y2: laneY, - class: 'expov-svg-power-chip-stem' - })); - if (crowded) continue; - g.appendChild(svgEl('rect', { - x: cx - chipW / 2, y: chipY, - width: chipW, height: 12, rx: 3, - class: 'expov-svg-power-chip-bg' - })); - g.appendChild(svgEl('text', { - x: cx, y: chipY + 9, - 'text-anchor': 'middle', - class: 'expov-svg-power-chip' - }, chipText)); - placedPowerChipX.push(cx); + if (t.kind === 'exclusive_burst' || t.kind === 'burst') { + if (struct.frames != null) { + rows.push(`
+ frames + ${ESC(struct.frames)} +
`); + } + if (struct.mode) { + rows.push(`
+ mode + ${ESC(struct.mode)} +
`); + } + } + if (t.kind === 'reactive_monitor' && struct.watch) { + rows.push(`
+ watch + ${ESC(struct.watch)} +
`); } - // Projected future segment (dashed) past 'now' to a horizon — - // skipped entirely when the embryo has been terminated, since - // there's no future to project. projEndT is hoisted because - // downstream code (stop-icon, dose-exhaust line) anchors to it. - const isTerminated = emb.terminated_at_s != null - && emb.terminated_at_s <= s.now_offset_s; - const projStartT = s.now_offset_s; - let projEndT = isTerminated ? emb.terminated_at_s : s.horizon_s; - let projEndsAtBudget = false; - if (!isTerminated) { - if (emb.projected_end_s) projEndT = Math.min(projEndT, emb.projected_end_s); - if (emb.dose_exhaust_at_s && emb.dose_exhaust_at_s < projEndT) { - projEndT = emb.dose_exhaust_at_s; - projEndsAtBudget = true; - } - const xProjStart = xForT(projStartT); - const xProjEnd = xForT(projEndT); - if (xProjEnd > xProjStart) { - g.appendChild(svgEl('line', { - x1: xProjStart, y1: laneMid, x2: xProjEnd, y2: laneMid, - class: projEndsAtBudget - ? 'expov-svg-projected-bar warn' - : 'expov-svg-projected-bar' - })); + // Relations — resolve tactic IDs to names + const relations = t.relations || {}; + const afterIds = Array.isArray(relations.after) ? relations.after : []; + if (afterIds.length) { + const tacticIdMap = {}; + for (const tac of (tactics || [])) { + if (tac.id) tacticIdMap[tac.id] = tac.name || tac.id; } + const names = afterIds.map(id => tacticIdMap[id] || id); + rows.push(`
+ runs after + ${ESC(names.join(', '))} +
`); } - // Terminated cap: small vertical stop bar at the termination - // point + a "■ DONE · T##" label below the lane so a finished - // embryo doesn't look like it's still acquiring. - if (isTerminated) { - const termX = xForT(emb.terminated_at_s); - g.appendChild(svgEl('line', { - x1: termX, x2: termX, - y1: laneY - 2, y2: laneBottom + 2, - class: 'expov-svg-terminated-bar' - })); - g.appendChild(svgEl('rect', { - x: termX - 2, y: laneY + LANE_H / 2 - 3, - width: 6, height: 6, - class: 'expov-svg-terminated-stop' - })); - const tp = tpIndexAt(emb.terminated_at_s); - const capText = `DONE · T${tp}`; - g.appendChild(svgEl('text', { - x: termX + 6, y: laneBottom + 9, - class: 'expov-svg-terminated-label' - }, capText)); + + // Live readouts (queued/done tactics may carry pre-set readout definitions) + const live = t.live || {}; + if (live.readouts && live.readouts.length) { + rows.push(`
+ ${live.readouts.map(r => this._renderOpsReadout(r, ESC)).join('')} +
`); } - // Trigger diamonds — placed in the upper half of the lane to avoid - // colliding with the burst balloon above. Each diamond gets a tiny - // T# label below it so the user can see at which timepoint the - // rule fired without hovering. - (emb.trigger_events || []).forEach(te => { - const x = xForT(te.at); - const dy = laneY + 6; - const size = 4; - const trig = s.triggers.find(t => t.id === te.trigger_id); - const label = trig ? trig.label : te.trigger_id; - const dia = svgEl('polygon', { - points: `${x},${dy-size} ${x+size},${dy} ${x},${dy+size} ${x-size},${dy}`, - class: 'expov-svg-trigger-diamond expov-svg-tooltip-target' + if (!rows.length) return ''; + + const isOpen = this._expandedTacticIds.has(this._tacticExpandKey(t)); + return `
+
+ ${rows.join('\n')} +
`; + }, + + // Wire click-to-expand on all expandable tactic cards in root after innerHTML is set. + // Persist expand state in _expandedTacticIds so it survives debounced re-renders. + _wireSpineExpand(root) { + root.querySelectorAll('.ops-card[data-tactic-expand-id]').forEach(card => { + const expandId = card.dataset.tacticExpandId; + const body = card.querySelector('.ops-expand-body'); + const chevron = card.querySelector('.ops-expand-chevron'); + if (!body) return; + + card.addEventListener('click', (e) => { + // Let clicks on interactive elements inside the body bubble freely. + if (e.target.closest('a, button:not(.ops-expand-chevron)')) return; + const isExpanded = !body.classList.contains('hidden'); + if (isExpanded) { + body.classList.add('hidden'); + if (chevron) chevron.classList.remove('open'); + this._expandedTacticIds.delete(expandId); + } else { + body.classList.remove('hidden'); + if (chevron) chevron.classList.add('open'); + this._expandedTacticIds.add(expandId); + } }); - const tooltip = svgEl('title'); - tooltip.textContent = `${label}\n${trig?.when_text || ''} → ${trig?.then_text || ''}` + - (te.count ? ` (×${te.count})` : ''); - dia.appendChild(tooltip); - g.appendChild(dia); - g.appendChild(svgEl('text', { - x: x, y: laneBottom + 9, - 'text-anchor': 'middle', - class: 'expov-svg-trigger-tp' - }, `T${tpIndexAt(te.at)}`)); }); + }, + + // Render a readout gauge. `r.value` may contain trusted HTML (span markup). + // Stamps data-livebind on the outer div so _handleTempUpdate (and future + // live-binding) can find the gauge in-place without a full re-render. + // Priority: r.bind (explicit semantic key) > normalised r.label. + _renderOpsReadout(r, ESC) { + const bindKey = r.bind + ? r.bind + : (r.label + ? r.label.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_|_$/g, '') + : ''); + const bindAttr = bindKey ? ` data-livebind="${ESC(bindKey)}"` : ''; + return `
+
${ESC(r.label)}
+
${r.value}
+ ${r.bar != null + ? `
` + : ''} + ${r.sub ? `
${ESC(r.sub)}
` : ''} +
`; + }, - // Dose-exhaust warning: ⚠ + time-to-exhaust positioned ABOVE the lane - // so it doesn't overlap with the dashed projected bar - if (emb.dose_exhaust_at_s && emb.dose_exhaust_at_s < s.horizon_s) { - const exhX = xForT(emb.dose_exhaust_at_s); - const remain = emb.dose_exhaust_at_s - s.now_offset_s; - const rh = Math.floor(remain / 3600); - const rm = Math.floor((remain % 3600) / 60); - const exhText = `⚠ budget exhausts in ${rh > 0 ? rh + 'h ' : ''}${rm}m`; - g.appendChild(svgEl('text', { - x: exhX - 4, y: laneY - 5, - 'text-anchor': 'end', - fill: 'var(--accent-orange)', - 'font-size': 10, - 'font-weight': 600, - 'font-family': "'JetBrains Mono', monospace" - }, exhText)); - // Small dotted vertical marker so the user can see WHERE on the lane - g.appendChild(svgEl('line', { - x1: exhX, x2: exhX, y1: laneY, y2: laneY + LANE_H, - stroke: 'var(--accent-orange)', - 'stroke-width': 1.5, - 'stroke-dasharray': '2 2', - opacity: 0.7 - })); + // Render one phase in the scripted_protocol stepper. + // AUDIT: the active phase is the HEADLINE — CSS makes it larger. + _renderOpsPhase(p, ESC) { + const pips = (p.pips || []) + .map(k => ``) + .join(''); + const ic = p.state === 'done' ? '✓' + : p.state === 'active' ? '▶' + : (p.icon || '·'); + return `
+
${ic}${ESC(p.name)}
+
${ESC(p.count || '')}
+ ${pips ? `
${pips}
` : ''} +
`; + }, + + // Kind-specific structure for ACTIVE tactics. + _renderOpsKindActive(t, live, ESC) { + if (!t.kind) return ''; + + // scripted_protocol → before/during/after phase stepper. + // Prefer live.phases (may carry pip/count state); fall back to structure.phases. + if (t.kind === 'scripted_protocol') { + const phases = live.phases || (t.structure && t.structure.phases) || []; + if (!phases.length) return ''; + return `
+ ${phases.map(p => this._renderOpsPhase(p, ESC)).join('')} +
`; } - // Open-ended ∞ glyph at right edge - if (emb.stop_kind === 'open_ended') { - g.appendChild(svgEl('text', { - x: LEFT + LANE_W + 8, y: laneMid + 5, - class: 'expov-svg-infinity' - }, '∞')); - } else { - g.appendChild(svgEl('text', { - x: xForT(projEndT) + 6, y: laneMid + 4, - class: 'expov-svg-stop-icon expov-svg-stop-hatch' - }, '■')); + + // standing_timelapse → compact per-embryo cadence strip. + if (t.kind === 'standing_timelapse') { + const perEmbryo = t.structure && t.structure.per_embryo; + if (!perEmbryo || !perEmbryo.length) return ''; + const rows = perEmbryo.map(e => { + const intervalStr = e.interval_s != null ? `${ESC(e.interval_s)}s` : '—'; + return `
+ ${ESC(e.embryo_id)} + ${ESC(e.cadence_phase)} + ${intervalStr} +
`; + }).join(''); + return `
${rows}
`; + } + + // reactive_monitor → armed/watching/fired status badge. + if (t.kind === 'reactive_monitor') { + const st = (t.structure && t.structure.status) || 'armed'; + return `
${ESC(st)}
`; + } + + // exclusive_burst / oneshot / custom — readouts only (already rendered above). + return ''; + }, + + // Kind-specific structure for PLANNED (queued) tactics — compact hints. + _renderOpsKindPlanned(t, ESC) { + if (!t.kind) return ''; + + if (t.kind === 'scripted_protocol') { + const phases = (t.structure && t.structure.phases) || []; + if (!phases.length) return ''; + return `
+ ${phases.map(p => this._renderOpsPhase(p, ESC)).join('')} +
`; + } + + if (t.kind === 'standing_timelapse' && t.structure && t.structure.cadence_s) { + return `
cadence · ${ESC(t.structure.cadence_s)}s
`; + } + + if (t.kind === 'reactive_monitor' && t.structure && t.structure.watch) { + return `
watch · ${ESC(t.structure.watch)}
`; + } + + if ((t.kind === 'oneshot' || t.kind === 'custom') && t.structure && t.structure.note) { + return `
${ESC(t.structure.note)}
`; + } + + return ''; + }, + + // ================================================================= + // D2 — Roster Lens: embryo population by role class + role + // ================================================================= + + // Fetch the current embryo roster from /api/embryos/positions. + // Returns [] on failure or when no embryos have positions yet. + async _loadRoster() { + try { + const resp = await fetch('/api/embryos/positions', { cache: 'no-store' }); + if (!resp.ok) return []; + const data = await resp.json(); + return Array.isArray(data.embryos) ? data.embryos : []; + } catch (e) { + console.warn('[ExperimentOverview] roster fetch error:', e); + return []; } + }, - // ---- Power strip --------------------------------------------- - // Visual encoding makes "steady" vs "ramping" obvious: - // • Steady segments = thin grey horizontal line - // • Ramping segments = bright cyan line + filled area + dot at each step - // • Step transitions get a small arrow (↓ or ↑) and a delta tag - const powerY = laneY + LANE_H + 6; - const powerH = POWER_H; - const powerYBase = powerY + powerH; - g.appendChild(svgEl('line', { - x1: LEFT, x2: LEFT + LANE_W, y1: powerYBase, y2: powerYBase, - class: 'expov-svg-power-baseline' - })); - const yForPct = (pct) => powerYBase - (pct / 10) * powerH; - - const hist = emb.power_history_488 || []; - if (hist.length > 1) { - // Detect ramp clusters: consecutive change-steps with x-spacing < - // CLUSTER_PX get grouped, annotated once at the cluster end. - const CLUSTER_PX = 20; - const stepEvents = []; // each: {fromIdx, toIdx, isRamp} - for (let k = 0; k < hist.length - 1; k++) { - if (hist[k].pct !== hist[k+1].pct) { - stepEvents.push({ fromIdx: k, toIdx: k+1 }); + // Fetch the roles registry from /api/roles. + // Returns a Map(name → {ui_color, ui_icon, role_class, default_cadence_seconds}). + // Returns empty Map on failure. + async _loadRolesMap() { + try { + const resp = await fetch('/api/roles', { cache: 'no-store' }); + if (!resp.ok) return new Map(); + const data = await resp.json(); + const map = new Map(); + if (Array.isArray(data.roles)) { + for (const r of data.roles) { + map.set(r.name, r); } } - // Group consecutive close steps into clusters - const clusters = []; - stepEvents.forEach(step => { - const last = clusters[clusters.length - 1]; - const stepX = xForT(hist[step.toIdx].at); - if (last) { - const lastX = xForT(hist[last[last.length-1].toIdx].at); - if (Math.abs(stepX - lastX) < CLUSTER_PX) { - last.push(step); - return; - } - } - clusters.push([step]); - }); + return map; + } catch (e) { + console.warn('[ExperimentOverview] roles fetch error:', e); + return new Map(); + } + }, - // Draw horizontal "steady" segments + vertical "step" lines for all - // adjacent (hist[k], hist[k+1]) pairs. - for (let k = 0; k < hist.length - 1; k++) { - const x0 = xForT(hist[k].at); - const x1 = xForT(hist[k+1].at); - const y = yForPct(hist[k].pct); - const yNext = yForPct(hist[k+1].pct); - g.appendChild(svgEl('line', { - x1: x0, x2: x1, y1: y, y2: y, - class: 'expov-svg-power-steady' - })); - if (hist[k].pct !== hist[k+1].pct) { - g.appendChild(svgEl('line', { - x1: x1, x2: x1, y1: y, y2: yNext, - class: 'expov-svg-power-step' - })); - g.appendChild(svgEl('circle', { - cx: x1, cy: yNext, r: 2.2, - class: 'expov-svg-power-stepdot' - })); - } + // Convert a hex color (#rrggbb) to rgba(r,g,b,alpha) for inline styles. + _hexToRgba(hex, alpha) { + const h = (hex || '#888888').replace('#', ''); + const r = parseInt(h.slice(0, 2), 16) || 0; + const g = parseInt(h.slice(2, 4), 16) || 0; + const b = parseInt(h.slice(4, 6), 16) || 0; + return `rgba(${r},${g},${b},${alpha})`; + }, + + // Cross-reference: find the name of the active (or most recently done) tactic + // that covers a given embryo (by embryo_id + role). + // Scope resolution: global → covers all; role → covers matching role; + // embryos → covers listed ids. Returns '' when no tactic found. + // NOTE: mirrors resolve_scope_embryos() in gently/app/orchestration/role_scope.py — keep in sync if a new scope mode is added. + _resolveCurrentTactic(embryoId, role, plan) { + if (!plan || !Array.isArray(plan.tactics)) return ''; + const covers = (t) => { + const scope = t.scope || { mode: 'global' }; + if (scope.mode === 'global') return true; + if (scope.mode === 'role') return scope.role === role; + if (scope.mode === 'embryos') { + return Array.isArray(scope.embryo_ids) && scope.embryo_ids.includes(embryoId); } - // Final tail to lane right edge - const last = hist[hist.length - 1]; - const lastX = xForT(last.at); - const lastY = yForPct(last.pct); - g.appendChild(svgEl('line', { - x1: lastX, x2: LEFT + LANE_W, y1: lastY, y2: lastY, - class: 'expov-svg-power-steady' - })); - - // One annotation per cluster — bracket + "5% → 3%" label - clusters.forEach(cluster => { - const first = cluster[0]; - const tail = cluster[cluster.length - 1]; - const xStart = xForT(hist[first.fromIdx].at); - const xEnd = xForT(hist[tail.toIdx].at); - const pctStart = hist[first.fromIdx].pct; - const pctEnd = hist[tail.toIdx].pct; - const arrow = pctEnd < pctStart ? '↓' : '↑'; - const yMid = (yForPct(pctStart) + yForPct(pctEnd)) / 2; - // Bracket: small horizontal line above the cluster steps - const bracketY = Math.min(yForPct(pctStart), yForPct(pctEnd)) - 6; - g.appendChild(svgEl('path', { - d: `M ${xStart} ${bracketY+3} L ${xStart} ${bracketY} L ${xEnd+2} ${bracketY} L ${xEnd+2} ${bracketY+3}`, - class: 'expov-svg-power-ramp-bracket' - })); - // Label "488 ↓ 5%→3%" anchored just right of the bracket end - g.appendChild(svgEl('text', { - x: xEnd + 6, y: bracketY + 4, - class: 'expov-svg-power-ramp-label' - }, `${arrow} ${pctStart}%→${pctEnd}%`)); - }); + return false; + }; + // Prefer the active tactic covering this embryo. + const active = plan.tactics.find(t => t.state === 'active' && covers(t)); + if (active) return active.name; + // Fall back to the most recently done tactic (last in array order). + for (let i = plan.tactics.length - 1; i >= 0; i--) { + if (plan.tactics[i].state === 'done' && covers(plan.tactics[i])) { + return plan.tactics[i].name; + } + } + return ''; + }, - // Subtle filled area under the curve — helps read overall level - const areaPts = [`${xForT(hist[0].at)},${powerYBase}`]; - for (let k = 0; k < hist.length; k++) { - const x = xForT(hist[k].at); - const y = yForPct(hist[k].pct); - areaPts.push(`${x},${y}`); - if (k < hist.length - 1) { - const xNext = xForT(hist[k+1].at); - areaPts.push(`${xNext},${y}`); - } + // Render a scope badge for a tactic node in the spine. + // Colors for role-scoped badges come from the roles registry (API), not CSS. + // Global and explicit-embryos scopes use the static `.ops-scope-global` class. + _renderOpsScopeBadge(scope, embryos, rolesMap, ESC) { + if (!scope || scope.mode === 'global') { + return '→ all embryos'; + } + if (scope.mode === 'role') { + const role = scope.role || ''; + const roleInfo = rolesMap ? rolesMap.get(role) : null; + const color = (roleInfo && roleInfo.ui_color) || '#8b949e'; + const matchIds = embryos + .filter(e => e.role === role) + .map(e => e.embryo_id) + .join(', '); + const label = matchIds + ? `→ ${ESC(role)} · ${ESC(matchIds)}` + : `→ ${ESC(role)}`; + const bg = this._hexToRgba(color, 0.12); + const border = this._hexToRgba(color, 0.35); + return `${label}`; + } + if (scope.mode === 'embryos') { + const ids = ESC((scope.embryo_ids || []).join(', ')); + return `→ ${ids}`; + } + return ''; + }, + + // Render the D2 roster lens: embryos grouped by role class then by role. + // SUBJECTS section is foregrounded; REFERENCES section is compact/muted. + // Role colors/icons come from the rolesMap (API data), not from CSS constants. + // Returns empty string when embryos array is empty (backward compat). + _renderRosterLens(embryos, rolesMap, plan, ESC) { + if (!embryos || embryos.length === 0) return ''; + + // Group embryos by role_class then by role, preserving first-seen order. + const CLASS_ORDER_PREF = ['subject', 'reference']; + const classOrder = []; + const byClass = {}; + + for (const emb of embryos) { + const roleInfo = rolesMap.get(emb.role); + const cls = (roleInfo && roleInfo.role_class) || 'subject'; + if (!byClass[cls]) { + byClass[cls] = { roleOrder: [], byRole: {} }; + classOrder.push(cls); + } + const section = byClass[cls]; + if (!section.byRole[emb.role]) { + section.byRole[emb.role] = []; + section.roleOrder.push(emb.role); } - areaPts.push(`${LEFT + LANE_W},${yForPct(last.pct)}`); - areaPts.push(`${LEFT + LANE_W},${powerYBase}`); - g.appendChild(svgEl('polygon', { - points: areaPts.join(' '), - class: 'expov-svg-power-area' - })); + section.byRole[emb.role].push(emb); } - // Power label with current value — "@" reads as "at this power" - // and avoids confusion with the bullet-separator used elsewhere - g.appendChild(svgEl('text', { - x: LEFT - 8, y: powerY + powerH / 2 + 3, - 'text-anchor': 'end', - class: 'expov-svg-sublabel' - }, `488 @ ${emb.laser_488_pct_now}%`)); - - // ---- Dose gauge ---------------------------------------------- - const doseY = powerYBase + 6; - const doseW = LANE_W; - g.appendChild(svgEl('rect', { - x: LEFT, y: doseY, width: doseW, height: DOSE_H, rx: 2, - class: 'expov-svg-dose-track' - })); - const dosePct = emb.dose_used_ms / emb.dose_budget_ms; - const fillCls = dosePct > 0.85 ? 'expov-svg-dose-fill-crit' - : dosePct > 0.60 ? 'expov-svg-dose-fill-warn' - : 'expov-svg-dose-fill-ok'; - g.appendChild(svgEl('rect', { - x: LEFT, y: doseY, width: Math.max(1, doseW * dosePct), height: DOSE_H, rx: 2, - class: fillCls - })); - // Dose label (shows 10× hint for calibration role) - const doseLabel = emb.role === 'calibration' ? 'dose (10×)' : 'dose'; - g.appendChild(svgEl('text', { - x: LEFT - 8, y: doseY + DOSE_H - 2, - 'text-anchor': 'end', - class: 'expov-svg-sublabel' - }, doseLabel)); - // Inside the bar: usage figure — "used of budget" is more scannable - // than "x / y s" which reads like a fraction - const usedS = (emb.dose_used_ms / 1000).toFixed(1); - const budgetS = (emb.dose_budget_ms / 1000).toFixed(1); - const doseText = emb.dose_budget_ms > 0 - ? `${usedS}s of ${budgetS}s (${Math.round(dosePct * 100)}%)` - : `${usedS}s used`; - g.appendChild(svgEl('text', { - x: LEFT + 6, y: doseY + DOSE_H - 2, - class: 'expov-svg-dose-text' - }, doseText)); - - return g; + + // Canonical class order: subjects first. + classOrder.sort((a, b) => { + const ai = CLASS_ORDER_PREF.indexOf(a); + const bi = CLASS_ORDER_PREF.indexOf(b); + return (ai < 0 ? 99 : ai) - (bi < 0 ? 99 : bi); + }); + + const totalCount = embryos.length; + const totalRoles = classOrder.reduce((n, cls) => n + byClass[cls].roleOrder.length, 0); + + const classSections = classOrder.map(cls => { + const { roleOrder, byRole } = byClass[cls]; + const isSubject = cls === 'subject'; + + const classHeaderInner = isSubject + ? `Subjects— adaptive tactics / scenarios` + : `References— steady acquisition`; + + const roleGroups = roleOrder.map(role => { + const roleEmbyros = byRole[role]; + const roleInfo = rolesMap.get(role); + const uiColor = (roleInfo && roleInfo.ui_color) || '#8b949e'; + const uiIcon = (roleInfo && roleInfo.ui_icon) || ''; + const bgRgba = this._hexToRgba(uiColor, 0.08); + const ids = roleEmbyros.map(e => e.embryo_id).join(', '); + + const embryoRows = roleEmbyros.map(emb => { + const cadencePhase = emb.cadence_phase || 'normal'; + const strain = emb.strain || '—'; + const label = emb.user_label || emb.embryo_id; + const tacticName = this._resolveCurrentTactic(emb.embryo_id, emb.role, plan); + const stateStr = emb.is_complete ? 'done' + : cadencePhase === 'paused' ? 'paused' + : 'active'; + const compact = isSubject ? '' : ' compact'; + const chipBg = this._hexToRgba(uiColor, 0.15); + const chipBorder = this._hexToRgba(uiColor, 0.4); + return `
+ ${ESC(label)} + ${uiIcon ? ESC(uiIcon) + ' ' : ''}${ESC(role)} + ${ESC(strain)} + ${ESC(cadencePhase)} + ${ESC(tacticName || '—')} + ${ESC(stateStr)} +
`; + }).join(''); + + return `
+
+ ${ESC(role.toUpperCase())} + · + ${roleEmbyros.length} embryo${roleEmbyros.length !== 1 ? 's' : ''} + ${ESC(ids)} +
+ ${embryoRows} +
`; + }).join(''); + + return `
+
${classHeaderInner}
+ ${roleGroups} +
`; + }).join(''); + + return `
+
+ Population roster + ${totalCount} embryo${totalCount !== 1 ? 's' : ''} · ${totalRoles} role${totalRoles !== 1 ? 's' : ''} +
+ ${classSections} +
`; }, // ----------------------------------------------------------------- diff --git a/gently/ui/web/static/js/gallery.js b/gently/ui/web/static/js/gallery.js index 42159d18..259e6636 100644 --- a/gently/ui/web/static/js/gallery.js +++ b/gently/ui/web/static/js/gallery.js @@ -1631,3 +1631,173 @@ function filterByEmbryo(list) { if (!state.embryoFilter) return list; return list.filter(img => img.metadata?.embryo_id === state.embryoFilter); } + +// ========================================== +// GalleryTab — top-level Gallery tab controller +// ========================================== + +const GalleryTab = { + _allItems: [], + _embryoFilter: '', + _typeFilter: '', + + async init() { + const panel = document.getElementById('gallery-tab-body'); + if (!panel) return; + + // Wire filter controls (idempotent) + const embryoSel = document.getElementById('gallery-embryo-filter'); + const typeSel = document.getElementById('gallery-type-filter'); + const refreshBtn = document.getElementById('gallery-refresh-btn'); + if (embryoSel && !embryoSel._gtWired) { + embryoSel._gtWired = true; + embryoSel.addEventListener('change', () => { + this._embryoFilter = embryoSel.value; + this._renderGrid(); + }); + } + if (typeSel && !typeSel._gtWired) { + typeSel._gtWired = true; + typeSel.addEventListener('change', () => { + this._typeFilter = typeSel.value; + this._renderGrid(); + }); + } + if (refreshBtn && !refreshBtn._gtWired) { + refreshBtn._gtWired = true; + refreshBtn.addEventListener('click', () => this._load()); + } + + await this._load(); + }, + + async _load() { + const panel = document.getElementById('gallery-tab-body'); + if (!panel) return; + panel.innerHTML = ''; + try { + const data = await fetch('/api/snapshots').then(r => r.json()); + const items = data.snapshots || []; + // Sort newest first + items.sort((a, b) => (b.timestamp || '').localeCompare(a.timestamp || '')); + this._allItems = items; + this._populateEmbroyFilter(items); + this._renderGrid(); + } catch (err) { + panel.innerHTML = ''; + } + }, + + _populateEmbroyFilter(items) { + const sel = document.getElementById('gallery-embryo-filter'); + if (!sel) return; + const embryos = [...new Set(items.map(i => i.metadata?.embryo_id).filter(Boolean))].sort(); + const cur = sel.value; + // Rebuild options beyond the "All embryos" placeholder + while (sel.options.length > 1) sel.remove(1); + embryos.forEach(eid => { + const opt = document.createElement('option'); + opt.value = eid; + opt.textContent = eid; + sel.appendChild(opt); + }); + if (cur && embryos.includes(cur)) sel.value = cur; + }, + + _renderGrid() { + const panel = document.getElementById('gallery-tab-body'); + if (!panel) return; + + let items = this._allItems; + if (this._embryoFilter) { + items = items.filter(i => (i.metadata?.embryo_id || '') === this._embryoFilter); + } + if (this._typeFilter) { + items = items.filter(i => i.data_type === this._typeFilter); + } + + if (items.length === 0) { + panel.innerHTML = ''; + return; + } + + const html = ``; + panel.innerHTML = html; + + // Wire click handlers + panel.querySelectorAll('.gallery-tab-item').forEach(el => { + el.addEventListener('click', () => { + const idx = parseInt(el.dataset.idx, 10); + Lightbox.open(items, idx, 'gallery'); + }); + }); + }, + + _itemHtml(img, idx) { + const embryo = img.metadata?.embryo_id || ''; + const ts = img.timestamp ? img.timestamp.slice(0, 19).replace('T', ' ') : ''; + const typeLabel = img.data_type || 'image'; + const thumb = img.base64_png + ? `${typeLabel}` + : ``; + return ` + + `; + }, +}; + +// ========================================== +// showGentlyToast — lightweight global toast (volume acquired, etc.) +// ========================================== + +/** + * Show a brief toast notification with an optional action link. + * @param {string} message - Primary message text + * @param {string|null} actionLabel - Label for the action button (null = no button) + * @param {Function|null} actionFn - Callback invoked when the action is clicked + * @param {number} [duration=6000] - Auto-dismiss delay in ms + */ +function showGentlyToast(message, actionLabel, actionFn, duration = 6000) { + // Remove any existing gently-toast + document.querySelectorAll('.gently-toast').forEach(t => t.remove()); + + const toast = document.createElement('div'); + toast.className = 'gently-toast'; + + const msgSpan = document.createElement('span'); + msgSpan.className = 'gently-toast-msg'; + msgSpan.textContent = message; + toast.appendChild(msgSpan); + + if (actionLabel && actionFn) { + const actionBtn = document.createElement('button'); + actionBtn.className = 'gently-toast-action'; + actionBtn.textContent = actionLabel; + actionBtn.addEventListener('click', () => { + actionFn(); + toast.remove(); + }); + toast.appendChild(actionBtn); + } + + const dismiss = document.createElement('button'); + dismiss.className = 'gently-toast-dismiss'; + dismiss.setAttribute('aria-label', 'Dismiss'); + dismiss.textContent = '×'; + dismiss.addEventListener('click', () => toast.remove()); + toast.appendChild(dismiss); + + document.body.appendChild(toast); + // Trigger transition + requestAnimationFrame(() => toast.classList.add('visible')); + + const timer = setTimeout(() => toast.remove(), duration); + dismiss.addEventListener('click', () => clearTimeout(timer)); +} diff --git a/gently/ui/web/static/js/home.js b/gently/ui/web/static/js/home.js index 089d7de3..bf5cad17 100644 --- a/gently/ui/web/static/js/home.js +++ b/gently/ui/web/static/js/home.js @@ -136,7 +136,13 @@ const HomeApp = (() => { function updateStatus() { const el = document.getElementById('home-status'); if (!el) return; - const connected = (typeof state !== 'undefined' && state.connected); + // Read the shared ConnectionStatus store, not a one-shot snapshot of + // state.connected — the latter was read once at tab init (before the + // /ws handshake) and never corrected, showing "Offline" while the + // header pill said "Online". + const connected = (typeof ConnectionStatus !== 'undefined') + ? ConnectionStatus.get().gentlyConnected + : (typeof state !== 'undefined' && state.connected); const n = (typeof state !== 'undefined' && Array.isArray(state.embryos)) ? state.embryos.length : 0; el.textContent = connected ? `Connected · ${n} embryo${n === 1 ? '' : 's'} in view` @@ -162,6 +168,12 @@ const HomeApp = (() => { if (AgentChat.runCommand) setTimeout(() => AgentChat.runCommand('/wizard'), 250); } }); + // Re-render the status line on every connection change. subscribe() + // replays the current snapshot immediately, so a late init still + // renders correct state. Registered once (inside the _inited guard). + if (typeof ConnectionStatus !== 'undefined') { + ConnectionStatus.subscribe(() => updateStatus()); + } } refresh(); // re-fetch on every entry to the tab } diff --git a/gently/ui/web/static/js/landing.js b/gently/ui/web/static/js/landing.js new file mode 100644 index 00000000..6d058ebe --- /dev/null +++ b/gently/ui/web/static/js/landing.js @@ -0,0 +1,876 @@ +/** + * V2Landing (ux_v2): the agent-first welcome AND the in-place plan wizard. + * + * Clicking "Plan an experiment" switches the landing to a plan screen, enters + * plan mode, and renders the agent's work IN THE WIZARD — not the chat REPL: + * - the agent's reasoning + tool calls render as a tidy, claude.ai-style + * collapsible activity feed (#v2-plan-activity), fed by the AGENT_ACTIVITY + * event that agent-chat.js mirrors off the /ws/agent stream; + * - the agent's ask_user_choice questions render as button cards + * (#v2-plan-ask) via AgentChat.buildAskCard; + * - "THE PLAN" panel (#v2-plan-summary) mirrors the REAL plan (phases→tasks) + * fetched from /api/campaigns once a turn settles. + * Chat is the last resort (the escape pill / "Open conversation"). + * + * No-ops unless #v2-landing is present (flag off → v1 untouched, overlay absent). + */ +const V2Landing = (() => { + let el = null; + let current = null; // the ask currently in #v2-plan-ask + let feedTextEl = null; // current accumulating prose paragraph in the feed + let feedThinkingEl = null; // current accumulating reasoning (thinking) block + let runningTools = {}; // tool name -> stack of running card elements + let feedHadContent = false; // did this turn surface anything in the feed? + let capturedCampaignId = null; // best-effort id scraped from tool results + let planProposed = false; // propose_plan ran → plan is ready to commit + + const $ = (id) => document.getElementById(id); + + function greet() { + const g = $('v2-landing-greeting'); + if (!g) return; + const h = new Date().getHours(); + const t = h < 5 ? 'Still here.' : h < 12 ? 'Good morning.' + : h < 18 ? 'Good afternoon.' : 'Good evening.'; + g.innerHTML = t + '
What are we doing today?'; + } + + function setScreen(name) { if (el) el.dataset.screen = name; } + function planActive() { + return !!el && el.dataset.screen === 'plan' && !el.classList.contains('dismissed') + && el.style.display !== 'none'; + } + + function dismiss() { + if (!el || el.classList.contains('dismissed')) return; + el.classList.add('dismissed'); + let done = false; + const finish = () => { + if (done) return; + done = true; + el.style.display = 'none'; + el.setAttribute('aria-hidden', 'true'); + }; + el.addEventListener('transitionend', finish, { once: true }); + setTimeout(finish, 650); + } + + // ── status / error helpers ──────────────────────────────────────── + function setThinkingLabel(text) { + const l = document.querySelector('#v2-plan-thinking .v2-plan-thinking-label'); + if (l && text) l.textContent = text; + } + // Elapsed-time counter so a long think reads as progress, not a hang. Starts + // when the thinking indicator first shows and runs until it's hidden (turn end). + let _thinkTimer = null; + let _thinkStart = 0; + function _thinkTick() { + const t = $('v2-plan-thinking'); + if (!t) return; + let el = t.querySelector('.v2-plan-elapsed'); + if (!el) { + el = document.createElement('span'); + el.className = 'v2-plan-elapsed'; + el.style.cssText = 'margin-left:6px;opacity:.55;font-variant-numeric:tabular-nums;'; + t.appendChild(el); + } + const s = Math.round((Date.now() - _thinkStart) / 1000); + el.textContent = s > 0 ? s + 's' : ''; + } + function showThinking(on, label) { + const t = $('v2-plan-thinking'); + if (t) t.classList.toggle('hidden', !on); + if (on && label) setThinkingLabel(label); + if (on) { + if (!_thinkTimer) { + _thinkStart = Date.now(); + _thinkTick(); + _thinkTimer = setInterval(_thinkTick, 1000); + } + } else if (_thinkTimer) { + clearInterval(_thinkTimer); + _thinkTimer = null; + const el = t && t.querySelector('.v2-plan-elapsed'); + if (el) el.textContent = ''; + } + } + // Human-readable "what's happening right now" from a tool activity event, + // so the status line names the live operation instead of a static string. + function prettyTool(act) { + const raw = (act && (act.label || act.name)) || 'the next step'; + const s = String(raw).replace(/_/g, ' ').trim(); + return s.charAt(0).toUpperCase() + s.slice(1) + '…'; + } + function errorVisible() { const e = $('v2-plan-error'); return !!e && !e.classList.contains('hidden'); } + function showPlanError(msg) { + const e = $('v2-plan-error'); if (!e) return; + e.textContent = msg; e.classList.remove('hidden'); + showThinking(false); + } + function hidePlanError() { const e = $('v2-plan-error'); if (e) e.classList.add('hidden'); } + + function clearAsk() { const m = $('v2-plan-ask'); if (m) m.innerHTML = ''; } + function resetSummary() { + const list = $('v2-plan-summary'); + if (list) list.innerHTML = '
The plan will take shape here as Gently designs it.
'; + planPage = 0; planPages = []; planTitleText = ''; + } + + // ── activity feed: paginated, ONE agent step (turn) per page ─────── + // Instead of one ever-growing scroll, each agent turn — its reasoning + + // the tool calls it made — is a page you flip through with ‹ Prev / Next ›. + // The current question stays pinned below the feed (#v2-plan-ask). A new + // turn auto-advances to its page; you can flip back to review earlier steps. + let feedPages = []; // .v2-act-page elements, one per turn + let feedPage = 0; // index currently shown + let curPageEl = null; // page receiving this turn's content + let pendingNewPage = false; // a turn started; open a fresh page on first content + + function feedEl() { return $('v2-plan-activity'); } + function feedPagesWrap() { return feedEl()?.querySelector('.v2-feed-pages'); } + function clearActivity() { + const f = feedEl(); + if (f) { + f.innerHTML = + '' + + '
' + + ''; + } + feedPages = []; feedPage = 0; curPageEl = null; pendingNewPage = false; + feedTextEl = null; feedThinkingEl = null; runningTools = {}; feedHadContent = false; + capturedCampaignId = null; planProposed = false; + clearPlanReady(); + hidePlanError(); + } + function newFeedPage() { + const wrap = feedPagesWrap(); if (!wrap) return null; + const page = document.createElement('div'); + page.className = 'v2-act-page'; + wrap.appendChild(page); + feedPages.push(page); + curPageEl = page; + feedPage = feedPages.length - 1; // auto-advance to the live step + feedTextEl = null; + drawFeedPager(); + return page; + } + // Where this turn's prose/tool cards land. Opens a fresh page the first time + // content arrives after a turn_start (so content-less command turns don't + // leave empty pages), and lazily on the very first content. + function feedTarget() { + if (pendingNewPage || !curPageEl) { newFeedPage(); pendingNewPage = false; } + return curPageEl; + } + function viewingLatest() { return feedPage >= feedPages.length - 1; } + function drawFeedPager() { + const f = feedEl(); if (!f) return; + const n = feedPages.length; + const i = Math.min(Math.max(feedPage, 0), Math.max(n - 1, 0)); + feedPages.forEach((p, idx) => p.classList.toggle('active', idx === i)); + const bar = f.querySelector('.v2-feed-pager-bar'); + const dots = f.querySelector('.v2-feed-dots'); + if (!bar || !dots) return; + if (n <= 1) { bar.hidden = true; dots.hidden = true; return; } + bar.hidden = false; dots.hidden = false; + bar.innerHTML = ''; + const mkBtn = (txt, disabled, fn) => { + const b = document.createElement('button'); + b.className = 'v2-plan-pager-btn'; b.type = 'button'; b.textContent = txt; + b.disabled = disabled; b.addEventListener('click', fn); + return b; + }; + const pos = document.createElement('span'); + pos.className = 'v2-plan-pager-pos'; pos.textContent = `Step ${i + 1} of ${n}`; + bar.append( + mkBtn('‹ Prev', i === 0, () => { if (feedPage > 0) { feedPage--; drawFeedPager(); } }), + pos, + mkBtn('Next ›', i === n - 1, () => { if (feedPage < n - 1) { feedPage++; drawFeedPager(); } }), + ); + dots.innerHTML = ''; + for (let d = 0; d < n; d++) { + const dot = document.createElement('button'); + dot.className = 'v2-plan-dot' + (d === i ? ' active' : ''); + dot.type = 'button'; + dot.setAttribute('aria-label', `Step ${d + 1} of ${n}`); + dot.addEventListener('click', () => { feedPage = d; drawFeedPager(); }); + dots.appendChild(dot); + } + } + function scrollFeedIfNearBottom() { + if (!viewingLatest()) return; // don't yank the user off an earlier step + const m = document.querySelector('.v2-screen-plan .v2-plan-main'); + if (m && (m.scrollHeight - m.scrollTop - m.clientHeight) < 140) m.scrollTop = m.scrollHeight; + } + function clearFallback() { feedEl()?.querySelectorAll('.v2-plan-fallback').forEach(n => n.remove()); } + + // Render the agent's prose like the chat does (reuses AgentChat.mdToHtml — + // escapes then renders bold/italic/code/line-breaks), so the feed isn't raw + // markdown. Falls back to escaped text if the helper isn't available. + function renderMd(s) { + if (typeof AgentChat !== 'undefined' && AgentChat.mdToHtml) return AgentChat.mdToHtml(s); + const esc = (typeof escapeHtml === 'function') ? escapeHtml(String(s)) : String(s); + return esc.replace(/\n/g, '
'); + } + + // Plan-writing tools → refresh THE PLAN panel during the turn (debounced), + // not only at turn_end (ask_user_choice pauses the turn before it ends). + const PLAN_TOOLS = new Set([ + 'create_campaign', 'create_plan_item', 'link_plan_items', 'update_plan_item', + 'delete_plan_item', 'propose_plan', 'get_plan_status', 'validate_plan', + ]); + let planRefreshTimer = null; + function schedulePlanRefresh() { + if (planRefreshTimer) clearTimeout(planRefreshTimer); + planRefreshTimer = setTimeout(() => { planRefreshTimer = null; refreshPlanPanel(); }, 600); + } + + function safeStringify(v) { + try { + const s = (typeof v === 'string') ? v : JSON.stringify(v, null, 2); + return s.length > 4000 ? s.slice(0, 4000) + '\n…' : s; + } catch (e) { return String(v); } + } + function fillToolBody(body, act) { + body.innerHTML = ''; + // grid-template-rows reveal (landing.css) needs ONE collapsible child — + // append blocks into a single inner wrapper, not directly onto body. + const inner = document.createElement('div'); + body.appendChild(inner); + const inputStr = (act.input != null) ? safeStringify(act.input) : ''; + const full = act.full || act.summary || ''; + const block = (label, text) => { + const l = document.createElement('div'); l.className = 'v2-act-block-label'; l.textContent = label; + const b = document.createElement('pre'); b.className = 'v2-act-block'; b.textContent = text; + inner.append(l, b); + }; + if (inputStr) block('input', inputStr); + if (full) block('result', full); + if (!inputStr && !full) { + const e = document.createElement('div'); e.className = 'v2-act-block-label'; e.textContent = 'no details'; + inner.append(e); + } + } + function buildToolCard(act, done) { + const card = document.createElement('div'); + card.className = 'v2-act-tool' + (done ? (act.is_error ? ' done err' : ' done') : ''); + const head = document.createElement('button'); + head.className = 'v2-act-tool-head'; head.type = 'button'; + head.setAttribute('aria-expanded', 'false'); + const ic = document.createElement('span'); ic.className = 'v2-act-ic'; + ic.innerHTML = done ? (act.is_error ? '⚠' : '✓') : ''; + const label = document.createElement('span'); label.className = 'v2-act-label'; + label.textContent = act.label || act.name || 'tool'; + const sum = document.createElement('span'); sum.className = 'v2-act-summary'; + sum.textContent = done ? (act.summary || '') : ''; + const chev = document.createElement('span'); chev.className = 'v2-act-chev'; chev.textContent = '›'; + head.append(ic, label, sum, chev); + const body = document.createElement('div'); body.className = 'v2-act-tool-body'; + fillToolBody(body, act); + head.addEventListener('click', () => { + const open = card.classList.toggle('open'); + head.setAttribute('aria-expanded', open ? 'true' : 'false'); + }); + card.append(head, body); + return card; + } + function updateToolCard(card, act) { + card.classList.add('done'); + if (act.is_error) card.classList.add('err'); + const ic = card.querySelector('.v2-act-ic'); if (ic) ic.textContent = act.is_error ? '⚠' : '✓'; + const sum = card.querySelector('.v2-act-summary'); if (sum) sum.textContent = act.summary || ''; + const body = card.querySelector('.v2-act-tool-body'); if (body) fillToolBody(body, act); + } + function captureCampaignId(text) { + if (!text) return; + const s = String(text); + const m = s.match(/campaign_id[=:\s]+([0-9a-f]{6,})/i) || s.match(/\(id:\s*([0-9a-f]{6,})/i); + if (m) capturedCampaignId = m[1]; + } + + function applyActivity(act) { + if (!planActive() || !act) return; + const f = feedEl(); if (!f) return; + switch (act.kind) { + case 'turn_start': + feedTextEl = null; feedThinkingEl = null; pendingNewPage = true; hidePlanError(); clearFallback(); + clearPlanReady(); // new work in flight — drop any "ready" state + showThinking(true, 'reviewing your campaign and plan…'); + break; + case 'thinking': { + // Stream the model's reasoning summary live into the feed as a dim + // block, so the wait shows what the agent is actually considering. + showThinking(true); + const chunk = act.text || ''; + if (!chunk) { setThinkingLabel('thinking through the next step…'); break; } + if (!feedThinkingEl) { + feedThinkingEl = document.createElement('div'); + feedThinkingEl.className = 'v2-act-think'; + feedThinkingEl.style.cssText = + 'font-style:italic;opacity:.7;white-space:pre-wrap;margin:2px 0 8px;font-size:12.5px;line-height:1.5;'; + feedThinkingEl._raw = ''; + feedTarget().appendChild(feedThinkingEl); + } + feedThinkingEl._raw += chunk; + feedThinkingEl.textContent = feedThinkingEl._raw; + feedHadContent = true; + setThinkingLabel('reasoning…'); + scrollFeedIfNearBottom(); + break; + } + case 'text': { + const chunk = act.text || ''; + if (!chunk) break; + // The reasoning that immediately precedes the spoken answer is + // wrap-up meta ("let me wrap this up concisely and offer to + // export…") — drop the block entirely so the feed keeps the + // answer, not the narration of getting there. Reasoning that + // precedes a TOOL is left in place (tool_start only nulls the + // pointer) as the rationale for that action. + if (feedThinkingEl) { feedThinkingEl.remove(); feedThinkingEl = null; } + if (!feedTextEl) { + feedTextEl = document.createElement('div'); + feedTextEl.className = 'v2-act-text'; + feedTextEl._raw = ''; + feedTarget().appendChild(feedTextEl); + } + feedTextEl._raw += chunk; + feedTextEl.innerHTML = renderMd(feedTextEl._raw); + feedHadContent = true; showThinking(true, 'composing the response…'); scrollFeedIfNearBottom(); + break; + } + case 'tool_start': { + // ask_user_choice IS the active question (rendered separately in + // #v2-plan-ask) — don't also show it as a feed card. + if (act.name === 'ask_user_choice') break; + feedTextEl = null; feedThinkingEl = null; + const card = buildToolCard(act, false); + feedTarget().appendChild(card); + (runningTools[act.name] = runningTools[act.name] || []).push(card); + feedHadContent = true; showThinking(true, prettyTool(act)); scrollFeedIfNearBottom(); + break; + } + case 'tool_result': { + captureCampaignId(act.summary); + captureCampaignId(act.full); + if (PLAN_TOOLS.has(act.name)) schedulePlanRefresh(); + if (act.name === 'propose_plan' && !act.is_error) planProposed = true; + if (act.name === 'ask_user_choice') break; + feedTextEl = null; feedThinkingEl = null; + const arr = runningTools[act.name] || []; + const card = arr.pop(); + if (card) updateToolCard(card, act); + else feedTarget().appendChild(buildToolCard(act, true)); + feedHadContent = true; setThinkingLabel('working through the next step…'); scrollFeedIfNearBottom(); + break; + } + case 'turn_end': + showThinking(false); feedTextEl = null; feedThinkingEl = null; + refreshPlanPanel(); + if (!current && !feedHadContent) showFallback(); + // Plan proposed and the agent has settled (no pending question) → + // the design is done. Surface a clear "ready" state instead of + // leaving the user parked on the last wizard step. + if (planProposed && !current) showPlanReady(); + break; + case 'turn_error': + showPlanError(act.error || 'Something went wrong — open the conversation for detail.'); + break; + } + } + + function showFallback() { + const f = feedEl(); if (!f || f.querySelector('.v2-plan-fallback')) return; + const d = document.createElement('div'); + d.className = 'v2-plan-fallback'; + d.innerHTML = 'Gently replied in prose — open the conversation to read it.'; + d.querySelector('a').addEventListener('click', openChat); + feedTarget().appendChild(d); + } + + // ── plan-ready state ─────────────────────────────────────────────── + // Once the agent has proposed the plan and gone quiet, the wizard is done. + // Mark the screen "ready": rename the header, count phases/items from the + // panel, and promote "open workspace" to the obvious primary action — so the + // finish line is signposted instead of looking like one more wizard step. + function planCounts() { + let phases = 0, items = 0; + planPages.forEach(p => { + if (p.name !== 'Tasks') phases++; + items += (p.items || []).length; + }); + return { phases, items }; + } + function showPlanReady() { + const sec = document.querySelector('.v2-screen-plan'); + if (!sec) return; + sec.classList.add('ready'); + showThinking(false); + const who = sec.querySelector('.v2-plan-who'); + const title = sec.querySelector('.v2-plan-title'); + if (who) who.textContent = 'Gently · plan ready'; + if (title) { + const { phases, items } = planCounts(); + title.textContent = items + ? `Your plan is ready — ${items} item${items === 1 ? '' : 's'} across ${phases} phase${phases === 1 ? '' : 's'}` + : 'Your plan is ready'; + } + const cont = $('v2-plan-continue'); + if (cont) cont.textContent = 'Open the workspace ›'; + const exp = $('v2-plan-export'); + if (exp) exp.hidden = false; // the plan is final → offer the download + } + function clearPlanReady() { + const sec = document.querySelector('.v2-screen-plan'); + if (!sec || !sec.classList.contains('ready')) return; + sec.classList.remove('ready'); + const who = sec.querySelector('.v2-plan-who'); + const title = sec.querySelector('.v2-plan-title'); + if (who) who.textContent = 'Gently · planning'; + if (title) title.textContent = "Let's design your run"; + const cont = $('v2-plan-continue'); + if (cont) cont.textContent = 'Continue in workspace ›'; + const exp = $('v2-plan-export'); + if (exp) exp.hidden = true; + } + + // ── export the finished plan as a shareable markdown doc ──────────── + // Replaces the agent's end-of-plan "want me to export this?" prose with a + // real action: pull the enriched plan tree (/export) and render it to + // markdown client-side so the biologist can drop it in a doc or share it. + function specToLines(spec) { + let s = spec; + if (typeof s === 'string') { try { s = JSON.parse(s); } catch { return s ? ['- ' + s] : []; } } + if (!s || typeof s !== 'object') return []; + const out = []; + const fmt = (v) => Array.isArray(v) ? v.join(', ') : (typeof v === 'object' ? JSON.stringify(v) : String(v)); + const pick = (k, label) => { if (s[k] != null && s[k] !== '') out.push(`- **${label}:** ${fmt(s[k])}`); }; + pick('strain', 'Strain'); pick('goal', 'Goal'); + if (Array.isArray(s.channels) && s.channels.length) { + out.push('- **Channels:** ' + s.channels.map(c => `${c.name || '?'} (${c.excitation_nm || '?'} nm${c.exposure_ms ? `, ${c.exposure_ms} ms` : ''})`).join(', ')); + } + pick('num_slices', 'Slices'); pick('interval_s', 'Interval (s)'); pick('temperature_c', 'Temperature (°C)'); + pick('num_embryos', 'Embryos'); pick('start_stage', 'Start stage'); pick('stop_condition', 'Stop condition'); + pick('criteria', 'Criteria'); pick('success_criteria', 'Success criteria'); + return out; + } + function buildPlanMarkdown(tree) { + const L = []; + L.push(`# ${tree.description || tree.shorthand || 'Experimental plan'}`, ''); + if (tree.target) L.push(`**Goal:** ${tree.target}`, ''); + if (tree.shorthand) L.push(`**Plan ID:** \`${tree.shorthand}\``, ''); + L.push(`_Exported from Gently — ${new Date().toLocaleString()}_`, ''); + const renderItems = (items, prefix) => { + (items || []).slice().sort((a, b) => (a.phase_order || 0) - (b.phase_order || 0)).forEach((it, idx) => { + L.push(`### ${prefix}${idx + 1} ${it.title || '(task)'} \`${it.type || 'task'}\``, ''); + if (it.description) L.push(it.description, ''); + const sl = specToLines(it.spec); + if (sl.length) L.push(...sl, ''); + const refs = it.references || []; + if (refs.length) { + L.push('**References:**'); + refs.forEach((r, i) => L.push(`${i + 1}. ${r.citation || r.id || ''}${r.source ? ` _(${r.source})_` : ''}`)); + L.push(''); + } + }); + }; + if ((tree.items || []).length) { L.push('## Tasks', ''); renderItems(tree.items, ''); } + (tree.children || []).forEach((ph, pi) => { + if (!ph) return; + L.push(`## ${ph.display_name || ph.description || ph.shorthand || `Phase ${pi + 1}`}`, ''); + if (ph.target) L.push(ph.target, ''); + renderItems(ph.items, `${pi + 1}.`); + }); + return L.join('\n').replace(/\n{3,}/g, '\n\n').trimEnd() + '\n'; + } + async function resolveCampaignId() { + if (capturedCampaignId) return capturedCampaignId; + try { + const r = await fetch('/api/campaigns'); + if (r.ok) { const d = await r.json(); const t = (d.campaigns || [])[0]; return (t && t.campaign && t.campaign.id) || null; } + } catch (e) { /* offline */ } + return null; + } + async function exportPlan() { + const btn = $('v2-plan-export'); + const id = await resolveCampaignId(); + if (!id) { showPlanError('No plan to export yet.'); return; } + if (btn) { btn.disabled = true; btn.textContent = '↓ Exporting…'; } + try { + const r = await fetch(`/api/campaigns/${encodeURIComponent(id)}/export`); + if (!r.ok) throw new Error(`export ${r.status}`); + const tree = await r.json(); + const md = buildPlanMarkdown(tree); + const blob = new Blob([md], { type: 'text/markdown' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `${(tree.shorthand || 'plan').replace(/[^\w.-]+/g, '_')}.md`; + document.body.appendChild(a); a.click(); a.remove(); + setTimeout(() => URL.revokeObjectURL(url), 1000); + } catch (e) { + showPlanError('Could not export the plan — open the conversation to export it manually.'); + } finally { + if (btn) { btn.disabled = false; btn.textContent = '↓ Export plan'; } + } + } + + // ── THE PLAN panel: mirror the real campaign tree ────────────────── + async function refreshPlanPanel() { + try { + let tree = null; + if (capturedCampaignId) { + const r = await fetch(`/api/campaigns/${encodeURIComponent(capturedCampaignId)}/tree`); + if (r.ok) tree = await r.json(); + } + if (!tree) { + const r = await fetch('/api/campaigns'); + if (r.ok) { const d = await r.json(); tree = (d.campaigns || [])[0] || null; } + } + if (tree) renderPlanTree(tree); + } catch (e) { /* keep whatever is shown */ } + } + function planName(c) { + c = c || {}; + return c.shorthand || c.display_name || c.description || 'Plan'; + } + // Phases read better by their human name ("Phase 1 — Reporter validation") + // than by their code shorthand ("nrp-p1"), which looks like a machine id. + function phaseName(c) { + c = c || {}; + return c.display_name || c.description || c.shorthand || 'Phase'; + } + // THE PLAN renders as a pager — one phase per page with ‹ Prev / Next ›, + // a position label, and dots — instead of one long scroll. planPage is held + // across re-renders (the panel refetches on every plan-writing tool) so the + // page you're reading doesn't snap back to the start mid-design. + let planPage = 0; + let planPages = []; // [{ name, items }] + let planTitleText = ''; + + function renderPlanTree(tree) { + if (!tree) return; + const phases = tree.children || []; + const rootItems = tree.items || []; + if (!phases.length && !rootItems.length) return; // nothing to show yet — keep placeholder + const pages = []; + if (rootItems.length) pages.push({ name: 'Tasks', items: rootItems }); + phases.forEach(phase => { + if (!phase) return; + pages.push({ name: phaseName(phase.campaign), items: phase.items || [] }); + }); + planPages = pages; + planTitleText = planName(tree.campaign); + if (planPage >= pages.length) planPage = pages.length - 1; + if (planPage < 0) planPage = 0; + drawPlanPage(); + } + + function drawPlanPage() { + const list = $('v2-plan-summary'); + if (!list) return; + const pages = planPages; + const n = pages.length; + if (!n) return; + const i = Math.min(Math.max(planPage, 0), n - 1); + const page = pages[i]; + + list.innerHTML = ''; + const title = document.createElement('div'); + title.className = 'v2-plan-title-row'; + title.textContent = planTitleText; + list.appendChild(title); + + if (n > 1) { + const bar = document.createElement('div'); + bar.className = 'v2-plan-pager'; + const prev = document.createElement('button'); + prev.className = 'v2-plan-pager-btn'; prev.type = 'button'; prev.textContent = '‹ Prev'; + prev.disabled = i === 0; + prev.addEventListener('click', () => { if (planPage > 0) { planPage--; drawPlanPage(); } }); + const pos = document.createElement('span'); + pos.className = 'v2-plan-pager-pos'; + pos.textContent = page.name; // position shown by the dots below + pos.title = `${page.name} · ${i + 1} of ${n}`; + const next = document.createElement('button'); + next.className = 'v2-plan-pager-btn'; next.type = 'button'; next.textContent = 'Next ›'; + next.disabled = i === n - 1; + next.addEventListener('click', () => { if (planPage < n - 1) { planPage++; drawPlanPage(); } }); + bar.append(prev, pos, next); + list.appendChild(bar); + } else { + const h = document.createElement('div'); + h.className = 'v2-plan-phase-h'; + h.textContent = page.name; + list.appendChild(h); + } + + const tasks = document.createElement('div'); + tasks.className = 'v2-plan-phase'; + const items = page.items || []; + // phase ordinal (1-based) for "P.I" numbering; the rootItems "Tasks" page + // isn't a phase, so it numbers items bare (1, 2, …). + const phaseOrd = pages.slice(0, i + 1).filter(p => p.name !== 'Tasks').length; + if (items.length) { + items.forEach((it, idx) => { + const type = String(it.type || '').toLowerCase(); + const t = document.createElement('div'); + t.className = 'v2-plan-task type-' + (type || 'other'); + const num = document.createElement('span'); + num.className = 'v2-task-num'; + num.textContent = phaseOrd ? `${phaseOrd}.${idx + 1}` : `${idx + 1}`; + const dot = document.createElement('span'); + dot.className = 'v2-task-dot'; + dot.title = type || 'task'; + const ttl = document.createElement('span'); + ttl.className = 'v2-task-ttl'; + ttl.textContent = it.title || it.shorthand || '(task)'; + t.append(num, dot, ttl); + if (it.estimated_days) { + const d = document.createElement('span'); + d.className = 'v2-task-days'; + d.textContent = `${it.estimated_days}d`; + t.append(d); + } + tasks.appendChild(t); + }); + } else { + const e = document.createElement('div'); + e.className = 'v2-plan-task v2-plan-task-empty'; + e.textContent = 'no items in this phase yet'; + tasks.appendChild(e); + } + list.appendChild(tasks); + + if (n > 1) { + const dots = document.createElement('div'); + dots.className = 'v2-plan-dots'; + for (let d = 0; d < n; d++) { + const dot = document.createElement('button'); + dot.className = 'v2-plan-dot' + (d === i ? ' active' : ''); + dot.type = 'button'; + dot.setAttribute('aria-label', `Go to ${pages[d].name} (${d + 1} of ${n})`); + dot.addEventListener('click', () => { planPage = d; drawPlanPage(); }); + dots.appendChild(dot); + } + list.appendChild(dots); + } + } + + // ── ask rendering (the active question) ──────────────────────────── + function labelFor(data, sel) { + const opts = (data && data.options) || []; + const one = (s) => { + const o = opts.find(o => o && (o.id === s || o.value === s || o.label === s)); + return o ? o.label : String(s); + }; + return Array.isArray(sel) ? sel.map(one).join(', ') : one(sel); + } + function recordPick(data, sel) { + const list = $('v2-plan-summary'); + if (!list) return; + const empty = list.querySelector('.v2-plan-side-empty'); + if (empty) empty.remove(); + const matched = (data && data.options || []).some(o => o && (o.id === sel || o.value === sel || o.label === sel)); + const row = document.createElement('div'); + row.className = 'v2-plan-row' + (matched ? '' : ' v2-plan-row-freetext'); + row.innerHTML = ''; + row.querySelector('.k').textContent = (data && data.question) || 'Choice'; + row.querySelector('.v').textContent = labelFor(data, sel); + list.appendChild(row); + } + function renderAsk() { + const mount = $('v2-plan-ask'); + if (!mount || !current || typeof AgentChat === 'undefined' || !AgentChat.buildAskCard) return; + showThinking(false); hidePlanError(); clearFallback(); + const data = current.data, reqId = current.reqId; + const hasControl = AgentChat.hasControl ? AgentChat.hasControl() : true; + const card = AgentChat.buildAskCard(data, { + reqId, isWake: current.isWake, hasControl, + onPick: (sel) => { + recordPick(data, sel); + AgentChat.answerChoice(reqId, sel); + current = null; clearAsk(); showThinking(true); + }, + }); + mount.innerHTML = ''; + mount.appendChild(card); + const first = mount.querySelector('button:not([disabled])'); + if (first) setTimeout(() => first.focus(), 30); + } + + let planKickedOff = false; // guard: design-kickoff fires once per session + async function startPlan() { + setScreen('plan'); + // Re-entering the wizard (Back → Plan again) must NOT re-fire the + // kickoff — that stacked duplicate "/plan" + design turns. Just show + // the wizard with its existing state. + if (planKickedOff) return; + planKickedOff = true; + resetSummary(); clearAsk(); clearActivity(); + current = null; + showThinking(true); + // Campaigns are persistent agent memory (not session state), so the + // agent always builds on an existing one — which leaves a user wanting a + // fresh plan stuck. So if an active campaign exists, ask up front: + // continue it (the default) or start a brand-new one. With NO campaign + // there's nothing to continue, so skip the gate and design straight away + // (that path is fresh anyway). + let campaign = null; + try { + const r = await fetch('/api/campaigns'); + if (r.ok) { const d = await r.json(); campaign = (d.campaigns || [])[0] || null; } + } catch (e) { /* offline / no API — just design */ } + if (campaign) renderCampaignChoice(campaign); + else kickoffDesign('continue'); + } + + // Enter plan mode, then prompt design. The prompt differs by intent: build + // on the active campaign, or set it aside and create a new one. A free-typed + // answer from the choice card becomes the design brief directly. + function kickoffDesign(mode) { + showThinking(true); + if (typeof AgentChat === 'undefined' || !AgentChat.runCommand) return; + AgentChat.runCommand('/plan'); + if (mode === 'fresh') { + AgentChat.runCommand( + "I want to start a brand-new experiment, not continue any existing " + + "campaign. Create a new campaign and let's design it from scratch — " + + "what should we capture?" + ); + } else if (mode === 'continue') { + AgentChat.runCommand("Let's design this run — what should it capture?"); + } else { + // free text from the choice card's "Something else…" escape + AgentChat.runCommand(String(mode)); + } + } + + // Continue-vs-fresh gate, shown only when an active campaign exists. Reuses + // the agent ask-card styling so it's visually identical to the agent's own + // questions; picking routes into kickoffDesign rather than the agent bridge. + function renderCampaignChoice(tree) { + const mount = $('v2-plan-ask'); + if (!mount || typeof AgentChat === 'undefined' || !AgentChat.buildAskCard) { + kickoffDesign('continue'); + return; + } + showThinking(false); hidePlanError(); clearFallback(); + const name = planName((tree && tree.campaign) || {}); + const data = { + question: `You have an active campaign — **${name}**. Design the next run inside it, or start something new?`, + options: [ + { id: 'continue', label: `Continue ${name}`, description: 'Design the next run inside your existing campaign' }, + { id: 'fresh', label: 'Start a brand-new campaign', description: 'Set the existing plan aside and plan from scratch' }, + ], + }; + const hasControl = AgentChat.hasControl ? AgentChat.hasControl() : true; + const card = AgentChat.buildAskCard(data, { + reqId: 'landing-campaign-choice', isWake: false, hasControl, + onPick: (sel) => { clearAsk(); kickoffDesign(sel); }, + }); + mount.innerHTML = ''; + mount.appendChild(card); + const first = mount.querySelector('button:not([disabled])'); + if (first) setTimeout(() => first.focus(), 30); + } + + function openScope() { + dismiss(); + if (typeof switchTab === 'function') switchTab('devices'); + } + function openChat() { + dismiss(); + if (typeof AgentChat !== 'undefined' && AgentChat.togglePanel) { + setTimeout(() => AgentChat.togglePanel(true), 300); + } + } + function sendFreeform(text) { + const v = (text || '').trim(); + dismiss(); + if (typeof AgentChat !== 'undefined' && AgentChat.togglePanel) { + AgentChat.togglePanel(true); + if (v && AgentChat.runCommand) setTimeout(() => AgentChat.runCommand(v), 300); + } + } + + function init() { + el = $('v2-landing'); + if (!el || typeof ClientEventBus === 'undefined') return; // flag off → no-op + greet(); + + el.querySelectorAll('[data-landing]').forEach(btn => btn.addEventListener('click', () => { + const kind = btn.dataset.landing; + if (kind === 'plan') startPlan(); + else if (kind === 'standalone') openScope(); + })); + + const esc = $('v2-escape'), escToggle = $('v2-escape-toggle'), + escInput = $('v2-escape-input'), escSend = $('v2-escape-send'); + if (escToggle && esc && escInput) { + escToggle.addEventListener('click', () => { + const open = esc.classList.toggle('open'); + escToggle.setAttribute('aria-expanded', open ? 'true' : 'false'); + if (open) setTimeout(() => escInput.focus(), 120); + }); + const submit = () => sendFreeform(escInput.value); + if (escSend) escSend.addEventListener('click', submit); + escInput.addEventListener('keydown', e => { + if (e.key === 'Enter') { e.preventDefault(); submit(); } + else if (e.key === 'Escape') { e.stopPropagation(); esc.classList.remove('open'); escToggle.setAttribute('aria-expanded', 'false'); } + }); + } + + const skip = $('v2-landing-skip'); + if (skip) skip.addEventListener('click', dismiss); + + const back = $('v2-plan-back'); + if (back) back.addEventListener('click', () => setScreen('welcome')); + const planChat = $('v2-plan-chat'); + if (planChat) planChat.addEventListener('click', openChat); + const cont = $('v2-plan-continue'); + if (cont) cont.addEventListener('click', dismiss); + const exp = $('v2-plan-export'); + if (exp) exp.addEventListener('click', exportPlan); + + // The agent's questions + work render in the plan stage while it's active; + // once we've receded into the workspace, AskStage (#ask-stage) takes over. + ClientEventBus.on('AGENT_ASK', ({ request_id, choice_data, origin }) => { + if (!planActive()) return; + current = { reqId: request_id, data: choice_data || {}, isWake: origin === 'wake' }; + renderAsk(); + }); + ClientEventBus.on('ASK_CLEARED', ({ request_id }) => { + if (request_id === '*' || (current && request_id === current.reqId)) { + current = null; clearAsk(); + if (planActive() && !errorVisible()) showThinking(true); + // A question was just answered — the agent's continuation is the + // next step, so open a fresh feed page for it. (A turn stays one + // stream across an ask_user_choice pause, so turn_start alone + // would lump every step of the design into a single page.) + pendingNewPage = true; + } + }); + ClientEventBus.on('AGENT_CONTROL', () => { if (current && planActive()) renderAsk(); }); + ClientEventBus.on('AGENT_ACTIVITY', (act) => applyActivity(act)); + + document.addEventListener('keydown', e => { + if (e.key !== 'Escape' || !el || el.classList.contains('dismissed')) return; + if (el.dataset.screen === 'plan') setScreen('welcome'); // step back, don't bail + else dismiss(); + }); + } + + document.addEventListener('DOMContentLoaded', init); + + return { + dismiss, + show: () => { + if (!el) return; + el.style.display = ''; + el.removeAttribute('aria-hidden'); + el.classList.remove('dismissed'); + setScreen('welcome'); + greet(); + }, + }; +})(); diff --git a/gently/ui/web/static/js/notebook.js b/gently/ui/web/static/js/notebook.js new file mode 100644 index 00000000..1130a31a --- /dev/null +++ b/gently/ui/web/static/js/notebook.js @@ -0,0 +1,196 @@ +/** + * NotebookApp — the LIBRARY "Notebook" tab. + * + * The reading room for the shared lab notebook: a thread rail (the inquiry + * spine) + kind filter, rendering Notes from the read API (/api/notebook). + * Read-only for now; authoring/curation arrive in a later increment. + */ +const NotebookApp = (() => { + let inited = false; + let kindFilter = ''; // '' | observation | finding | question + let threadFilter = ''; // '' = all notes + + const $ = (id) => document.getElementById(id); + + async function fetchJSON(url) { + try { + const r = await fetch(url); + if (!r.ok) return null; + return await r.json(); + } catch (e) { + return null; + } + } + + function kindMeta(kind) { + return ({ + observation: { label: 'Observation', cls: 'nb-k-obs' }, + finding: { label: 'Finding', cls: 'nb-k-find' }, + question: { label: 'Question', cls: 'nb-k-q' }, + })[kind] || { label: kind || 'note', cls: '' }; + } + + async function loadThreads() { + const rail = $('nb-threads'); + if (!rail) return; + const data = await fetchJSON('/api/notebook/threads'); + const threads = (data && data.threads) || []; + rail.innerHTML = ''; + const mk = (id, label, count, active) => { + const b = document.createElement('button'); + b.className = 'nb-thread' + (active ? ' active' : ''); + b.textContent = label + (count != null ? ` ${count}` : ''); + b.addEventListener('click', () => { threadFilter = id; loadThreads(); loadNotes(); }); + return b; + }; + rail.appendChild(mk('', 'All notes', null, threadFilter === '')); + threads.forEach(t => rail.appendChild(mk(t.id, t.id, t.count, threadFilter === t.id))); + } + + function card(n) { + const km = kindMeta(n.kind); + const el = document.createElement('div'); + el.className = 'nb-card'; + + const head = document.createElement('div'); + head.className = 'nb-card-head'; + const badge = document.createElement('span'); + badge.className = 'nb-badge ' + km.cls; + badge.textContent = km.label; + const author = document.createElement('span'); + author.className = 'nb-author'; + author.textContent = n.author || ''; + const status = document.createElement('span'); + status.className = 'nb-status'; + status.textContent = n.status || ''; + head.append(badge, author, status); + + const body = document.createElement('div'); + body.className = 'nb-body-text'; + body.textContent = n.title || n.body || ''; + + el.append(head, body); + + const chips = [] + .concat((n.strains || []).map(s => '🧬 ' + s)) + .concat((n.embryos || []).map(e => '◌ ' + e)) + .concat((n.threads || []).map(t => '# ' + t)); + if (chips.length) { + const row = document.createElement('div'); + row.className = 'nb-chips'; + chips.forEach(text => { + const c = document.createElement('span'); + c.className = 'nb-chip'; + c.textContent = text; + row.appendChild(c); + }); + el.appendChild(row); + } + return el; + } + + async function loadNotes() { + const list = $('nb-notes'); + if (!list) return; + const params = new URLSearchParams(); + if (kindFilter) params.set('kind', kindFilter); + if (threadFilter) params.set('thread', threadFilter); + const qs = params.toString(); + const data = await fetchJSON('/api/notebook/notes' + (qs ? `?${qs}` : '')); + if (!data || data.available === false) { + list.innerHTML = '
Notebook unavailable.
'; + return; + } + const notes = data.notes || []; + if (!notes.length) { + list.innerHTML = + '
No notes yet — the notebook fills as the agent ' + + 'records observations, findings, and open questions.
'; + return; + } + list.innerHTML = ''; + notes.forEach(n => list.appendChild(card(n))); + } + + function setupFilters() { + document.querySelectorAll('#notebook-content [data-nb-kind]').forEach(btn => { + btn.addEventListener('click', () => { + kindFilter = btn.dataset.nbKind; + document.querySelectorAll('#notebook-content [data-nb-kind]') + .forEach(b => b.classList.toggle('active', b === btn)); + loadNotes(); + }); + }); + } + + // ── Ask the notebook ─────────────────────────────────────────────── + function renderAskResult(data) { + const box = $('nb-ask-result'); + if (!box) return; + box.hidden = false; + if (!data || data.available === false) { + box.innerHTML = '
The notebook is unavailable right now.
'; + return; + } + const cov = data.coverage || 'covered'; + const covLabel = { covered: 'Grounded in the notebook', partial: 'Partially covered', not_in_notebook: 'Not in the notebook yet' }[cov] || cov; + const points = (data.points || []).map(p => ` +
  • + ${esc(p.text)} + ${(p.note_ids || []).map(id => `${esc(id)}`).join('')} +
  • `).join(''); + const nexts = (data.suggested_next || []).map(s => `
  • ${esc(s)}
  • `).join(''); + box.innerHTML = + `
    ${esc(covLabel)}
    ` + + `
    ${esc(data.answer || '')}
    ` + + (points ? `
    Why
      ${points}
    ` : '') + + (nexts ? `
    Try next
      ${nexts}
    ` : ''); + } + + async function ask() { + const input = $('nb-ask-input'); + const box = $('nb-ask-result'); + const q = (input && input.value || '').trim(); + if (!q) return; + if (box) { box.hidden = false; box.innerHTML = '
    Thinking over the notebook…
    '; } + const body = { question: q }; + if (threadFilter) body.thread = threadFilter; // ask within the selected thread + try { + const r = await fetch('/api/notebook/ask', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), + }); + renderAskResult(await r.json()); + } catch (e) { + if (box) box.innerHTML = '
    Something went wrong asking the notebook.
    '; + } + } + + function setupAsk() { + const go = $('nb-ask-go'), input = $('nb-ask-input'); + if (go) go.addEventListener('click', ask); + if (input) input.addEventListener('keydown', e => { if (e.key === 'Enter') { e.preventDefault(); ask(); } }); + } + + function esc(s) { + return (typeof escapeHtml === 'function') ? escapeHtml(String(s == null ? '' : s)) + : String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); + } + + function refresh() { loadThreads(); loadNotes(); } + + function init() { + if (inited) { refresh(); return; } + inited = true; + setupFilters(); + setupAsk(); + refresh(); + // Notebook writes ride the CONTEXT_UPDATED event — live-refresh if visible. + if (typeof ClientEventBus !== 'undefined') { + ClientEventBus.on('CONTEXT_UPDATED', () => { + if (typeof state !== 'undefined' && state.tab === 'notebook') refresh(); + }); + } + } + + return { init }; +})(); diff --git a/gently/ui/web/static/js/occupancy3d.js b/gently/ui/web/static/js/occupancy3d.js new file mode 100644 index 00000000..b9cfb7ee --- /dev/null +++ b/gently/ui/web/static/js/occupancy3d.js @@ -0,0 +1,489 @@ +// ══════════════════════════════════════════════════════════════════════ +// 3D Optical Space — live digital-twin of the addressable imaging volume +// +// Renders the acquisition cuboid (the box of voxels being scanned) with the +// live light-sheet plane inside it, plus a Z-neighbourhood reference frame. +// An HTML overlay (mode badge + readouts + a top-down minimap) carries the +// GLOBAL context: where in the addressable XY stage range this cuboid sits, +// and the embryos around it. +// +// Why two representations: the addressable stage XY (~tens of mm), the cuboid +// footprint (~hundreds of µm) and the piezo Z range (~µm) differ by ~100x, so +// a single literal-scale 3D box would draw the cuboid invisibly small. The 3D +// scene therefore stays in one µm scale around the cuboid; the minimap (2D) +// handles the much larger stage extent. Some scales are local by design — see +// FOV_UM / the outer-frame sizing below. +// +// Data: +// DEVICE_STATE_UPDATE → live Piezo.Position (sheet Z), Galvo.A/B, XYStage, +// and the firmware box (minimap extent). +// SCAN_GEOMETRY_UPDATE → cuboid extents, num_slices, pencil/sheet mode. +// EMBRYOS_UPDATE → minimap markers. +// Bootstrap via /api/devices/scan_geometry + /api/embryos/current. +// +// Mirrors the DevicesManager IIFE pattern (devices.js) and reuses the +// Three.js scaffold + drag-orbit from projection-viewer.js. +// ══════════════════════════════════════════════════════════════════════ + +const Occupancy3DManager = (function () { + 'use strict'; + + // --- Tunables / approximations (v1) -------------------------------- + // Camera FOV footprint of the SPIM cuboid in µm. SPIM is 0.1625 µm/px; + // a ~2048px sCMOS ROI ≈ 333 µm. Not currently streamed, so we use a + // constant until SCAN_GEOMETRY_UPDATE carries fov_um. (Documented approx.) + const FOV_UM = 333.0; + const MAX_SLICE_LINES = 30; // cap drawn slice outlines for perf + const COLORS = { + outer: 0x33414d, + cuboid: 0x14b8c4, + cuboidFace: 0x14b8c4, + sheet: 0x39d0ff, + slice: 0x2a6f78, + beam: 0xffd166, + }; + + // --- Module state -------------------------------------------------- + let _initialized = false; + let _scene = null, _camera = null, _renderer = null, _root = null; + let _animationId = null, _resizeObserver = null, _resizeRaf = null, _onLayoutChanged = null; + let _isDragging = false, _prevMouse = { x: 0, y: 0 }; + const _rot = { x: -0.6, y: 0.6 }; + let _zoom = 1.7; + + // Live data caches + let _geom = null; // last SCAN_GEOMETRY_UPDATE.data + let _firmwareBox = null; // {x:[min,max], y:[min,max]} µm + let _stage = { x: null, y: null }; + let _piezoZ = null; // live axial position (µm) + let _galvo = { a: null, b: null }; + let _embryos = []; // [{x,y,role,id}] + let _scaler = null; + + // Scene object handles (rebuilt as geometry changes) + let _outerBox = null, _cuboid = null, _cuboidEdges = null; + let _sheet = null, _beam = null, _sliceGroup = null; + + // DOM + let _container = null, _modeEl = null, _readoutsEl = null, _minimapEl = null, _demoBtn = null; + let _demoTimer = null; + + // =================================================================== + // Init / scene scaffold + // =================================================================== + function init() { + if (_initialized) { _resize(); return; } + if (typeof THREE === 'undefined') { + console.warn('[occupancy3d] THREE not loaded'); + return; + } + _container = document.getElementById('occ3d-container'); + _modeEl = document.getElementById('occ3d-mode'); + _readoutsEl = document.getElementById('occ3d-readouts'); + _minimapEl = document.getElementById('occ3d-minimap'); + _demoBtn = document.getElementById('occ3d-demo-btn'); + if (!_container) return; + + _buildScene(); + _wireInteraction(); + if (_demoBtn) _demoBtn.addEventListener('click', toggleDemo); + + // Subscribe to live data (mirror devices.js:1553-1559) + if (typeof ClientEventBus !== 'undefined') { + ClientEventBus.on('DEVICE_STATE_UPDATE', handleDeviceState); + ClientEventBus.on('SCAN_GEOMETRY_UPDATE', handleScanGeometry); + ClientEventBus.on('EMBRYOS_UPDATE', handleEmbryos); + } + _bootstrap(); + + _initialized = true; + _rebuildSceneObjects(); + _renderReadouts(); + _renderMinimap(); + _animate(); + // Container is 0×0 while the tab is hidden; size once it's visible. + requestAnimationFrame(_resize); + } + + function _buildScene() { + const w = _container.clientWidth || 600; + const h = _container.clientHeight || 460; + + _scene = new THREE.Scene(); + _camera = new THREE.PerspectiveCamera(45, w / h, 0.01, 100); + _camera.position.set(0, 0, _zoom); + + _renderer = new THREE.WebGLRenderer({ antialias: true }); + _renderer.setSize(w, h); + _renderer.setClearColor(0x0a0e12); + _container.innerHTML = ''; + _container.appendChild(_renderer.domElement); + + _root = new THREE.Group(); + _root.rotation.x = _rot.x; + _root.rotation.y = _rot.y; + _scene.add(_root); + + // Keep the canvas in sync with its container (chat dock / window resize). + if (_resizeObserver) _resizeObserver.disconnect(); + _resizeObserver = new ResizeObserver(() => { + if (_resizeRaf) cancelAnimationFrame(_resizeRaf); + _resizeRaf = requestAnimationFrame(_resize); + }); + _resizeObserver.observe(_container); + if (!_onLayoutChanged) { + _onLayoutChanged = () => _resize(); + window.addEventListener('gently:layout-changed', _onLayoutChanged); + } + } + + function _resize() { + if (!_renderer || !_container) return; + const w = _container.clientWidth || 600; + const h = _container.clientHeight || 460; + if (w === 0 || h === 0) return; + _camera.aspect = w / h; + _camera.updateProjectionMatrix(); + _renderer.setSize(w, h); + } + + function _wireInteraction() { + const el = _renderer.domElement; + el.addEventListener('mousedown', (e) => { + _isDragging = true; _prevMouse = { x: e.clientX, y: e.clientY }; + }); + el.addEventListener('mousemove', (e) => { + if (!_isDragging) return; + _root.rotation.y += (e.clientX - _prevMouse.x) * 0.01; + _root.rotation.x += (e.clientY - _prevMouse.y) * 0.01; + _rot.x = _root.rotation.x; _rot.y = _root.rotation.y; + _prevMouse = { x: e.clientX, y: e.clientY }; + }); + window.addEventListener('mouseup', () => { _isDragging = false; }); + el.addEventListener('wheel', (e) => { + e.preventDefault(); + _zoom = Math.max(0.4, Math.min(6, _zoom + e.deltaY * 0.002)); + _camera.position.z = _zoom; + }, { passive: false }); + el.addEventListener('dblclick', () => { + _rot.x = -0.6; _rot.y = 0.6; _zoom = 1.7; + _root.rotation.x = _rot.x; _root.rotation.y = _rot.y; + _camera.position.z = _zoom; + }); + } + + function _animate() { + _animationId = requestAnimationFrame(_animate); + if (_renderer && _scene && _camera) _renderer.render(_scene, _camera); + } + + // =================================================================== + // Scene geometry (rebuilt when scan geometry changes) + // =================================================================== + function _disposeObj(obj) { + if (!obj) return; + _root.remove(obj); + obj.traverse?.((c) => { + c.geometry?.dispose?.(); + if (c.material) (Array.isArray(c.material) ? c.material : [c.material]).forEach(m => m.dispose()); + }); + obj.geometry?.dispose?.(); + if (obj.material) (Array.isArray(obj.material) ? obj.material : [obj.material]).forEach(m => m.dispose()); + } + + function _currentGeom() { + // Fall back to nominal defaults so the scene is never empty. + const g = _geom || {}; + const scan = g.scan || {}; + const derived = g.derived || {}; + const piezoCenter = scan.piezo_center_um != null ? scan.piezo_center_um : 50.0; + const zExtent = derived.z_extent_um != null ? derived.z_extent_um : 50.0; + return { + numSlices: scan.num_slices != null ? scan.num_slices : 50, + piezoCenter, + zExtent, + mode: g.mode || 'sheet', + }; + } + + function _rebuildSceneObjects() { + if (!_root) return; + [_outerBox, _cuboid, _cuboidEdges, _sheet, _beam, _sliceGroup].forEach(_disposeObj); + _outerBox = _cuboid = _cuboidEdges = _sheet = _beam = _sliceGroup = null; + + const g = _currentGeom(); + const fov = FOV_UM; + // Outer Z neighbourhood centred on the cuboid so it's always framed. + const halfZ = Math.max(g.zExtent * 2.5, 75); + const zMin = g.piezoCenter - halfZ; + const zMax = g.piezoCenter + halfZ; + const halfXY = fov * 1.5; + + _scaler = makeSceneScaler({ + xRange: [-halfXY, halfXY], + yRange: [-halfXY, halfXY], + zRange: [zMin, zMax], + }); + const L = (um) => _scaler.scaleLen(um); + const Z = (um) => _scaler.toScene(um, 'z'); + + // --- Outer reference frame (addressable Z × local XY) ---------- + _outerBox = new THREE.LineSegments( + new THREE.EdgesGeometry(new THREE.BoxGeometry(L(2 * halfXY), L(zMax - zMin), L(2 * halfXY))), + new THREE.LineBasicMaterial({ color: COLORS.outer }) + ); + _outerBox.position.y = Z(g.piezoCenter); // box centred on its own midpoint == piezoCenter + _root.add(_outerBox); + + // --- Acquisition cuboid (footprint × z-extent) ----------------- + // Three.js Y is our axial (Z µm) axis; X/Z are the lateral footprint. + const cw = L(fov), cd = L(fov), ch = L(g.zExtent); + _cuboid = new THREE.Mesh( + new THREE.BoxGeometry(cw, ch, cd), + new THREE.MeshBasicMaterial({ + color: COLORS.cuboidFace, transparent: true, opacity: 0.06, + depthWrite: false, side: THREE.DoubleSide, + }) + ); + _cuboid.position.y = Z(g.piezoCenter); + _root.add(_cuboid); + _cuboidEdges = new THREE.LineSegments( + new THREE.EdgesGeometry(new THREE.BoxGeometry(cw, ch, cd)), + new THREE.LineBasicMaterial({ color: COLORS.cuboid }) + ); + _cuboidEdges.position.y = Z(g.piezoCenter); + _root.add(_cuboidEdges); + + // --- Slice planes (faint outlines through the cuboid) ---------- + _sliceGroup = new THREE.Group(); + const n = Math.max(1, Math.min(g.numSlices, MAX_SLICE_LINES)); + const sliceMat = new THREE.LineBasicMaterial({ color: COLORS.slice, transparent: true, opacity: 0.5 }); + for (let i = 0; i < n; i++) { + const frac = n === 1 ? 0.5 : i / (n - 1); + const zUm = (g.piezoCenter - g.zExtent / 2) + frac * g.zExtent; + const ring = new THREE.LineLoop(_rectXZ(cw, cd), sliceMat); + ring.position.y = Z(zUm); + _sliceGroup.add(ring); + } + _root.add(_sliceGroup); + + // --- Light sheet / pencil beam --------------------------------- + if (g.mode === 'pencil') { + // Pencil: a thin beam along the lateral axis through cuboid centre. + _beam = new THREE.LineSegments( + new THREE.BufferGeometry().setFromPoints([ + new THREE.Vector3(-cw / 2, 0, 0), new THREE.Vector3(cw / 2, 0, 0), + ]), + new THREE.LineBasicMaterial({ color: COLORS.beam }) + ); + _root.add(_beam); + } else { + _sheet = new THREE.Mesh( + new THREE.PlaneGeometry(cw, cd), + new THREE.MeshBasicMaterial({ + color: COLORS.sheet, transparent: true, opacity: 0.35, + side: THREE.DoubleSide, depthWrite: false, + }) + ); + _sheet.rotation.x = -Math.PI / 2; // lie in the lateral (X-Z) plane + _root.add(_sheet); + } + _updateSheetPosition(); + } + + // A rectangle outline in the lateral (X-Z) plane, centred at origin. + function _rectXZ(w, d) { + const hw = w / 2, hd = d / 2; + return new THREE.BufferGeometry().setFromPoints([ + new THREE.Vector3(-hw, 0, -hd), new THREE.Vector3(hw, 0, -hd), + new THREE.Vector3(hw, 0, hd), new THREE.Vector3(-hw, 0, hd), + ]); + } + + // Move the sheet/beam to the live axial position (piezo µm), clamped to + // the cuboid extent. Falls back to the cuboid centre when no live value. + function _updateSheetPosition() { + if (!_scaler) return; + const g = _currentGeom(); + const zMin = g.piezoCenter - g.zExtent / 2; + const zMax = g.piezoCenter + g.zExtent / 2; + let zUm = _piezoZ != null ? _piezoZ : g.piezoCenter; + zUm = Math.max(zMin, Math.min(zMax, zUm)); + const y = _scaler.toScene(zUm, 'z'); + if (_sheet) _sheet.position.y = y; + if (_beam) _beam.position.y = y; + } + + // =================================================================== + // Event handlers + // =================================================================== + function handleDeviceState(payload) { + if (!payload) return; + const pos = payload.positions || {}; + for (const name of Object.keys(pos)) { + const e = pos[name] || {}; + if (e.kind === 'xy_stage') { + if (e.X != null) _stage.x = e.X; + if (e.Y != null) _stage.y = e.Y; + } else if (e.kind === 'piezo') { + if (e.Position != null) _piezoZ = e.Position; + } else if (e.kind === 'galvo') { + if (e.A != null) _galvo.a = e.A; + if (e.B != null) _galvo.b = e.B; + } + } + const box = extractFirmwareBox(payload.properties); + if (box) _firmwareBox = box; + _updateSheetPosition(); + _renderReadouts(); + _renderMinimap(); + } + + function handleScanGeometry(payload) { + if (!payload) return; + _geom = payload; + if (payload.stage_position_um) { + if (payload.stage_position_um.x != null) _stage.x = payload.stage_position_um.x; + if (payload.stage_position_um.y != null) _stage.y = payload.stage_position_um.y; + } + _rebuildSceneObjects(); + _renderReadouts(); + _renderMinimap(); + } + + function handleEmbryos(payload) { + if (!payload || !Array.isArray(payload.embryos)) return; + _embryos = payload.embryos.map((e) => { + const fine = e.position_fine || {}; + const coarse = e.position_coarse || {}; + const x = fine.x != null ? fine.x : coarse.x; + const y = fine.y != null ? fine.y : coarse.y; + return { x, y, role: e.role, id: e.id }; + }).filter((e) => e.x != null && e.y != null); + _renderMinimap(); + } + + async function _bootstrap() { + try { + const r = await fetch('/api/devices/scan_geometry'); + if (r.ok) handleScanGeometry(await r.json()); + } catch (_) { /* offline — demo button covers it */ } + try { + const r = await fetch('/api/embryos/current'); + if (r.ok) handleEmbryos(await r.json()); + } catch (_) { /* ignore */ } + } + + // =================================================================== + // HTML overlay: mode badge, readouts, minimap + // =================================================================== + function _fmt(v, digits = 1, unit = '') { + return v == null ? '—' : (Number(v).toFixed(digits) + unit); + } + + function _renderReadouts() { + const g = _currentGeom(); + if (_modeEl) { + _modeEl.textContent = g.mode === 'pencil' ? 'PENCIL' : 'SHEET'; + _modeEl.classList.toggle('is-pencil', g.mode === 'pencil'); + } + if (!_readoutsEl) return; + const scan = (_geom && _geom.scan) || {}; + const derived = (_geom && _geom.derived) || {}; + const rows = [ + ['stage X', _fmt(_stage.x, 0, ' µm')], + ['stage Y', _fmt(_stage.y, 0, ' µm')], + ['piezo Z', _fmt(_piezoZ, 1, ' µm')], + ['galvo A/B', `${_fmt(_galvo.a, 3)} / ${_fmt(_galvo.b, 3)}°`], + ['slices', scan.num_slices != null ? String(scan.num_slices) : '—'], + ['Z extent', _fmt(derived.z_extent_um, 1, ' µm')], + ['slice step', _fmt(derived.slice_spacing_um, 3, ' µm')], + ]; + _readoutsEl.innerHTML = rows + .map(([k, v]) => `
    ${k}${escapeHtml(v)}
    `) + .join(''); + } + + function _renderMinimap() { + if (!_minimapEl) return; + const VB = { w: 200, h: 120, pad: 8 }; + const box = _firmwareBox || { x: [-25000, 25000], y: [-12000, 12000] }; + const bw = box.x[1] - box.x[0], bh = box.y[1] - box.y[0]; + if (!(bw > 0 && bh > 0)) return; + const sx = (VB.w - 2 * VB.pad) / bw; + const sy = (VB.h - 2 * VB.pad) / bh; + const s = Math.min(sx, sy); + const ox = VB.pad + (VB.w - 2 * VB.pad - bw * s) / 2; + const oy = VB.pad + (VB.h - 2 * VB.pad - bh * s) / 2; + const px = (x) => ox + (x - box.x[0]) * s; + const py = (y) => oy + (box.y[1] - y) * s; // flip Y for screen + + const parts = []; + parts.push(``); + for (const e of _embryos) { + parts.push(``); + } + if (_stage.x != null && _stage.y != null) { + const fovPx = FOV_UM * s; + parts.push(``); + parts.push(``); + } + _minimapEl.innerHTML = parts.join(''); + } + + // =================================================================== + // Demo driver — develop without live hardware (launch_gently.py --offline) + // =================================================================== + function toggleDemo() { + if (_demoTimer) { + clearInterval(_demoTimer); _demoTimer = null; + if (_demoBtn) _demoBtn.classList.remove('is-on'); + return; + } + if (_demoBtn) _demoBtn.classList.add('is-on'); + // Seed a firmware box, a scan geometry, and a few embryos. + _firmwareBox = { x: [-25000, 25000], y: [-12000, 12000] }; + handleScanGeometry({ + embryo_id: 'demo_2', + stage_position_um: { x: 4200, y: -1800 }, + scan: { + num_slices: 60, exposure_ms: 5.0, + galvo_amplitude_deg: 0.5, galvo_center_deg: 0.0, + piezo_amplitude_um: 25.0, piezo_center_um: 50.0, + }, + derived: { z_extent_um: 50.0, slice_spacing_um: 50 / 59, z_min_um: 25, z_max_um: 75 }, + mode: 'sheet', ts: 0, + }); + handleEmbryos({ + embryos: [ + { id: 'demo_1', role: 'test', position_coarse: { x: 4200, y: -1800 } }, + { id: 'demo_2', role: 'control', position_coarse: { x: -8000, y: 5200 } }, + { id: 'demo_3', role: 'test', position_coarse: { x: 12000, y: 2400 } }, + ], + }); + // Sweep the sheet in Z to animate the plane. + let t = 0; + _demoTimer = setInterval(() => { + t += 0.08; + const g = _currentGeom(); + _piezoZ = g.piezoCenter + (g.zExtent / 2) * Math.sin(t); + _galvo.a = 0.5 * Math.sin(t); + _updateSheetPosition(); + _renderReadouts(); + }, 60); + } + + function cleanup() { + if (_animationId) cancelAnimationFrame(_animationId); + if (_demoTimer) { clearInterval(_demoTimer); _demoTimer = null; } + if (_resizeObserver) _resizeObserver.disconnect(); + if (_renderer) { _renderer.dispose(); } + } + + return { init, cleanup, toggleDemo, handleDeviceState, handleScanGeometry, handleEmbryos }; +})(); + +document.addEventListener('DOMContentLoaded', () => { + // Build lazily on first tab activation (container is 0×0 while hidden), + // so init() is invoked from app.js switchTab(), not here. +}); diff --git a/gently/ui/web/static/js/operate.js b/gently/ui/web/static/js/operate.js new file mode 100644 index 00000000..527e5fe1 --- /dev/null +++ b/gently/ui/web/static/js/operate.js @@ -0,0 +1,931 @@ +/** + * Operate view — "The Operator Spine". + * + * One guided surface for the bottom-cam → SPIM workflow. A single state driver + * (`renderStep`) reads the selected embryo + its progress and sets EVERYTHING in + * lockstep: the header stepper ("you are here"), the always-on safety status + * strip, the left worklist + dish mini-map, the one live viewport (its camera + * source + on-image overlays), and which single control group the right rail + * shows. Exactly one camera and one step are live at a time. + * + * Phase A (no embryo selected): A1 focus bottom objective → A2 mark all (one + * frozen FOV, positions only) → Confirm into the canonical list. + * Phase B (per selected embryo): B1 center → B2 lower SPIM head (fenced, floor) → + * B3 focus SPIM (LED) → B4 acquire → B5 retract & advance. + * + * Safety: manual fenced nudges only (no autofocus); F-drive floor honored and + * down-nudges auto-grey near it; XY centering blocked while the head is lowered; + * 'focused' is earned at B3 (never on a stray F-drive nudge); LED is force-closed + * on step-leave and view-leave. + */ +const OperateManager = (function () { + const BASE_UM_PER_PX = 6.5 / 10.0; // pixel_size_um / objective_mag + const MARK_HIT_PX = 14; + const SVG_NS = 'http://www.w3.org/2000/svg'; + const ROLE_NEUTRAL = '#8a8f98'; + const STATE_RANK = { marked: 0, centered: 1, lowering: 2, focused: 3, calibrated: 4, imaged: 5 }; + + let _wired = false, _active = false; + + // workflow state + let _selected = null; // embryo id, or null = Phase A (survey) + let _step = null; // explicit B-step for the selected embryo + let _marking = false; + const _states = {}; // id -> marked|centered|lowering|focused|imaged + let _embryos = []; // canonical EMBRYOS_UPDATE list + let _headLowered = false; // global: is the SPIM head below safe travel? + let _acquiring = false; + + // viewport / streams + let _camOn = false, _spimOn = false, _camStarting = false; + let _lastFrame = null; // last live bottom-cam payload + let _lastSpimFrame = null; + let _bzScore = null, _spimScore = null; + + // marking + let _frozenSrc = null, _frozenFrame = null, _captureStage = [0, 0], _markers = []; + + // device telemetry + let _lastXY = null; // {X,Y} + let _fdPos = null, _fdFloor = null; + let _galvo = 0.0, _piezo = 50.0, _ledOn = false; + + // Phase C "Run": _runState null = not in Run; 'choose' = chooser; 'running' = live. + // Every Run mode emits one tactic scoped to the marked set. _roles maps each + // embryo to a role ('test'=subject default, 'calibration'=reference). + let _runState = null; + let _runMode = 'adaptive'; + const _roles = {}; + let _runMeta = null; // summary of the active run (for the run-spine) + let _runPlan = null; // operation plan {tactics:[]} for the run-spine + let _runPaused = false; + let _selectedLib = null, _selectedPlan = null; + + // DOM (cached on wire) + const D = {}; + + function $(id) { return document.getElementById(id); } + function toast(m) { if (typeof showGentlyToast === 'function') showGentlyToast(m); } + + async function postJSON(url, body) { + const res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body || {}) }); + if (!res.ok) { const t = await res.text().catch(() => ''); const e = new Error(`${res.status} ${t}`); e.status = res.status; throw e; } + return res.json().catch(() => ({})); + } + async function getJSON(url) { const r = await fetch(url); if (!r.ok) throw new Error(String(r.status)); return r.json(); } + + function cacheDom() { + [ + 'op-stepper', 'op-loop-emb', 'op-st-head', 'op-st-floor', 'op-st-led', 'op-st-led-wrap', + 'op-st-laser', 'op-st-laser-wrap', 'op-st-cam', 'op-minimap', 'op-board', 'op-board-count', + 'op-survey-btn', 'op-badge', 'op-cam-stage', 'op-cam-img', 'op-mark-canvas', 'op-cam-ph', + 'op-rail', 'op-rail-head', 'op-cam-toggle', 'op-bz-pos', 'op-bz-score', 'op-bz-nudge', + 'op-tomark', 'op-detect', 'op-confirm', 'op-mark-count', 'op-clear', 'op-center', 'op-center-hint', + 'op-fd-pos', 'op-fd-floor', 'op-fd-nudge', 'op-fd-d100', 'op-fd-d10', 'op-tofocus', + 'op-spim-toggle', 'op-led', 'op-gv', 'op-pz', 'op-spim-score', 'op-infocus', + 'op-calibrate', 'op-cal-result', 'op-cal-skip', 'op-acquire', 'op-retract', + 'op-rolechips', 'op-modes', 'op-panel-adaptive', 'op-panel-library', 'op-panel-plan', 'op-panel-agent', + 'op-tl-interval', 'op-tl-stop', 'op-tl-condval', 'op-tl-monitor', 'op-lib-list', 'op-plan-list', + 'op-run-start', 'op-runspine', 'op-run-pause', 'op-run-stop', 'op-run-open', + ].forEach(id => { D[id] = $(id); }); + } + + // ── step model ─────────────────────────────────────────────────────── + function stepForState(st) { + return { marked: 'b1', centered: 'b2', lowering: 'b2', focused: 'bc', calibrated: 'b4', imaged: 'b5' }[st] || 'b1'; + } + function effectiveStep() { + if (_marking) return 'a2'; + if (_runState === 'running') return 'running'; + if (_runState === 'choose') return 'c0'; + if (_selected) return _step || 'b1'; + return 'a1'; + } + function cameraForStep(step) { + if (step === 'a1' || step === 'a2' || step === 'b1') return 'bottom'; + if (step === 'b2' || step === 'b3' || step === 'bc' || step === 'b4' || step === 'b5') return 'spim'; + return 'none'; // c0 / running are non-camera (the dish map carries context) + } + + const RAIL_HEADS = { + a1: 'Focus the bottom objective', a2: 'Mark all embryos', + b1: 'Center the embryo', b2: 'Lower the SPIM head', b3: 'Focus the SPIM objective', + bc: 'Calibrate piezo-galvo', b4: 'Acquire the volume', b5: 'Retract & advance', + c0: 'Run — choose how to image', running: 'Run — live', + }; + const STEP_NODE = { a1: 'a1', a2: 'a2', b1: 'b1', b2: 'b2', b3: 'b3', bc: 'bc', b4: 'b4', b5: 'b4', c0: 'run', running: 'run' }; + + function setStep(s) { _step = s; renderStep(); } + + // The single driver — every state change routes through here. + function renderStep() { + const step = effectiveStep(); + const cam = cameraForStep(step); + + // rail group + head + if (D['op-rail']) D['op-rail'].dataset.active = step; + if (D['op-rail-head']) { + const emb = _embryos.find(e => e.id === _selected); + D['op-rail-head'].textContent = emb + ? `Embryo ${labelFor(emb)} · ${RAIL_HEADS[step]}` + : RAIL_HEADS[step]; + } + + // stepper nodes (done/active/locked) driven by selected embryo's state + const st = _selected ? (_states[_selected] || 'marked') : null; + const rank = st ? STATE_RANK[st] : -1; + const order = { b1: 0, b2: 1, b3: 2, bc: 3, b4: 4 }; + D['op-stepper'].querySelectorAll('.op-node').forEach(n => { + const node = n.dataset.node; + n.classList.remove('is-active', 'is-done', 'is-locked'); + const active = STEP_NODE[step] === node; + if (node === 'a1' || node === 'a2') { + if (active) n.classList.add('is-active'); + else if (_embryos.length) n.classList.add('is-done'); + } else if (node === 'run') { + if (active) n.classList.add('is-active'); + else if (_embryos.length && _selected) n.classList.add('is-done'); + else if (!_embryos.length) n.classList.add('is-locked'); + } else { + const oi = order[node]; + if (active) n.classList.add('is-active'); + else if (rank > oi) n.classList.add('is-done'); + else if (!_selected || rank < oi) n.classList.add('is-locked'); + } + }); + if (D['op-loop-emb']) { + const emb = _embryos.find(e => e.id === _selected); + const idx = emb ? _embryos.indexOf(emb) + 1 : 0; + D['op-loop-emb'].textContent = emb ? ` · ${idx}/${_embryos.length}` : ''; + } + + // viewport: badge + stop the inactive camera + if (D['op-badge']) { + D['op-badge'].textContent = cam === 'none' + ? RAIL_HEADS[step] + : (cam === 'bottom' ? 'Bottom cam' : 'SPIM · side A') + ' — ' + RAIL_HEADS[step].toLowerCase(); + } + ensureInactiveCameraStopped(cam); + // B1 (Center) needs a live bottom view but has no Start button (the + // chooser stops all cameras) — auto-start it so centering has feedback. + if (step === 'b1' && !_camOn && !_camStarting && !_marking) { + _camStarting = true; + fetch('/api/devices/bottom_camera/stream/start', { method: 'POST' }) + .then(r => r.json()).then(d => { _camStarting = false; applyCam(!!d.streaming); }) + .catch(() => { _camStarting = false; }); + } + // show the right frame + if (cam === 'bottom' && !_marking && _lastFrame) setImg(_lastFrame); + else if (cam === 'spim' && _lastSpimFrame) setImg(_lastSpimFrame); + else if (cam === 'spim' && !_spimOn) { D['op-cam-img'].classList.remove('has-frame'); D['op-cam-ph'].style.display = ''; D['op-cam-ph'].textContent = 'SPIM view off'; } + else if (cam === 'bottom' && !_camOn && !_marking) { D['op-cam-img'].classList.remove('has-frame'); D['op-cam-ph'].style.display = ''; D['op-cam-ph'].textContent = 'Camera off'; } + else if (cam === 'none') { + D['op-cam-img'].classList.remove('has-frame'); D['op-cam-ph'].style.display = ''; + D['op-cam-ph'].textContent = step === 'running' + ? `Run live — ${_embryos.length} embryo${_embryos.length === 1 ? '' : 's'} (see the dish map)` + : `${_embryos.length} embryo${_embryos.length === 1 ? '' : 's'} marked — choose how to image`; + } + + // gating + if (D['op-center']) { + if (_headLowered) { D['op-center'].textContent = 'Retract head first'; D['op-center'].disabled = true; } + else { D['op-center'].textContent = 'Center stage on embryo'; D['op-center'].disabled = false; } + } + gateFdriveNudges(); + + drawOverlay(step); + renderStatus(); + renderBoard(); + renderMiniMap(); + if (step === 'c0') renderChooser(); + else if (step === 'running') renderRunSpine(); + } + + function ensureInactiveCameraStopped(cam) { + if (cam !== 'bottom' && _camOn) { fetch('/api/devices/bottom_camera/stream/stop', { method: 'POST' }).catch(() => {}); applyCam(false); } + if (cam !== 'spim' && _spimOn) { fetch('/api/devices/lightsheet/live/stop', { method: 'POST' }).catch(() => {}); _spimOn = false; D['op-spim-toggle'].textContent = 'Start view'; D['op-spim-toggle'].classList.remove('op-btn-on'); } + } + function setImg(p) { + if (!p || !p.jpeg_b64) return; + D['op-cam-img'].src = `data:${p.mime || 'image/jpeg'};base64,${p.jpeg_b64}`; + if (!D['op-cam-img'].classList.contains('has-frame')) { D['op-cam-img'].classList.add('has-frame'); D['op-cam-ph'].style.display = 'none'; } + } + + // ── status strip ────────────────────────────────────────────────────── + function renderStatus() { + D['op-st-head'].textContent = _headLowered ? '▼ lowered' : '▲ up'; + D['op-st-head'].parentElement.classList.toggle('is-down', _headLowered); + D['op-st-floor'].textContent = _fdFloor != null ? `${Math.round(_fdFloor)} µm` : '—'; + D['op-st-led'].textContent = _ledOn ? 'EMITTING' : 'OFF'; + D['op-st-led-wrap'].classList.toggle('is-emitting', _ledOn); + D['op-st-laser'].textContent = _acquiring ? 'EMITTING' : 'OFF'; + D['op-st-laser-wrap'].classList.toggle('is-emitting', _acquiring); + const cam = cameraForStep(effectiveStep()); + const on = cam === 'bottom' ? _camOn : _spimOn; + D['op-st-cam'].textContent = `${cam === 'bottom' ? 'bottom' : 'SPIM'} ${on ? '● live' : '○ off'}`; + } + + // ── left spine: worklist + mini-map ──────────────────────────────────── + function resolveXY(emb) { + const f = emb && emb.position_fine; + if (f && Number.isFinite(f.x) && Number.isFinite(f.y)) return { x: f.x, y: f.y }; + const c = emb && emb.position_coarse; + if (c && Number.isFinite(c.x) && Number.isFinite(c.y)) return { x: c.x, y: c.y }; + return null; + } + function labelFor(emb) { const m = emb && emb.id && String(emb.id).match(/(\d+)/); return m ? m[1] : '?'; } + + function renderBoard() { + if (!D['op-board']) return; + const imaged = _embryos.filter(e => _states[e.id] === 'imaged').length; + D['op-board-count'].textContent = `${imaged} / ${_embryos.length} imaged`; + D['op-board'].innerHTML = ''; + if (!_embryos.length) { const e = document.createElement('div'); e.className = 'op-empty'; e.textContent = 'No embryos yet'; D['op-board'].appendChild(e); return; } + _embryos.forEach(emb => { + const st = _states[emb.id] || 'marked'; + const rank = STATE_RANK[st]; + const xy = resolveXY(emb); + const row = document.createElement('div'); + row.className = 'op-brow' + (emb.id === _selected ? ' is-sel' : ''); + row.addEventListener('click', () => selectEmbryo(emb.id)); + + const dot = document.createElement('span'); + dot.className = 'op-bdot'; dot.textContent = labelFor(emb); + if (st === 'imaged') dot.style.background = 'var(--accent-green)'; + row.appendChild(dot); + + const meta = document.createElement('span'); meta.className = 'op-bmeta'; + const lab = document.createElement('span'); lab.className = 'op-blabel'; + lab.textContent = xy ? `(${xy.x.toFixed(0)}, ${xy.y.toFixed(0)})` : `embryo ${labelFor(emb)}`; + meta.appendChild(lab); + const track = document.createElement('span'); track.className = 'op-track'; + ['centered', 'lowering', 'focused', 'imaged'].forEach((k, i) => { + const t = document.createElement('span'); t.className = 'op-tnode'; + if (rank >= STATE_RANK[k]) t.classList.add('on-' + k); + track.appendChild(t); + }); + meta.appendChild(track); + row.appendChild(meta); + + const sc = document.createElement('span'); sc.className = 'op-bstate'; sc.textContent = st; + row.appendChild(sc); + D['op-board'].appendChild(row); + }); + } + + function renderMiniMap() { + const svg = D['op-minimap']; if (!svg) return; + while (svg.firstChild) svg.removeChild(svg.firstChild); + const pts = _embryos.map(e => resolveXY(e)).filter(Boolean); + if (_lastXY) pts.push({ x: _lastXY.X, y: _lastXY.Y }); + if (!pts.length) return; + let xMin = Math.min(...pts.map(p => p.x)), xMax = Math.max(...pts.map(p => p.x)); + let yMin = Math.min(...pts.map(p => p.y)), yMax = Math.max(...pts.map(p => p.y)); + const span = Math.max(xMax - xMin, yMax - yMin, 100), padf = span * 0.18; + const cx = (xMin + xMax) / 2, cy = (yMin + yMax) / 2, half = span / 2 + padf; + const toX = x => ((x - (cx - half)) / (2 * half)) * 100; + const toY = y => 100 - ((y - (cy - half)) / (2 * half)) * 100; // flip Y (stage +Y up) + const r = 2.4; + _embryos.forEach(emb => { + const xy = resolveXY(emb); if (!xy) return; + const st = _states[emb.id] || 'marked'; + const c = document.createElementNS(SVG_NS, 'circle'); + c.setAttribute('cx', toX(xy.x)); c.setAttribute('cy', toY(xy.y)); c.setAttribute('r', r); + const col = st === 'imaged' ? 'var(--accent-green)' : (st === 'focused' || st === 'calibrated') ? 'var(--accent-orange)' + : (st === 'centered' || st === 'lowering') ? 'var(--accent)' : ROLE_NEUTRAL; + c.setAttribute('fill', col); + if (emb.id === _selected) { c.setAttribute('stroke', 'var(--text)'); c.setAttribute('stroke-width', '1.2'); } + svg.appendChild(c); + }); + if (_lastXY) { // stage crosshair + const X = toX(_lastXY.X), Y = toY(_lastXY.Y); + [[X - 4, Y, X + 4, Y], [X, Y - 4, X, Y + 4]].forEach(([x1, y1, x2, y2]) => { + const l = document.createElementNS(SVG_NS, 'line'); + l.setAttribute('x1', x1); l.setAttribute('y1', y1); l.setAttribute('x2', x2); l.setAttribute('y2', y2); + l.setAttribute('stroke', 'var(--accent-cyan)'); l.setAttribute('stroke-width', '0.8'); + svg.appendChild(l); + }); + } + } + + // ── viewport overlays ────────────────────────────────────────────────── + function renderedRect() { + const sb = D['op-cam-stage'].getBoundingClientRect(); + const fw = _frozenFrame ? _frozenFrame.w : (_lastFrame ? _lastFrame.shape[1] : sb.width); + const fh = _frozenFrame ? _frozenFrame.h : (_lastFrame ? _lastFrame.shape[0] : sb.height); + const ar = fw / fh, sar = sb.width / sb.height; + let w, h; + if (ar > sar) { w = sb.width; h = sb.width / ar; } else { h = sb.height; w = sb.height * ar; } + return { x: (sb.width - w) / 2, y: (sb.height - h) / 2, w, h, fw, fh, sb }; + } + function canvasCtx() { + const sb = D['op-cam-stage'].getBoundingClientRect(); + D['op-mark-canvas'].width = Math.round(sb.width); D['op-mark-canvas'].height = Math.round(sb.height); + const ctx = D['op-mark-canvas'].getContext('2d'); + ctx.clearRect(0, 0, D['op-mark-canvas'].width, D['op-mark-canvas'].height); + return ctx; + } + function drawOverlay(step) { + if (!D['op-mark-canvas']) return; + if (step === 'a2') return drawMarkers(); + const ctx = canvasCtx(); + if (step === 'b1') drawReticle(ctx); + else if (step === 'b2') drawFloorGauge(ctx); + } + function drawMarkers() { + const r = renderedRect(); const ctx = canvasCtx(); + _markers.forEach((m, i) => { + const cx = r.x + (m.fx / r.fw) * r.w, cy = r.y + (m.fy / r.fh) * r.h; + ctx.beginPath(); ctx.arc(cx, cy, 11, 0, 7); ctx.lineWidth = 2; ctx.strokeStyle = '#34d399'; ctx.stroke(); + ctx.beginPath(); ctx.moveTo(cx - 6, cy); ctx.lineTo(cx + 6, cy); ctx.moveTo(cx, cy - 6); ctx.lineTo(cx, cy + 6); ctx.stroke(); + ctx.fillStyle = '#34d399'; ctx.font = '600 11px Inter Tight, sans-serif'; ctx.fillText(String(i + 1), cx + 13, cy - 8); + }); + } + function drawReticle(ctx) { + const sb = D['op-mark-canvas']; const cx = sb.width / 2, cy = sb.height / 2; + ctx.strokeStyle = 'rgba(96,165,250,0.85)'; ctx.lineWidth = 1.2; + ctx.beginPath(); ctx.moveTo(cx - 22, cy); ctx.lineTo(cx + 22, cy); ctx.moveTo(cx, cy - 22); ctx.lineTo(cx, cy + 22); ctx.stroke(); + ctx.beginPath(); ctx.arc(cx, cy, 10, 0, 7); ctx.stroke(); + // SPIM-FOV footprint box (light-sheet FOV << bottom FOV) + const r = renderedRect(); const bw = r.w * 0.22, bh = r.h * 0.22; + ctx.setLineDash([5, 3]); ctx.strokeStyle = 'rgba(34,211,238,0.7)'; + ctx.strokeRect(cx - bw / 2, cy - bh / 2, bw, bh); ctx.setLineDash([]); + ctx.fillStyle = 'rgba(34,211,238,0.85)'; ctx.font = '600 10px Inter Tight, sans-serif'; + ctx.fillText('SPIM FOV', cx - bw / 2, cy - bh / 2 - 4); + } + function drawFloorGauge(ctx) { + const sb = D['op-mark-canvas']; const pad = 24, h = 26, y = sb.height - pad - h, w = sb.width - 2 * pad, x = pad; + const floor = _fdFloor; + ctx.fillStyle = 'rgba(0,0,0,0.45)'; ctx.fillRect(x, y, w, h); + ctx.strokeStyle = 'rgba(255,255,255,0.25)'; ctx.lineWidth = 1; ctx.strokeRect(x, y, w, h); + // fill: full when far from floor, shrinks toward floor + const MAXD = 500; + const frac = floor == null ? 0 : Math.max(0, Math.min(1, floor / MAXD)); + const col = floor == null ? '#555' : floor <= 30 ? '#ef4444' : floor < 150 ? '#fb923c' : '#4ade80'; + ctx.fillStyle = col; ctx.fillRect(x + 2, y + 2, (w - 4) * frac, h - 4); + ctx.fillStyle = '#fff'; ctx.font = '700 13px Inter Tight, sans-serif'; + ctx.fillText(floor == null ? 'distance to floor —' : `${Math.round(floor)} µm to floor (30 µm hard floor)`, x + 8, y + h - 8); + } + + // ── bottom-cam frames ────────────────────────────────────────────────── + function onBottomFrame(p) { + if (!p || !p.jpeg_b64) return; + _lastFrame = p; + if (p.focus_score != null) { _bzScore = p.focus_score; if (D['op-bz-score']) D['op-bz-score'].textContent = Number(p.focus_score).toFixed(3); } + const cam = cameraForStep(effectiveStep()); + if (cam === 'bottom' && !_marking) setImg(p); + } + function onLightsheetFrame(p) { + if (!p || !p.jpeg_b64) return; + _lastSpimFrame = p; + if (p.focus_score != null) { _spimScore = p.focus_score; if (D['op-spim-score']) D['op-spim-score'].textContent = Number(p.focus_score).toFixed(3); } + if (cameraForStep(effectiveStep()) === 'spim') setImg(p); + } + + // ── A1 focus bottom ──────────────────────────────────────────────────── + async function toggleCamera() { + D['op-cam-toggle'].disabled = true; + try { + const ep = _camOn ? '/api/devices/bottom_camera/stream/stop' : '/api/devices/bottom_camera/stream/start'; + const d = await postJSON(ep, {}); applyCam(!!d.streaming); + } catch (e) { toast(`Camera toggle failed (${e.status || e.message})`); } + finally { D['op-cam-toggle'].disabled = false; } + } + function applyCam(on) { + _camOn = on; + D['op-cam-toggle'].textContent = on ? 'Stop camera' : 'Start camera'; + D['op-cam-toggle'].classList.toggle('op-btn-on', on); + if (!on && !_marking) { D['op-cam-img'].classList.remove('has-frame'); D['op-cam-ph'].style.display = ''; D['op-cam-ph'].textContent = 'Camera off'; } + renderStatus(); + } + async function nudgeBottomZ(delta) { + if (_marking) { toast('Finish marking first'); return; } + try { const d = await postJSON('/api/devices/stage/bottom_z/nudge', { delta }); if (d.position != null) D['op-bz-pos'].textContent = Number(d.position).toFixed(1); } + catch (e) { toast(`Bottom-Z nudge blocked (${e.status || e.message})`); } + } + + // ── A2 mark ──────────────────────────────────────────────────────────── + function umPerPxDisplay() { return BASE_UM_PER_PX * ((_frozenFrame && _frozenFrame.downsample) || 1); } + function frameToStage(fx, fy) { + const u = umPerPxDisplay(), cx = _frozenFrame.w / 2, cy = _frozenFrame.h / 2; + return [_captureStage[0] + (fx - cx) * u, _captureStage[1] - (fy - cy) * u]; + } + function enterMarking(cands) { + if (!_lastFrame) { toast('Start the camera first'); return false; } + // Absolute stage origin for pixel→stage conversion. Prefer the XY + // stamped on the live frame by the device layer; fall back to the + // position stream. NEVER default to [0, 0] — that silently converts + // clicks to offsets from stage origin, so embryos land hundreds of µm + // off and calibration images empty field. Block marking instead. + const capStage = (Array.isArray(_lastFrame.stage_position) && _lastFrame.stage_position.length === 2) + ? _lastFrame.stage_position + : (_lastXY ? [_lastXY.X, _lastXY.Y] : null); + if (!capStage) { toast('Stage position unknown — wait for the position readout, then mark'); return false; } + _marking = true; + _frozenFrame = { w: _lastFrame.shape[1], h: _lastFrame.shape[0], downsample: _lastFrame.downsample || 1 }; + _captureStage = capStage; + _frozenSrc = `data:${_lastFrame.mime || 'image/jpeg'};base64,${_lastFrame.jpeg_b64}`; + D['op-cam-img'].src = _frozenSrc; D['op-cam-img'].classList.add('has-frame'); D['op-cam-ph'].style.display = 'none'; + _markers = []; + (cands || []).forEach(c => { + const ds = _frozenFrame.downsample; + const fx = c.pixel_x != null ? c.pixel_x / ds : _frozenFrame.w / 2; + const fy = c.pixel_y != null ? c.pixel_y / ds : _frozenFrame.h / 2; + const s = (c.stage_x_um != null && c.stage_y_um != null) ? [c.stage_x_um, c.stage_y_um] : frameToStage(fx, fy); + _markers.push({ fx, fy, stageX: s[0], stageY: s[1], source: 'sam' }); + }); + D['op-cam-stage'].classList.add('is-marking'); + renderStep(); updateMarkCount(); + return true; + } + function exitMarking() { + _marking = false; _markers = []; _frozenSrc = null; _frozenFrame = null; + D['op-cam-stage'].classList.remove('is-marking'); + } + function updateMarkCount() { + const n = _markers.length; + if (D['op-mark-count']) D['op-mark-count'].textContent = `(${n})`; + if (D['op-confirm']) D['op-confirm'].disabled = n === 0; + if (D['op-clear']) D['op-clear'].disabled = n === 0; + } + function onCanvasClick(e) { + if (!_marking) return; + const r = renderedRect(), rect = D['op-mark-canvas'].getBoundingClientRect(); + const cxv = e.clientX - rect.left, cyv = e.clientY - rect.top; + for (let i = 0; i < _markers.length; i++) { + const mx = r.x + (_markers[i].fx / r.fw) * r.w, my = r.y + (_markers[i].fy / r.fh) * r.h; + if (Math.hypot(cxv - mx, cyv - my) <= MARK_HIT_PX) { _markers.splice(i, 1); drawMarkers(); updateMarkCount(); return; } + } + if (cxv < r.x || cxv > r.x + r.w || cyv < r.y || cyv > r.y + r.h) return; + const fx = ((cxv - r.x) / r.w) * r.fw, fy = ((cyv - r.y) / r.h) * r.fh, s = frameToStage(fx, fy); + _markers.push({ fx, fy, stageX: s[0], stageY: s[1], source: 'manual' }); + drawMarkers(); updateMarkCount(); + } + async function runDetect() { + if (!_marking && !enterMarking([])) return; + D['op-detect'].disabled = true; D['op-detect'].textContent = 'Detecting…'; + try { + const d = await postJSON('/api/devices/detect_embryos', {}); + const cands = Array.isArray(d.embryos) ? d.embryos : []; + if (_lastFrame && d.stage_position) { _lastFrame.stage_position = d.stage_position; _captureStage = d.stage_position; } + cands.forEach(c => { + const ds = _frozenFrame.downsample; + const fx = c.pixel_x != null ? c.pixel_x / ds : _frozenFrame.w / 2; + const fy = c.pixel_y != null ? c.pixel_y / ds : _frozenFrame.h / 2; + const s = (c.stage_x_um != null && c.stage_y_um != null) ? [c.stage_x_um, c.stage_y_um] : frameToStage(fx, fy); + _markers.push({ fx, fy, stageX: s[0], stageY: s[1], source: 'sam' }); + }); + drawMarkers(); updateMarkCount(); + toast(`Detected ${cands.length} candidate${cands.length === 1 ? '' : 's'}`); + } catch (e) { toast(`Detect failed (${e.status || e.message})`); } + finally { D['op-detect'].disabled = false; D['op-detect'].textContent = 'Detect (SAM)'; } + } + async function confirmMarks() { + if (!_markers.length) return; + if (!Array.isArray(_captureStage) || _captureStage.length !== 2) { + toast('Stage position unknown — cannot register markers'); return; + } + D['op-confirm'].disabled = true; + try { + const markers = _markers.map(m => ({ stage_x_um: m.stageX, stage_y_um: m.stageY, pixel_x: m.fx, pixel_y: m.fy, source: m.source })); + const d = await postJSON('/api/devices/embryos/confirm', { + markers, image_b64: _frozenSrc ? _frozenSrc.split(',')[1] : undefined, + frame: _frozenFrame ? { w: _frozenFrame.w, h: _frozenFrame.h, downsample: _frozenFrame.downsample } : undefined, + stage_position: _captureStage, + }); + toast(`Registered ${(d.registered || []).length} embryo${(d.registered || []).length === 1 ? '' : 's'}`); + exitMarking(); // EMBRYOS_UPDATE refreshes the board, then auto-select first + } catch (e) { toast(`Confirm failed (${e.status || e.message})`); D['op-confirm'].disabled = false; } + } + + // ── B1 center ────────────────────────────────────────────────────────── + function selectEmbryo(id) { + _selected = id; + if (id) _step = stepForState(_states[id] || 'marked'); + renderStep(); + } + async function centerOnSelected() { + if (_headLowered) { toast('Retract the SPIM head first'); return; } + const emb = _embryos.find(e => e.id === _selected), xy = emb ? resolveXY(emb) : null; + if (!xy) return; + D['op-center'].disabled = true; D['op-center'].textContent = 'Centering…'; + try { + await postJSON('/api/devices/stage/move', { x: xy.x, y: xy.y }); + advanceState(_selected, 'centered'); setStep('b2'); + toast(`Centred on embryo ${labelFor(emb)}`); + } catch (e) { toast(`Center failed (${e.status || e.message})`); D['op-center'].disabled = false; D['op-center'].textContent = 'Center stage on embryo'; } + } + function advanceState(id, st) { + if (!id) return; + if (STATE_RANK[st] > STATE_RANK[_states[id] || 'marked']) _states[id] = st; + renderStep(); + } + + // ── B2 lower (F-drive, fenced) ───────────────────────────────────────── + function gateFdriveNudges() { + // auto-grey down-nudges that would exceed remaining distance-to-floor + if (D['op-fd-d100']) D['op-fd-d100'].disabled = _fdFloor != null && _fdFloor < 100; + if (D['op-fd-d10']) D['op-fd-d10'].disabled = _fdFloor != null && _fdFloor < 10; + } + async function nudgeFdrive(delta) { + if (delta < 0 && _fdFloor != null && Math.abs(delta) > _fdFloor) { toast('Too close to the floor for that step'); return; } + try { + const d = await postJSON('/api/devices/spim/fdrive/nudge', { delta }); + if (d.position != null) { _fdPos = d.position; D['op-fd-pos'].textContent = Number(d.position).toFixed(1); } + if (d.distance_to_floor != null) { _fdFloor = d.distance_to_floor; D['op-fd-floor'].textContent = Number(d.distance_to_floor).toFixed(0); } + if (delta < 0) { _headLowered = true; if (_selected) advanceState(_selected, 'lowering'); } + renderStep(); // refresh gauge + gates + status + } catch (e) { toast(`F-drive nudge blocked (${e.status || e.message})`); } + } + + // ── B3 focus SPIM ────────────────────────────────────────────────────── + async function toggleSpim() { + D['op-spim-toggle'].disabled = true; + try { + const ep = _spimOn ? '/api/devices/lightsheet/live/stop' : '/api/devices/lightsheet/live/start'; + const d = await postJSON(ep, {}); _spimOn = !!d.streaming; + D['op-spim-toggle'].textContent = _spimOn ? 'Stop view' : 'Start view'; + D['op-spim-toggle'].classList.toggle('op-btn-on', _spimOn); + if (!_spimOn) { D['op-cam-img'].classList.remove('has-frame'); D['op-cam-ph'].style.display = ''; } + renderStatus(); + } catch (e) { toast(`SPIM view toggle failed (${e.status || e.message})`); } + finally { D['op-spim-toggle'].disabled = false; } + } + let _lsTimer = null; + function postLsParams() { + if (_lsTimer) clearTimeout(_lsTimer); + _lsTimer = setTimeout(() => { postJSON('/api/devices/lightsheet/live/params', { galvo: _galvo, piezo: _piezo, exposure: 20, side: 'A' }).catch(() => {}); }, 120); + } + function nudgeGalvo(d) { _galvo = Math.max(-5, Math.min(5, _galvo + d)); D['op-gv'].textContent = _galvo.toFixed(1); postLsParams(); } + function nudgePiezo(d) { _piezo = Math.max(0, Math.min(200, _piezo + d)); D['op-pz'].textContent = _piezo.toFixed(0); postLsParams(); } + async function toggleLed() { + _ledOn = !_ledOn; + D['op-led'].classList.toggle('op-btn-toggle', true); D['op-led'].setAttribute('aria-pressed', _ledOn ? 'true' : 'false'); + D['op-led'].classList.toggle('op-btn-on', _ledOn); + try { await postJSON('/api/devices/led/set', { state: _ledOn ? 'Open' : 'Closed' }); } catch (e) { toast(`LED failed (${e.status || e.message})`); } + renderStatus(); + } + async function forceLedOff() { + if (!_ledOn) return; + _ledOn = false; D['op-led'].setAttribute('aria-pressed', 'false'); D['op-led'].classList.remove('op-btn-on'); + try { await postJSON('/api/devices/led/set', { state: 'Closed' }); } catch (_) {} + renderStatus(); + } + function markInFocus() { + if (!_selected) return; + advanceState(_selected, 'focused'); forceLedOff(); setStep('bc'); + toast(`Embryo ${labelFor(_embryos.find(e => e.id === _selected))} in focus`); + } + + // ── B-cal calibrate (piezo-galvo) ────────────────────────────────────── + async function calibrateSelected() { + if (!_selected) return; + if (STATE_RANK[_states[_selected] || 'marked'] < STATE_RANK.focused) { toast('Focus the embryo first'); return; } + D['op-calibrate'].disabled = true; D['op-calibrate'].textContent = 'Calibrating…'; + if (D['op-cal-result']) D['op-cal-result'].textContent = 'sweeping…'; + try { + const d = await postJSON(`/api/devices/embryos/${_selected}/calibrate`, {}); + const cal = d.calibration || {}; + const slope = cal.slope_um_per_deg, r2 = cal.r_squared; + if (D['op-cal-result']) { + D['op-cal-result'].textContent = (slope != null) + ? `${Number(slope).toFixed(1)} µm/deg${r2 != null ? ` · R² ${Number(r2).toFixed(2)}` : ''}` + : 'done'; + } + advanceState(_selected, 'calibrated'); setStep('b4'); + toast(`Calibrated embryo ${labelFor(_embryos.find(e => e.id === _selected))}`); + } catch (e) { + if (D['op-cal-result']) D['op-cal-result'].textContent = 'failed'; + toast(`Calibrate failed (${e.status || e.message})`); + } finally { + D['op-calibrate'].disabled = false; D['op-calibrate'].textContent = 'Calibrate this embryo'; + } + } + function skipCalibration() { + if (!_selected) return; + advanceState(_selected, 'calibrated'); setStep('b4'); + toast('Calibration skipped'); + } + + // ── B4 acquire ───────────────────────────────────────────────────────── + async function acquireSelected() { + if (!_selected || STATE_RANK[_states[_selected] || 'marked'] < STATE_RANK.focused) { toast('Focus the embryo first'); return; } + D['op-acquire'].disabled = true; D['op-acquire'].textContent = 'Acquiring…'; _acquiring = true; renderStatus(); + try { + await postJSON('/api/devices/acquire/volume', { num_slices: 50, exposure_ms: 10.0 }); + advanceState(_selected, 'imaged'); setStep('b5'); + toast('Volume acquired'); + } catch (e) { toast(`Acquire failed (${e.status || e.message})`); } + finally { _acquiring = false; D['op-acquire'].disabled = false; D['op-acquire'].textContent = 'Acquire volume'; await forceLedOff(); renderStatus(); } + } + + // ── B5 retract & advance ─────────────────────────────────────────────── + async function retractAndAdvance() { + D['op-retract'].disabled = true; + try { + await postJSON('/api/devices/spim/fdrive/nudge', { delta: 100 }).catch(() => {}); + _headLowered = false; _fdFloor = null; + const next = _embryos.find(e => _states[e.id] !== 'imaged'); + if (next) { selectEmbryo(next.id); toast(`Next: embryo ${labelFor(next)}`); } + else { + // Done imaging — return to the Run chooser (not Focus) so another + // run mode can be chosen for the same marked set. + _selected = null; _step = null; _runState = 'choose'; renderStep(); + toast('All embryos imaged'); + } + } finally { D['op-retract'].disabled = false; } + } + + // ── embryo SSOT ──────────────────────────────────────────────────────── + function onEmbryosUpdate(p) { + const wasEmpty = _embryos.length === 0; + _embryos = (p && Array.isArray(p.embryos)) ? p.embryos : []; + const ids = new Set(_embryos.map(e => e.id)); + Object.keys(_states).forEach(id => { if (!ids.has(id)) delete _states[id]; }); + Object.keys(_roles).forEach(id => { if (!ids.has(id)) delete _roles[id]; }); + _embryos.forEach(e => { + if (!_states[e.id]) _states[e.id] = 'marked'; + // seed role from the embryo if present, else default to subject ('test') + if (!_roles[e.id]) _roles[e.id] = (e.role && e.role !== 'unassigned') ? e.role : 'test'; + }); + if (_selected && !ids.has(_selected)) { _selected = null; _step = null; } + // After the first marking confirm, enter the Phase C Run chooser + // (NOT the old auto-dive into the manual loop). + if (wasEmpty && _embryos.length && !_selected && !_marking && _runState === null) { + _runState = 'choose'; + } + renderStep(); + } + + // ── Phase C: Run chooser + run-spine ─────────────────────────────────── + function escapeHtml(s) { + return String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); + } + + function renderChooser() { + if (D['op-rolechips']) { + D['op-rolechips'].innerHTML = ''; + _embryos.forEach(emb => { + const role = _roles[emb.id] || 'test'; + const chip = document.createElement('button'); + chip.type = 'button'; + chip.className = 'op-rolechip' + (role === 'calibration' ? ' is-reference' : ''); + chip.textContent = `${labelFor(emb)} · ${role === 'calibration' ? 'ref' : 'subj'}`; + chip.title = 'Click to toggle subject / reference'; + chip.addEventListener('click', () => { + _roles[emb.id] = (_roles[emb.id] === 'calibration') ? 'test' : 'calibration'; + renderChooser(); + }); + D['op-rolechips'].appendChild(chip); + }); + } + document.querySelectorAll('.op-modepanel').forEach(p => p.classList.toggle('is-shown', p.dataset.mode === _runMode)); + if (D['op-tl-stop'] && D['op-tl-condval']) { + const s = D['op-tl-stop'].value; + D['op-tl-condval'].style.display = (s === 'timepoints' || s === 'duration') ? '' : 'none'; + } + if (_runMode === 'library') loadLibrary(); + if (_runMode === 'plan') loadPlanItems(); + } + + async function loadLibrary() { + if (!D['op-lib-list']) return; + try { + const d = await getJSON('/api/tactic_library'); + const items = (d && d.tactics) || []; + if (!items.length) { D['op-lib-list'].innerHTML = '
    No saved tactics
    '; return; } + D['op-lib-list'].innerHTML = ''; + items.forEach(t => { + const b = document.createElement('button'); + b.type = 'button'; + b.className = 'op-libitem' + (t.id === _selectedLib ? ' is-sel' : ''); + b.innerHTML = `${escapeHtml(t.name || t.id)}${escapeHtml(t.kind || '')}`; + b.addEventListener('click', () => { _selectedLib = t.id; loadLibrary(); }); + D['op-lib-list'].appendChild(b); + }); + } catch (_) { D['op-lib-list'].innerHTML = '
    Library unavailable
    '; } + } + function loadPlanItems() { + // Plan resolution lives in the agent (resume_plan + execute_plan_item); + // Start hands the roster to the agent to attach + continue the right item. + if (D['op-plan-list']) { + D['op-plan-list'].innerHTML = '
    Start hands these embryos to the agent to attach a plan item and continue imaging.
    '; + } + } + + async function applyRoles() { + const roles = {}; + _embryos.forEach(e => { roles[e.id] = _roles[e.id] || 'test'; }); + try { await postJSON('/api/embryos/roles', { roles }); } catch (e) { toast(`Roles failed (${e.status || e.message})`); } + } + + async function startRun() { + // Persist the chooser's role toggles for EVERY mode (manual included). + await applyRoles(); + if (_runMode === 'manual') { + // record a cosmetic oneshot tactic so even a manual sweep shows on the spine + postJSON('/api/operate/run-tactic', { + tactic: { kind: 'oneshot', name: 'Manual sweep', structure: { note: 'manual one-by-one' } }, + embryo_ids: _embryos.map(e => e.id), + }).catch(() => {}); + _runState = null; + if (_embryos.length) selectEmbryo(_embryos[0].id); + return; + } + const subjects = _embryos.filter(e => (_roles[e.id] || 'test') !== 'calibration').map(e => e.id); + const embryo_ids = subjects.length ? subjects : _embryos.map(e => e.id); + if (_runMode === 'adaptive') { + const interval = Math.max(1, Number(D['op-tl-interval'].value) || 120); + const stopSel = D['op-tl-stop'].value; + const monitoring = D['op-tl-monitor'].value; + // Send the combined stop form the orchestrator parser understands + // ('timepoints:N' / 'duration:Xh'); a bare 'timepoints' silently + // degrades to manual (never stops). + let stop_condition = stopSel; + if (stopSel === 'timepoints') stop_condition = `timepoints:${Math.max(1, Number(D['op-tl-condval'].value) || 1)}`; + else if (stopSel === 'duration') stop_condition = `duration:${Math.max(1, Number(D['op-tl-condval'].value) || 1)}h`; + const body = { embryo_ids, interval_seconds: interval, stop_condition, monitoring_mode: monitoring }; + D['op-run-start'].disabled = true; D['op-run-start'].textContent = 'Starting…'; + try { + await postJSON('/api/devices/timelapse/start', body); + _runMeta = { mode: 'adaptive', interval, stop: stop_condition, monitoring, n: embryo_ids.length }; + _runState = 'running'; _runPaused = false; + toast(`Adaptive timelapse started — ${embryo_ids.length} subject${embryo_ids.length === 1 ? '' : 's'}`); + renderStep(); + } catch (e) { + toast(`Start failed (${e.status || e.message})`); + } finally { D['op-run-start'].disabled = false; D['op-run-start'].textContent = 'Start run'; } + return; + } + const roster = _embryos.map(e => { + const xy = resolveXY(e); + const r = _roles[e.id] === 'calibration' ? 'reference' : 'subject'; + return `${labelFor(e)}${xy ? ` (${xy.x.toFixed(0)},${xy.y.toFixed(0)})` : ''} [${r}]`; + }).join(', '); + + if (_runMode === 'library') { + if (!_selectedLib) { toast('Pick a saved tactic'); return; } + D['op-run-start'].disabled = true; D['op-run-start'].textContent = 'Starting…'; + try { + const d = await postJSON('/api/operate/run-tactic', { library_id: _selectedLib, embryo_ids }); + if (d.success) { + _runMeta = { mode: 'library', n: embryo_ids.length }; + _runState = 'running'; _runPaused = false; toast('Tactic started'); renderStep(); + } else { toast(`Run failed: ${(d.result && d.result.message) || '?'}`); } + } catch (e) { toast(`Start failed (${e.status || e.message})`); } + finally { D['op-run-start'].disabled = false; D['op-run-start'].textContent = 'Start run'; } + return; + } + if (_runMode === 'plan' || _runMode === 'agent') { + // The agent owns plan resolution + composed tactics; hand off the roster. + const prompt = _runMode === 'plan' + ? `Continue a plan on these ${_embryos.length} marked embryos — attach this session to the right plan item and start imaging: ${roster}.` + : `I marked ${_embryos.length} embryos: ${roster}. Propose and start an Operation Plan to image them.`; + if (typeof AgentChat !== 'undefined' && AgentChat.togglePanel) { + AgentChat.togglePanel(true); + if (AgentChat.runCommand) setTimeout(() => AgentChat.runCommand(prompt), 300); + } else { toast('Agent chat unavailable'); } + _runState = 'running'; renderStep(); + return; + } + } + + async function renderRunSpine() { + if (!D['op-runspine']) return; + let tactics = []; + try { const d = await getJSON('/api/operation_plan'); tactics = (d && d.plan && d.plan.tactics) || []; } catch (_) {} + D['op-runspine'].innerHTML = ''; + if (tactics.length) { + tactics.forEach(t => D['op-runspine'].appendChild(tacticCard(t))); + } else if (_runMeta) { + // Fallback card when the plan fetch is empty/failed — shaped per mode + // (a library run carries no interval/stop/monitoring). + const n = _runMeta.n || 0; + const subj = `${n} subject${n === 1 ? '' : 's'}`; + const card = document.createElement('div'); + card.className = 'op-tcard st-active'; + if (_runMeta.mode === 'adaptive') { + const stopTxt = (_runMeta.stop === 'manual' || _runMeta.stop == null) ? 'until stopped' : _runMeta.stop; + const ivl = _runMeta.interval != null ? `${escapeHtml(String(_runMeta.interval))}s · ` : ''; + card.innerHTML = '
    Adaptive timelapseactive
    ' + + `
    standing_timelapse · ${escapeHtml(_runMeta.monitoring || 'idle')}
    ` + + `
    ${ivl}${subj} · ${escapeHtml(stopTxt)}
    `; + } else { + card.innerHTML = '
    Tacticactive
    ' + + `
    ${subj}
    `; + } + D['op-runspine'].appendChild(card); + } else { + D['op-runspine'].innerHTML = '
    Run active.
    '; + } + if (D['op-run-pause']) D['op-run-pause'].textContent = _runPaused ? 'Resume' : 'Pause'; + } + function tacticCard(t) { + const card = document.createElement('div'); + const state = t.state || 'planned'; + card.className = 'op-tcard st-' + state; + const struct = t.structure || {}; + const meta = []; + if (struct.cadence_s != null) meta.push(`${struct.cadence_s}s`); + if (struct.interval != null) meta.push(`${struct.interval}s`); + if (struct.status) meta.push(struct.status); + if (t.live && t.live.signal != null) meta.push(`signal ${t.live.signal}`); + card.innerHTML = `
    ${escapeHtml(t.name || t.id)}${escapeHtml(state)}
    ` + + `
    ${escapeHtml(t.kind || '')}
    ` + + (meta.length ? `
    ${escapeHtml(meta.join(' · '))}
    ` : '') + + (t.rationale ? `
    ${escapeHtml(t.rationale)}
    ` : ''); + return card; + } + + async function pauseRun() { + try { + if (_runPaused) { await postJSON('/api/devices/timelapse/resume', {}); _runPaused = false; toast('Resumed'); } + else { await postJSON('/api/devices/timelapse/pause', {}); _runPaused = true; toast('Paused'); } + renderRunSpine(); + } catch (e) { toast(`Pause/resume failed (${e.status || e.message})`); } + } + async function stopRun() { + if (!window.confirm('Stop the run?')) return; + try { await postJSON('/api/devices/timelapse/stop', { reason: 'operator' }); } catch (e) { toast(`Stop failed (${e.status || e.message})`); } + _runState = 'choose'; _runMeta = null; _runPaused = false; + toast('Run stopped'); renderStep(); + } + function openInOperations() { + const nav = [...document.querySelectorAll('[data-tab]')].find(n => /operations/i.test(n.textContent || '')); + if (nav) nav.click(); else toast('Operations tab not found'); + } + + // ── lifecycle ────────────────────────────────────────────────────────── + function surveyMore() { _selected = null; _step = null; _runState = null; if (_marking) exitMarking(); renderStep(); } + + function wire() { + if (_wired) return; _wired = true; + cacheDom(); + D['op-cam-toggle'].addEventListener('click', toggleCamera); + D['op-mark-canvas'].addEventListener('click', onCanvasClick); + D['op-bz-nudge'].addEventListener('click', e => { const b = e.target.closest('[data-bz]'); if (b) nudgeBottomZ(Number(b.dataset.bz)); }); + D['op-tomark'].addEventListener('click', () => { if (enterMarking([])) renderStep(); }); + D['op-detect'].addEventListener('click', runDetect); + D['op-confirm'].addEventListener('click', confirmMarks); + D['op-clear'].addEventListener('click', () => { _markers = []; drawMarkers(); updateMarkCount(); }); + D['op-center'].addEventListener('click', centerOnSelected); + D['op-fd-nudge'].addEventListener('click', e => { const b = e.target.closest('[data-fd]'); if (b) nudgeFdrive(Number(b.dataset.fd)); }); + D['op-tofocus'].addEventListener('click', () => setStep('b3')); + D['op-spim-toggle'].addEventListener('click', toggleSpim); + D['op-led'].addEventListener('click', toggleLed); + document.querySelectorAll('[data-gv]').forEach(b => b.addEventListener('click', () => nudgeGalvo(Number(b.dataset.gv)))); + document.querySelectorAll('[data-pz]').forEach(b => b.addEventListener('click', () => nudgePiezo(Number(b.dataset.pz)))); + D['op-infocus'].addEventListener('click', markInFocus); + if (D['op-calibrate']) D['op-calibrate'].addEventListener('click', calibrateSelected); + if (D['op-cal-skip']) D['op-cal-skip'].addEventListener('click', skipCalibration); + D['op-acquire'].addEventListener('click', acquireSelected); + D['op-retract'].addEventListener('click', retractAndAdvance); + D['op-survey-btn'].addEventListener('click', surveyMore); + // Re-enter the Run chooser by clicking the "Run" stepper node — so a + // different run mode can be chosen for an already-marked set (e.g. after + // a manual sweep). Without this the chooser is a one-time gate. + if (D['op-stepper']) D['op-stepper'].addEventListener('click', e => { + const n = e.target.closest('.op-node'); + if (n && n.dataset.node === 'run' && _embryos.length && !_marking && _runState !== 'running') { + _selected = null; _step = null; _runState = 'choose'; renderStep(); + } + }); + // Phase C: Run chooser + run-spine + document.querySelectorAll('input[name="op-mode"]').forEach(r => + r.addEventListener('change', () => { _runMode = r.value; renderChooser(); })); + if (D['op-tl-stop']) D['op-tl-stop'].addEventListener('change', renderChooser); + if (D['op-run-start']) D['op-run-start'].addEventListener('click', startRun); + if (D['op-run-pause']) D['op-run-pause'].addEventListener('click', pauseRun); + if (D['op-run-stop']) D['op-run-stop'].addEventListener('click', stopRun); + if (D['op-run-open']) D['op-run-open'].addEventListener('click', openInOperations); + window.addEventListener('resize', () => { if (_active) drawOverlay(effectiveStep()); }); + + if (typeof ClientEventBus !== 'undefined') { + ClientEventBus.on('BOTTOM_CAMERA_FRAME', onBottomFrame); + ClientEventBus.on('LIGHTSHEET_FRAME', onLightsheetFrame); + ClientEventBus.on('EMBRYOS_UPDATE', onEmbryosUpdate); + ClientEventBus.on('DEVICE_STATE_UPDATE', p => { + const pos = p && p.positions; if (!pos) return; + if (pos.xy_stage && Array.isArray(pos.xy_stage)) { _lastXY = { X: pos.xy_stage[0], Y: pos.xy_stage[1] }; if (_active) renderMiniMap(); } + }); + } + } + + async function activate() { + wire(); + if (_active) return; _active = true; + try { const s = await getJSON('/api/embryos/current'); onEmbryosUpdate(s); } catch (_) { renderStep(); } + try { const b = await getJSON('/api/devices/stage/bottom_z'); if (b.position != null) D['op-bz-pos'].textContent = Number(b.position).toFixed(1); } catch (_) {} + try { const f = await getJSON('/api/devices/spim/fdrive'); if (f.position != null) { _fdPos = f.position; D['op-fd-pos'].textContent = Number(f.position).toFixed(1); } if (f.distance_to_floor != null) { _fdFloor = f.distance_to_floor; D['op-fd-floor'].textContent = Number(f.distance_to_floor).toFixed(0); } } catch (_) {} + renderStep(); + } + function deactivate() { + if (!_active) return; _active = false; + if (_camOn) { fetch('/api/devices/bottom_camera/stream/stop', { method: 'POST' }).catch(() => {}); applyCam(false); } + if (_spimOn) { fetch('/api/devices/lightsheet/live/stop', { method: 'POST' }).catch(() => {}); _spimOn = false; D['op-spim-toggle'].textContent = 'Start view'; D['op-spim-toggle'].classList.remove('op-btn-on'); } + forceLedOff(); + if (_marking) exitMarking(); + } + + return { activate, deactivate }; +})(); diff --git a/gently/ui/web/static/js/operations-scenarios.js b/gently/ui/web/static/js/operations-scenarios.js new file mode 100644 index 00000000..0cd72fbf --- /dev/null +++ b/gently/ui/web/static/js/operations-scenarios.js @@ -0,0 +1,372 @@ +/** + * Operation Plan scenario fixtures — development and Chrome-MCP audit targets. + * + * Each entry is a plan object matching the real API schema returned by + * GET /api/operation_plan/{session_id} → { available, plan } + * The `.plan` is what gets passed to the renderer. Active tactics carry a + * `live` field (readouts + phases) that the API route merges from live + * telemetry; here they are baked into the fixture. + * + * Scenario dev mode: load via ?scenario= — ExperimentOverview reads + * window.OPERATIONS_SCENARIOS[name] and skips all fetches. + * + * Scenarios: + * temp_strain — scripted_protocol active (temp-change burst protocol) + * expression_onset — reactive_monitor active (reporter rising) + * hatching_detect — reactive_monitor active (watch=hatching, status=watching) + * transmission_survey — exclusive_burst active (brightfield only) + * decided_plan — all planned, nothing run yet + * async_multi — standing_timelapse per-embryo cadence + layered reactive_monitor + * idle — null (no operation running) + */ +window.OPERATIONS_SCENARIOS = { + + /* ------------------------------------------------------------------ */ + temp_strain: { + session_id: '20260628_1432_tempstrain_a', + title: 'Temperature-strain run · E01', + goal: 'Acquire volumes before, during, and after a +4 °C step to 32 °C; capture reporter response to heat stress.', + tactics: [ + { + id: 'ts-1', seq: 1, + name: 'Monitor — low cadence', + kind: 'standing_timelapse', state: 'done', + scope: { mode: 'global' }, + rationale: 'Baseline acquisition before thermal perturbation.', + structure: { cadence_s: 180 }, + live_bind: ['cadence'], + live: { summary: '22 min · ended on signal' }, + relations: {} + }, + { + id: 'ts-2', seq: 2, + name: 'Transmission burst — baseline', + kind: 'exclusive_burst', state: 'done', + scope: { mode: 'global' }, + rationale: 'Brightfield snapshot before setpoint change.', + structure: { frames: 3, mode: 'brightfield' }, + live_bind: [], + live: { summary: '3 bursts · brightfield' }, + relations: {} + }, + { + id: 'ts-3', seq: 3, + name: 'Temp-change burst protocol', + kind: 'scripted_protocol', state: 'active', + scope: { mode: 'global' }, + rationale: 'Systematic volume capture before, during ramp, and after thermal lock. Laser off during ramp to limit phototoxicity.', + structure: { + phases: [ + { name: 'before', state: 'done', count: '1/1 done' }, + { name: 'during', state: 'active', count: '2 · awaiting lock' }, + { name: 'after', state: 'todo', count: '0/1' } + ] + }, + live_bind: ['temperature', 'current_burst'], + live: { + target: '→ 32.0 °C', + summary: 'started 3m ago', + desc: 'bursts before · setpoint change · bursts through ramp · bursts after lock — laser off', + readouts: [ + { + label: 'stage temp', + bind: 'temperature', + value: '29.432.0°C', + bar: 62 + }, + { + label: 'current burst', + value: '#3 during', + sub: '60f · 1Hz · brightfield' + } + ], + phases: [ + { name: 'before', state: 'done', count: '1/1 done', pips: ['before'] }, + { name: 'during', state: 'active', count: '2 · awaiting lock', pips: ['during', 'during', 'pending'] }, + { name: 'after', state: 'todo', count: '0/1', pips: ['pending'] } + ] + }, + relations: {} + }, + { + id: 'ts-4', seq: 4, + name: 'Recovery monitor — low cadence', + kind: 'standing_timelapse', state: 'planned', + scope: { mode: 'global' }, + rationale: 'Resume gentle monitoring once temperature settles.', + structure: { cadence_s: 180 }, + live_bind: ['cadence'], + live: { + summary: 'queued · 30 min after lock', + desc: 'resume gentle monitoring once temperature settles' + }, + relations: { after: ['ts-3'] } + } + ] + }, + + /* ------------------------------------------------------------------ */ + expression_onset: { + session_id: '20260628_0915_onset_b', + title: 'Reporter-onset watch · E04', + goal: 'Detect first appearance of the fluorescent reporter; capture onset dynamics at high temporal resolution.', + tactics: [ + { + id: 'eo-1', seq: 1, + name: 'Monitor — low cadence', + kind: 'standing_timelapse', state: 'done', + scope: { mode: 'global' }, + rationale: 'Baseline acquisition before signal appears.', + structure: { cadence_s: 180 }, + live_bind: ['cadence'], + live: { summary: '1h 40m · baseline' }, + relations: {} + }, + { + id: 'eo-2', seq: 2, + name: 'Expression monitoring', + kind: 'reactive_monitor', state: 'active', + scope: { mode: 'global' }, + rationale: 'Accelerate cadence on signal, ramp 488 down on saturation, burst on stable structure.', + structure: { watch: 'reporter onset', reaction: 'accelerate + ramp laser', status: 'watching' }, + live_bind: ['signal', 'cadence'], + live: { + target: 'reporter onset', + summary: 'signal rising', + desc: 'accelerate cadence on signal · ramp 488 down on saturation · burst on stable structure', + readouts: [ + { + label: 'reporter signal', + value: 'rising', + sub: '+14% over 6 min', + bar: 48 + }, + { + label: 'cadence', + value: '120s 30s', + sub: 'accelerated on onset' + }, + { + label: '488 power', + value: '5% 3%', + sub: 'ramped to limit saturation' + } + ] + }, + relations: {} + }, + { + id: 'eo-3', seq: 3, + name: 'Burst on good structure', + kind: 'exclusive_burst', state: 'planned', + scope: { mode: 'global' }, + rationale: 'Capture a burst once the reporter pattern holds.', + structure: { frames: 60, mode: 'fluorescence' }, + live_bind: [], + live: { + summary: 'queued · when structure stable', + desc: 'capture a burst once the reporter pattern holds' + }, + relations: { after: ['eo-2'] } + } + ] + }, + + /* ------------------------------------------------------------------ */ + hatching_detect: { + session_id: '20260627_2210_hatch_c', + title: 'Pre-hatching vigil · E11', + goal: 'Detect and capture the hatching event; accelerate acquisition as hatching approaches.', + tactics: [ + { + id: 'hd-1', seq: 1, + name: 'Pre-terminal monitoring', + kind: 'reactive_monitor', state: 'active', + scope: { mode: 'global' }, + rationale: 'Low cadence now; speed up as hatching approaches.', + structure: { watch: 'hatching', reaction: 'accelerate near event', status: 'watching' }, + live_bind: ['cadence'], + live: { + target: 'hatching', + summary: 'watching', + desc: 'low cadence now · speed up as hatching approaches', + readouts: [ + { label: 'est. time to hatch', value: '~38 min', sub: 'from motion + morphology' }, + { label: 'cadence', value: '180s', sub: 'will speed up near event' } + ] + }, + relations: {} + }, + { + id: 'hd-2', seq: 2, + name: 'Hatching speedup', + kind: 'standing_timelapse', state: 'planned', + scope: { mode: 'global' }, + rationale: 'High cadence through hatching.', + structure: { cadence_s: 30 }, + live_bind: ['cadence'], + live: { + summary: 'queued · ~T-10 min', + desc: 'high cadence through hatching' + }, + relations: { after: ['hd-1'] } + }, + { + id: 'hd-3', seq: 3, + name: 'Post-hatch monitor', + kind: 'standing_timelapse', state: 'planned', + scope: { mode: 'global' }, + rationale: 'Resume normal cadence after the event.', + structure: { cadence_s: 120 }, + live_bind: ['cadence'], + live: { summary: 'queued · after event' }, + relations: { after: ['hd-2'] } + } + ] + }, + + /* ------------------------------------------------------------------ */ + transmission_survey: { + session_id: '20260628_1100_survey_a', + title: 'Transmission survey · plate A', + goal: 'Survey all embryos with brightfield only; no laser excitation.', + tactics: [ + { + id: 'srv-1', seq: 1, + name: 'Transmission burst', + kind: 'exclusive_burst', state: 'active', + scope: { mode: 'global' }, + rationale: 'LED/brightfield bursts, no laser — DIC-like contrast.', + structure: { frames: 30, mode: 'brightfield', phase: 'capturing' }, + live_bind: ['current_burst'], + live: { + summary: 'capturing', + desc: 'LED/brightfield bursts, no laser — DIC-like contrast', + readouts: [ + { label: 'bursts captured', value: '7', sub: 'across 3 embryos' }, + { label: 'illumination', value: 'LED · laser off', sub: 'brightfield' } + ] + }, + relations: {} + }, + { + id: 'srv-2', seq: 2, + name: 'Volume at best plane', + kind: 'oneshot', state: 'planned', + scope: { mode: 'global' }, + rationale: 'Full volume capture at the best focal plane after operator review.', + structure: { note: 'operator selects plane' }, + live_bind: [], + live: { summary: 'queued · operator review' }, + relations: { after: ['srv-1'] } + } + ] + }, + + /* ------------------------------------------------------------------ */ + decided_plan: { + session_id: '20260628_1500_tempstrain_b', + title: 'Temperature-strain run · E02', + goal: 'Repeat the thermal strain protocol on a second embryo cohort.', + tactics: [ + { + id: 'dp-1', seq: 1, + name: 'Transmission burst — baseline', + kind: 'exclusive_burst', state: 'planned', + scope: { mode: 'global' }, + rationale: 'Brightfield baseline before any change.', + structure: { frames: 3, mode: 'brightfield' }, + live_bind: [], + live: { + summary: 'queued · first', + desc: 'brightfield baseline before any change' + }, + relations: {} + }, + { + id: 'dp-2', seq: 2, + name: 'Temp-change burst protocol', + kind: 'scripted_protocol', state: 'planned', + scope: { mode: 'global' }, + rationale: 'Thermal step to 30.0 °C with phased acquisition.', + structure: { + phases: [ + { name: 'before', state: 'todo', count: '0/1' }, + { name: 'during', state: 'todo', count: '0/3' }, + { name: 'after', state: 'todo', count: '0/1' } + ] + }, + live_bind: ['temperature', 'current_burst'], + live: { target: '→ 30.0 °C', summary: 'queued · second' }, + relations: { after: ['dp-1'] } + }, + { + id: 'dp-3', seq: 3, + name: 'Recovery monitor', + kind: 'standing_timelapse', state: 'planned', + scope: { mode: 'global' }, + rationale: 'Low-cadence monitoring after temperature settles.', + structure: { cadence_s: 180 }, + live_bind: ['cadence'], + live: { summary: 'queued · last' }, + relations: { after: ['dp-2'] } + } + ] + }, + + /* ------------------------------------------------------------------ */ + async_multi: { + session_id: '20260628_1630_async_multi', + title: 'Async multi-embryo run · 4 embryos', + goal: 'Per-embryo asynchronous acquisition with individual cadence phases; overlay a hatching watch on the two most advanced.', + tactics: [ + { + id: 'am-1', seq: 1, + name: 'Async timelapse — per-embryo cadence', + kind: 'standing_timelapse', state: 'active', + scope: { mode: 'embryos', embryo_ids: ['E01', 'E02', 'E03', 'E04'] }, + rationale: 'Each embryo runs at its own cadence based on developmental stage and reporter state.', + structure: { + cadence_s: 120, + per_embryo: [ + { embryo_id: 'E01', cadence_phase: 'normal', interval_s: 180 }, + { embryo_id: 'E02', cadence_phase: 'fast', interval_s: 30 }, + { embryo_id: 'E03', cadence_phase: 'burst', interval_s: 0 }, + { embryo_id: 'E04', cadence_phase: 'paused', interval_s: null } + ] + }, + live_bind: ['cadence'], + live: { + summary: 'running · 4 embryos', + readouts: [ + { label: 'active embryos', value: '3 / 4', sub: 'E04 paused' }, + { label: 'cadence range', value: '30–180s', sub: 'per-embryo mode' } + ] + }, + relations: {} + }, + { + id: 'am-2', seq: 2, + name: 'Hatching watch — E01, E02', + kind: 'reactive_monitor', state: 'active', + scope: { mode: 'embryos', embryo_ids: ['E01', 'E02'] }, + rationale: 'Overlay a hatching detector on the two most advanced embryos.', + structure: { watch: 'hatching', reaction: 'accelerate + alert', status: 'armed' }, + live_bind: ['signal'], + live: { + target: 'hatching', + summary: 'armed · E01, E02', + desc: 'watching for hatching onset on the two most advanced embryos', + readouts: [ + { label: 'watch status', value: 'armed', sub: 'no event yet' }, + { label: 'scope', value: 'E01, E02', sub: '2 of 4 embryos' } + ] + }, + relations: { layered_on: ['am-1'] } + } + ] + }, + + /* ------------------------------------------------------------------ */ + idle: null +}; diff --git a/gently/ui/web/static/js/settings.js b/gently/ui/web/static/js/settings.js index 66558731..2ade33e2 100644 --- a/gently/ui/web/static/js/settings.js +++ b/gently/ui/web/static/js/settings.js @@ -39,23 +39,78 @@ const SettingsManager = { config: null, - init() { + async init() { + this.serverDefaults = await this.fetchServerDefaults(); this.config = this.load(); this.populateForm(); this.setupNavigation(); this.setupListeners(); + this.setupDefaultsBar(); + }, + + async fetchServerDefaults() { + try { + const res = await fetch('/api/config/dashboard-defaults'); + if (!res.ok) return {}; + const d = await res.json(); + return (d && typeof d === 'object') ? d : {}; + } catch (e) { return {}; } }, load() { + // Effective config = hardcoded defaults < rig-wide server defaults < this browser's localStorage. + const base = this.deepMerge(this.defaults, this.serverDefaults || {}); try { const stored = localStorage.getItem(this.STORAGE_KEY); if (stored) { - return this.deepMerge(this.defaults, JSON.parse(stored)); + return this.deepMerge(base, JSON.parse(stored)); } } catch (e) { console.warn('Failed to load settings:', e); } - return { ...this.defaults }; + return base; + }, + + setupDefaultsBar() { + const byId = (id) => document.getElementById(id); + const saveDef = byId('pref-save-defaults'); + if (saveDef) saveDef.addEventListener('click', async () => { + try { + const res = await fetch('/api/config/dashboard-defaults', { + method: 'PUT', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(this.config), + }); + if (res.status === 401 || res.status === 403) { this.showSaveStatus('Need control'); return; } + this.showSaveStatus(res.ok ? 'Saved as rig defaults' : 'Failed to save defaults'); + } catch (e) { this.showSaveStatus('Failed to save defaults'); } + }); + const reset = byId('pref-reset'); + if (reset) reset.addEventListener('click', () => { + if (!confirm("Clear this browser's dashboard prefs and use the rig defaults?")) return; + localStorage.removeItem(this.STORAGE_KEY); + location.reload(); + }); + const exp = byId('pref-export'); + if (exp) exp.addEventListener('click', () => { + const blob = new Blob([JSON.stringify(this.config, null, 2)], { type: 'application/json' }); + const a = document.createElement('a'); + a.href = URL.createObjectURL(blob); a.download = 'gently-dashboard-prefs.json'; + a.click(); URL.revokeObjectURL(a.href); + }); + const imp = byId('pref-import'), impFile = byId('pref-import-file'); + if (imp && impFile) { + imp.addEventListener('click', () => impFile.click()); + impFile.addEventListener('change', async () => { + const file = impFile.files[0]; if (!file) return; + try { + const obj = JSON.parse(await file.text()); + this.config = this.deepMerge(this.defaults, obj); + this.save(); this.populateForm(); + this.showSaveStatus('Imported'); + } catch (e) { this.showSaveStatus('Import failed: invalid JSON'); } + impFile.value = ''; + }); + } }, save() { @@ -177,7 +232,14 @@ const SettingsManager = { const content = document.getElementById('settings-content'); if (!content) return; - content.addEventListener('change', () => this.readFormAndSave()); + // Ignore the server-backed hardware sections — they own their own + // handlers (ThermalizerSettings) and must not trigger the localStorage + // save or its "Settings saved" toast. + const isHardware = (t) => t && t.closest && t.closest('#section-thermalizer, #section-effective'); + content.addEventListener('change', (e) => { + if (isHardware(e.target)) return; + this.readFormAndSave(); + }); content.addEventListener('input', (e) => { // Live update for range sliders if (e.target.id === 'cfg-imageSplitRatio') { @@ -233,3 +295,248 @@ const SettingsManager = { // Initialize on page load document.addEventListener('DOMContentLoaded', () => SettingsManager.init()); + + +/** + * ThermalizerSettings — server-backed hardware config, isolated from the + * localStorage SettingsManager above. Reads/writes the ACUITYnano connection + * via /api/devices/temperature/config{,/test}. Mock backend is dev-only + * (revealed via ?dev=1 or localStorage 'gently-dev'). + */ +const ThermalizerSettings = { + el(id) { return document.getElementById(id); }, + devMode() { + try { + return new URLSearchParams(location.search).has('dev') + || localStorage.getItem('gently-dev') === '1'; + } catch (_) { return false; } + }, + + async init() { + if (!this.el('section-thermalizer')) return; + if (this.devMode()) { + const m = document.querySelector('.th-mock-opt'); + if (m) m.style.display = ''; + } + // backend radio toggles the field groups + document.querySelectorAll('input[name="th-backend"]').forEach(r => + r.addEventListener('change', () => this.applyBackendVisibility(r.value))); + const test = this.el('th-test'), apply = this.el('th-apply'); + if (test) test.addEventListener('click', () => this.test()); + if (apply) apply.addEventListener('click', () => this.apply()); + await this.load(); + await this.loadEffective(); + }, + + applyBackendVisibility(backend) { + const s = this.el('th-serial'), m = this.el('th-mqtt'); + if (s) s.style.display = backend === 'serial' ? '' : 'none'; + if (m) m.style.display = backend === 'mqtt' ? '' : 'none'; + }, + + setForm(cfg) { + cfg = cfg || {}; + const backend = cfg.backend || 'serial'; + const radio = document.querySelector(`input[name="th-backend"][value="${backend}"]`); + if (radio) radio.checked = true; + this.applyBackendVisibility(backend); + const set = (id, v) => { const e = this.el(id); if (e && v != null) e.value = v; }; + set('th-com', cfg.com_port); set('th-baud', cfg.baud_rate); + set('th-broker', cfg.broker); set('th-port', cfg.port); set('th-user', cfg.user); + set('th-stabilize', cfg.stabilize_timeout); + const pel = this.el('th-peltier'); if (pel) pel.checked = !!cfg.feedback_peltier; + // password intentionally left blank (write-only) + }, + + readForm() { + const backend = (document.querySelector('input[name="th-backend"]:checked') || {}).value || 'serial'; + const cfg = { backend }; + const num = id => { const v = this.el(id) && this.el(id).value; return v === '' || v == null ? null : Number(v); }; + const str = id => { const v = this.el(id) && this.el(id).value; return v == null ? '' : v.trim(); }; + if (backend === 'serial') { + cfg.com_port = str('th-com'); + if (num('th-baud') != null) cfg.baud_rate = num('th-baud'); + } else if (backend === 'mqtt') { + if (str('th-broker')) cfg.broker = str('th-broker'); + if (num('th-port') != null) cfg.port = num('th-port'); + if (str('th-user')) cfg.user = str('th-user'); + const pw = this.el('th-pass') && this.el('th-pass').value; + if (pw) cfg.password = pw; // blank = keep stored (server preserves) + } + if (num('th-stabilize') != null) cfg.stabilize_timeout = num('th-stabilize'); + cfg.feedback_peltier = !!(this.el('th-peltier') && this.el('th-peltier').checked); + return cfg; + }, + + renderStatus(d) { + const el = this.el('th-status'); if (!el) return; + if (!d || d.available === false) { el.textContent = 'Controller not available (device layer offline or no thermalizer configured).'; return; } + const st = d.state || {}; + const parts = []; + if (d.live_backend) parts.push(`backend: ${d.live_backend}`); + if (st.temperature_c != null) parts.push(`water: ${st.temperature_c} °C`); + if (st.setpoint_c != null) parts.push(`setpoint: ${st.setpoint_c} °C`); + if (st.state) parts.push(st.state); + el.textContent = parts.length ? parts.join(' · ') : 'active'; + }, + + async load() { + try { + const res = await fetch('/api/devices/temperature/config'); + const d = await res.json(); + this.renderStatus(d); + if (d && d.config) this.setForm(d.config); + } catch (e) { this.renderStatus(null); } + }, + + result(msg, ok) { + const el = this.el('th-result'); if (!el) return; + el.textContent = msg; + el.className = 'settings-result ' + (ok ? 'is-ok' : 'is-err'); + }, + + async test() { + const btn = this.el('th-test'); btn.disabled = true; const old = btn.textContent; btn.textContent = 'Testing…'; + try { + const res = await fetch('/api/devices/temperature/config/test', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(this.readForm()), + }); + if (res.status === 401 || res.status === 403) { this.result('Need control to test.', false); return; } + const d = await res.json(); + if (d.success) { + const r = d.result || {}; + this.result(`OK — ${r.backend || 'connected'}${r.state ? ' · ' + r.state : ''}${r.temperature_c != null ? ' · ' + r.temperature_c + ' °C' : ''}`, true); + } else { this.result(`Failed: ${d.error || res.status}`, false); } + } catch (e) { this.result(`Error: ${e.message}`, false); } + finally { btn.disabled = false; btn.textContent = old; } + }, + + async apply() { + if (!window.confirm('Apply this thermalizer config to the rig?')) return; + const btn = this.el('th-apply'); btn.disabled = true; const old = btn.textContent; btn.textContent = 'Applying…'; + try { + const res = await fetch('/api/devices/temperature/config', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(this.readForm()), + }); + if (res.status === 401 || res.status === 403) { this.result('Need control to apply.', false); return; } + const d = await res.json(); + // The device-layer 409 (run/ramp active) is flattened to 200 by the + // proxy, so detect it via the body flag, not the HTTP status. + if (d.blocked) { this.result(`Blocked: ${d.error || 'a run/ramp is active'}`, false); return; } + if (d.success && d.applied) { this.result('Applied live.', true); await this.load(); } + else if (d.restart_required) { this.result(`Saved — restart the device layer to apply. (${d.error || ''})`, false); } + else { this.result(`Failed: ${d.error || (d.detail || res.status)}`, false); } + } catch (e) { this.result(`Error: ${e.message}`, false); } + finally { btn.disabled = false; btn.textContent = old; } + }, + + async loadEffective() { + const el = this.el('effective-config'); if (!el) return; + try { + const res = await fetch('/api/config/effective'); + if (!res.ok) { el.textContent = 'Unavailable.'; return; } + const d = await res.json(); + el.textContent = JSON.stringify(d, null, 2); + } catch (e) { el.textContent = 'Unavailable.'; } + }, +}; + +document.addEventListener('DOMContentLoaded', () => ThermalizerSettings.init()); + + +/** + * AdvancedSettings — restart-required settings.py editors, persisted to + * config/settings.local.yml via /api/config/settings-overrides. Renders an + * allowlisted set of tunables; saving does NOT apply live (needs a restart). + */ +const AdvancedSettings = { + el(id) { return document.getElementById(id); }, + + async init() { + if (!this.el('section-advanced')) return; + const save = this.el('adv-save'); + if (save) save.addEventListener('click', () => this.save()); + await this.load(); + }, + + renderField(it) { + const field = document.createElement('div'); + field.className = 'settings-field'; + const tag = it.overridden ? ' (override set)' : ''; + if (it.type === 'bool') { + const lab = document.createElement('label'); + lab.className = 'settings-checkbox'; + lab.innerHTML = ` ${it.label}${tag}`; + lab.querySelector('input').checked = !!it.current; + field.appendChild(lab); + } else { + const lab = document.createElement('label'); + lab.className = 'settings-label'; + lab.innerHTML = it.label + tag; + const inp = document.createElement('input'); + inp.className = 'settings-input'; + inp.dataset.env = it.env; + inp.type = it.type === 'str' ? 'text' : 'number'; + if (it.type === 'float') inp.step = 'any'; + if (it.current != null) inp.value = it.current; + field.appendChild(lab); field.appendChild(inp); + } + return field; + }, + + async load() { + const wrap = this.el('adv-fields'); if (!wrap) return; + try { + const res = await fetch('/api/config/settings-overrides'); + if (!res.ok) { wrap.textContent = 'Unavailable.'; return; } + const d = await res.json(); + const items = d.items || []; + wrap.innerHTML = ''; + // Group items by their `group`, preserving first-seen order. + const groups = []; + const byName = {}; + items.forEach(it => { + const g = it.group || 'Other'; + if (!byName[g]) { byName[g] = []; groups.push(g); } + byName[g].push(it); + }); + groups.forEach(g => { + const head = document.createElement('div'); + head.className = 'settings-subhead'; + head.textContent = g; + wrap.appendChild(head); + byName[g].forEach(it => wrap.appendChild(this.renderField(it))); + }); + } catch (e) { wrap.textContent = 'Unavailable.'; } + }, + + result(msg, ok) { + const el = this.el('adv-result'); if (!el) return; + el.textContent = msg; + el.className = 'settings-result ' + (ok ? 'is-ok' : 'is-err'); + }, + + async save() { + const payload = {}; + document.querySelectorAll('#adv-fields [data-env]').forEach(inp => { + if (inp.type === 'checkbox') payload[inp.dataset.env] = inp.checked; + else if (inp.value !== '') payload[inp.dataset.env] = inp.value; + }); + const btn = this.el('adv-save'); btn.disabled = true; const old = btn.textContent; btn.textContent = 'Saving…'; + try { + const res = await fetch('/api/config/settings-overrides', { + method: 'PUT', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (res.status === 401 || res.status === 403) { this.result('Need control to save.', false); return; } + const d = await res.json(); + if (res.ok) { this.result(`Saved ${(d.saved || []).length} setting(s) — restart the server to apply.`, true); await this.load(); } + else { this.result(`Failed: ${d.detail || res.status}`, false); } + } catch (e) { this.result(`Error: ${e.message}`, false); } + finally { btn.disabled = false; btn.textContent = old; } + }, +}; + +document.addEventListener('DOMContentLoaded', () => AdvancedSettings.init()); diff --git a/gently/ui/web/static/js/shell.js b/gently/ui/web/static/js/shell.js new file mode 100644 index 00000000..c85b2474 --- /dev/null +++ b/gently/ui/web/static/js/shell.js @@ -0,0 +1,58 @@ +/** + * Shell (ux_v2): the grouped left-rail nav (Now / Library / System) + the + * session-context strip that replace the flat 8-tab bar. + * + * CRITICAL: the rail ROUTES THROUGH switchTab(tabId) for every reveal — it + * never reimplements tab activation, so each tab's lazy-init side-effect + * (HomeApp.init, EmbryosManager.clearDetectionBadge, CampaignsApp.init, …) + * still fires. switchTab emits TAB_CHANGED, which keeps the rail's active + * state in sync no matter who switched (rail, keyboard shortcut, home card, + * hash route). No-ops unless body.ux-v2 is present (flag off → v1 untouched). + */ +const Shell = (() => { + let railItems = []; + + function setActive(tabName) { + railItems.forEach(b => b.classList.toggle('active', b.dataset.tab === tabName)); + } + + function currentTab() { + const active = document.querySelector('.tab.active'); + return (active && active.dataset.tab) || + (typeof state !== 'undefined' && state.tab) || 'home'; + } + + function renderStrip(status) { + const el = document.getElementById('v2-strip-status'); + if (!el) return; + const s = status || (typeof ConnectionStatus !== 'undefined' ? ConnectionStatus.get() : {}); + const n = (typeof state !== 'undefined' && Array.isArray(state.embryos)) ? state.embryos.length : 0; + const conn = s.gentlyConnected ? (s.microscopeConnected ? 'Connected' : 'Online') : 'Offline'; + el.textContent = `${n} embryo${n === 1 ? '' : 's'} · ${conn}`; + } + + function init() { + if (!document.body.classList.contains('ux-v2')) return; // flag off → no-op + + railItems = Array.from(document.querySelectorAll('.v2-nav-item')); + railItems.forEach(btn => btn.addEventListener('click', () => { + if (typeof switchTab === 'function') switchTab(btn.dataset.tab); + })); + setActive(currentTab()); + + if (typeof ClientEventBus !== 'undefined') { + ClientEventBus.on('TAB_CHANGED', (tabName) => setActive(tabName)); + ClientEventBus.on('CONNECTION_STATUS', (s) => renderStrip(s)); + } + + const chatBtn = document.getElementById('v2-rail-chat'); + if (chatBtn) chatBtn.addEventListener('click', () => { + if (typeof AgentChat !== 'undefined' && AgentChat.togglePanel) AgentChat.togglePanel(true); + }); + + renderStrip(); + } + + document.addEventListener('DOMContentLoaded', init); + return {}; +})(); diff --git a/gently/ui/web/static/js/status-store.js b/gently/ui/web/static/js/status-store.js new file mode 100644 index 00000000..0e50dd30 --- /dev/null +++ b/gently/ui/web/static/js/status-store.js @@ -0,0 +1,54 @@ +/** + * ConnectionStatus — the single source of truth for connection liveness. + * + * Fixes the "three disagreeing indicators" bug where 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 ONCE at tab init, + * before the /ws handshake, and never corrected — showing "Offline" while the + * header showed "Online"). + * + * Three genuinely distinct signals (kept separate, not flattened): + * - gentlyConnected : the main /ws telemetry socket (websocket.js) + * - microscopeConnected : the /api/device-status health poll (app.js) + * - agentConnected : the /ws/agent chat socket (agent-chat.js) + * + * Writers call set*(); readers subscribe(). The store is STICKY: subscribe() + * immediately replays the current snapshot to the new subscriber, so a late + * subscriber can never miss the initial state. Events only fire on real change. + */ +const ConnectionStatus = (() => { + const s = { gentlyConnected: false, microscopeConnected: false, agentConnected: false }; + + function emit() { + if (typeof ClientEventBus !== 'undefined') { + ClientEventBus.emit('CONNECTION_STATUS', { ...s }); + } + } + + function set(key, val) { + val = !!val; + if (s[key] === val) return; // only emit on actual change + s[key] = val; + emit(); + } + + return { + setGently(v) { set('gentlyConnected', v); }, + setMicroscope(v) { set('microscopeConnected', v); }, + setAgent(v) { set('agentConnected', v); }, + get() { return { ...s }; }, + + /** + * Subscribe to status changes AND immediately receive the current + * snapshot (sticky replay). This is the guard against the original bug: + * a subscriber that registers after the first emit still renders from + * the correct current state instead of a stale default. + */ + subscribe(handler) { + if (typeof ClientEventBus !== 'undefined') { + ClientEventBus.on('CONNECTION_STATUS', handler); + } + try { handler({ ...s }); } catch (e) { console.error('ConnectionStatus subscriber error', e); } + } + }; +})(); diff --git a/gently/ui/web/static/js/temperature-graph.js b/gently/ui/web/static/js/temperature-graph.js new file mode 100644 index 00000000..86129902 --- /dev/null +++ b/gently/ui/web/static/js/temperature-graph.js @@ -0,0 +1,168 @@ +/** + * Temperature Graph — hand-rolled SVG line chart for the Devices tab. + * + * Shows the water-temp trace (solid) and stepped setpoint line (dashed), + * backfilled from /api/temperature/{session}/history and then updated live + * via TEMPERATURE_UPDATE events from the ClientEventBus. + * + * No external dependencies. Calm empty state; never renders mock data. + * + * Usage: + * TemperatureGraph.init(containerEl, 'current'); + * // The component self-subscribes; no extra subscription needed in the caller. + * TemperatureGraph.dispose(); // on teardown + */ +const TemperatureGraph = (() => { + const SVGNS = "http://www.w3.org/2000/svg"; + const MAX_POINTS = 600; // rolling ~10 min @ 1 Hz + + let _root = null; + let _samples = []; + let _session = "current"; + + function init(container, sessionId) { + ClientEventBus.off("TEMPERATURE_UPDATE", onEvent); + _root = container; + _session = sessionId || "current"; + _samples = []; + backfill(); + ClientEventBus.on("TEMPERATURE_UPDATE", onEvent); + } + + async function backfill() { + try { + const r = await fetch(`/api/temperature/${_session}/history`); + if (!r.ok) { renderEmpty(); return; } + const body = await r.json(); + // Adopt the resolved session_id (e.g. 'current' → real id) so that + // subsequent event filtering is consistent. + _session = body.session_id || _session; + _samples = (body.samples || []).slice(-MAX_POINTS); + render(); + } catch (e) { + console.warn('[TemperatureGraph] backfill error:', e); + renderEmpty(); + } + } + + function onEvent(data) { + // data = {session_id, sample: {t, water_c, setpoint_c, state}} + if (!data || !data.sample) return; + _samples.push(data.sample); + if (_samples.length > MAX_POINTS) _samples.shift(); + render(); + } + + function renderEmpty() { + if (!_root) return; + _root.innerHTML = '
    No temperature data yet
    '; + } + + function render() { + if (!_root) return; + if (!_samples.length) { renderEmpty(); return; } + + const W = _root.clientWidth || 480; + const H = 160; + const pad = { top: 12, right: 16, bottom: 20, left: 28 }; + + const ws = _samples.map(s => s.water_c).filter(v => v != null); + const sps = _samples.map(s => s.setpoint_c).filter(v => v != null); + + if (!ws.length) { renderEmpty(); return; } + + const allVals = [...ws, ...sps]; + const lo = Math.min(...allVals) - 0.5; + const hi = Math.max(...allVals) + 0.5; + const range = Math.max(0.001, hi - lo); + const plotW = W - pad.left - pad.right; + const plotH = H - pad.top - pad.bottom; + const n = _samples.length; + + const sx = i => pad.left + (i / Math.max(1, n - 1)) * plotW; + const sy = v => pad.top + plotH - ((v - lo) / range) * plotH; + + const svg = document.createElementNS(SVGNS, "svg"); + svg.setAttribute("viewBox", `0 0 ${W} ${H}`); + svg.setAttribute("width", "100%"); + svg.setAttribute("aria-hidden", "true"); + + // Y-axis gridlines (3 levels) + const gridG = document.createElementNS(SVGNS, "g"); + gridG.setAttribute("class", "temp-graph-grid"); + for (let k = 0; k <= 2; k++) { + const v = lo + (k / 2) * (hi - lo); + const y = sy(v); + const gridLine = document.createElementNS(SVGNS, "line"); + gridLine.setAttribute("x1", pad.left); + gridLine.setAttribute("x2", W - pad.right); + gridLine.setAttribute("y1", y); + gridLine.setAttribute("y2", y); + gridLine.setAttribute("class", "temp-grid-line"); + gridG.appendChild(gridLine); + + const lbl = document.createElementNS(SVGNS, "text"); + lbl.setAttribute("x", pad.left - 3); + lbl.setAttribute("y", y); + lbl.setAttribute("class", "temp-grid-label"); + lbl.textContent = v.toFixed(1); + gridG.appendChild(lbl); + } + svg.appendChild(gridG); + + // Helper: build a polyline from a point-string array + const makeLine = (pts, cls) => { + if (!pts.length) return; + const p = document.createElementNS(SVGNS, "polyline"); + p.setAttribute("points", pts.join(" ")); + p.setAttribute("class", cls); + p.setAttribute("fill", "none"); + svg.appendChild(p); + }; + + // Water temp — skip null samples (gap rather than crash) + const waterPts = []; + _samples.forEach((s, i) => { + if (s.water_c != null) waterPts.push(`${sx(i).toFixed(1)},${sy(s.water_c).toFixed(1)}`); + }); + makeLine(waterPts, "temp-water"); + + // Setpoint — stepped: carry the last known setpoint forward + const spPts = []; + let lastSP = null; + _samples.forEach((s, i) => { + const sp = s.setpoint_c != null ? s.setpoint_c : lastSP; + if (sp != null) { + spPts.push(`${sx(i).toFixed(1)},${sy(sp).toFixed(1)}`); + lastSP = sp; + } + }); + makeLine(spPts, "temp-setpoint"); + + // Current readout line (last sample) + const last = _samples[_samples.length - 1]; + const wStr = last.water_c != null ? `${last.water_c.toFixed(1)} °C` : "—"; + const spStr = last.setpoint_c != null ? `${last.setpoint_c.toFixed(1)} °C` : "—"; + const stStr = last.state ? ` · ${last.state}` : ""; + + const readout = document.createElement("div"); + readout.className = "temp-graph-readout"; + readout.title = "Water temperature → setpoint (state)"; + readout.textContent = `${wStr} → ${spStr}${stStr}`; + + _root.innerHTML = ""; + _root.appendChild(readout); + _root.appendChild(svg); + } + + function dispose() { + ClientEventBus.off("TEMPERATURE_UPDATE", onEvent); + _root = null; + _samples = []; + } + + // Exposed for testing / forced refresh from devices.js + return { init, dispose, _render: render, _samples: () => _samples }; +})(); + +window.TemperatureGraph = TemperatureGraph; diff --git a/gently/ui/web/static/js/utils.js b/gently/ui/web/static/js/utils.js index 4b8ff62b..abf2dee0 100644 --- a/gently/ui/web/static/js/utils.js +++ b/gently/ui/web/static/js/utils.js @@ -3,7 +3,67 @@ // ══════════════════════════════════════════════════════════ // Tab and view name constants -const TABS = { HOME: 'home', EMBRYOS: 'embryos', CALIBRATION: 'calibration', EVENTS: 'events', PLANS: 'plans', SESSIONS: 'sessions', DEVICES: 'devices', EXPERIMENT: 'experiment' }; +const TABS = { HOME: 'home', EMBRYOS: 'embryos', CALIBRATION: 'calibration', EVENTS: 'events', PLANS: 'plans', SESSIONS: 'sessions', DEVICES: 'devices', EXPERIMENT: 'experiment', NOTEBOOK: 'notebook', GALLERY: 'gallery' }; + +/** + * Extract the XY firmware fence (the addressable stage box) from a device-state + * properties map. The ASI adapter exposes LowerLimX/UpperLimX/LowerLimY/ + * UpperLimY in mm; we convert to µm. Single source of truth for both the 2D + * devices map and the 3D optical-space view. + * + * @param {Object} propsByDevice - payload.properties from DEVICE_STATE_UPDATE + * @returns {{x:[number,number], y:[number,number]}|null} + */ +function extractFirmwareBox(propsByDevice) { + if (!propsByDevice) return null; + for (const name of Object.keys(propsByDevice)) { + const p = propsByDevice[name] || {}; + const xMinMm = parseFloat(p['LowerLimX(mm)']); + const xMaxMm = parseFloat(p['UpperLimX(mm)']); + const yMinMm = parseFloat(p['LowerLimY(mm)']); + const yMaxMm = parseFloat(p['UpperLimY(mm)']); + if (isFinite(xMinMm) && isFinite(xMaxMm) && + isFinite(yMinMm) && isFinite(yMaxMm)) { + return { + x: [xMinMm * 1000, xMaxMm * 1000], // mm → µm + y: [yMinMm * 1000, yMaxMm * 1000], + }; + } + } + return null; +} + +/** + * Build a coordinate mapper from device microns to Three.js scene units. + * Centers each axis on its range midpoint and divides by the LARGEST span so + * the whole scene fits a ~[-0.5, 0.5] cube while keeping axes proportional + * (anisotropic Z vs XY preserved). Returns helpers used by all scene objects + * so a single scale governs geometry and camera distance. + * + * @param {{xRange:[number,number], yRange:[number,number], zRange:[number,number]}} ranges (µm) + */ +function makeSceneScaler(ranges) { + const xc = (ranges.xRange[0] + ranges.xRange[1]) / 2; + const yc = (ranges.yRange[0] + ranges.yRange[1]) / 2; + const zc = (ranges.zRange[0] + ranges.zRange[1]) / 2; + const xs = Math.abs(ranges.xRange[1] - ranges.xRange[0]); + const ys = Math.abs(ranges.yRange[1] - ranges.yRange[0]); + const zs = Math.abs(ranges.zRange[1] - ranges.zRange[0]); + const maxExtent = Math.max(xs, ys, zs, 1e-6); + return { + maxExtent, + center: { x: xc, y: yc, z: zc }, + // Map an absolute µm position on one axis into scene space. + toScene(um, axis) { + const c = axis === 'x' ? xc : axis === 'y' ? yc : zc; + return (um - c) / maxExtent; + }, + // Map a µm length (span) into scene units (no centering). + scaleLen(um) { + return um / maxExtent; + }, + }; +} /** * HTML-escape a string (safe for insertion into innerHTML). diff --git a/gently/ui/web/static/js/websocket.js b/gently/ui/web/static/js/websocket.js index 069724a2..cd2ba5d5 100644 --- a/gently/ui/web/static/js/websocket.js +++ b/gently/ui/web/static/js/websocket.js @@ -103,7 +103,9 @@ function handleMessage(msg) { // per event and would lag every other handler), but still emit to the // client event bus so the Devices tab gets the payload. if (msg.event_type !== 'DEVICE_STATE_UPDATE' && - msg.event_type !== 'BOTTOM_CAMERA_FRAME') { + msg.event_type !== 'BOTTOM_CAMERA_FRAME' && + msg.event_type !== 'TEMPERATURE_UPDATE' && + msg.event_type !== 'LIGHTSHEET_FRAME') { handleFullEvent({ event_type: msg.event_type, data: msg.data, diff --git a/gently/ui/web/strategy_snapshot.py b/gently/ui/web/strategy_snapshot.py index 0a246a03..1fb0d07b 100644 --- a/gently/ui/web/strategy_snapshot.py +++ b/gently/ui/web/strategy_snapshot.py @@ -552,6 +552,9 @@ def _build_embryos_static( ], "trigger_events": [], "power_history_488": [{"at": 0.0, "pct": float(laser_488)}], + # Filled in by _replay_timeline when temperature-tactic events arrive. + "temp_protocol": None, + "setpoint_changes": [], # Filled in by _project_forward. "projected_cadence_s": initial_cadence, "projected_end_s": None, @@ -771,6 +774,7 @@ def _replay_timeline( "end": None, "frames": int(data.get("frames") or 0), "hz": hz, + "phase": data.get("phase"), } ) @@ -782,6 +786,28 @@ def _replay_timeline( # closed at now_offset_s by the tail pass below. _close_open_phase(emb, at_s) + elif subtype == "temp_protocol_started" and embryo_id in by_id: + emb = by_id[embryo_id] + emb["temp_protocol"] = { + "start": at_s, + "end": None, + "target_setpoint_c": data.get("target_setpoint_c"), + "frames": data.get("frames"), + "bursts_before": data.get("bursts_before"), + "bursts_after": data.get("bursts_after"), + } + + elif subtype == "temp_protocol_completed" and embryo_id in by_id: + emb = by_id[embryo_id] + tp = emb.get("temp_protocol") + if tp is not None: + tp["end"] = at_s + + elif subtype == "setpoint_changed" and embryo_id in by_id: + emb = by_id[embryo_id] + to_val = data.get("to") + emb["setpoint_changes"].append({"t": at_s, "to": to_val}) + elif subtype == "stopped": # Session-level stop: close every open phase at this time. for emb in embryo_dicts: diff --git a/gently/ui/web/templates/_navbar.html b/gently/ui/web/templates/_navbar.html index 33d5f675..ed02ba49 100644 --- a/gently/ui/web/templates/_navbar.html +++ b/gently/ui/web/templates/_navbar.html @@ -23,6 +23,7 @@
    Plans
    Sessions
    +
    Notebook
    {% else %} {# Standalone pages — all tabs link back to the SPA #} Home @@ -34,5 +35,6 @@
    Plans Sessions + Notebook {% endif %} diff --git a/gently/ui/web/templates/index.html b/gently/ui/web/templates/index.html index 85a60345..14767be8 100644 --- a/gently/ui/web/templates/index.html +++ b/gently/ui/web/templates/index.html @@ -15,19 +15,140 @@ + + + + + - + + {% if ux_v2 %} + {# Agent-first welcome — shown on entry, recedes into the workspace once the + user picks a path. Chat is the last resort (the escape pill), not the + first thing. Only rendered when the flag is on. #} +
    +
    + + {# ── Screen 1: welcome ── #} +
    +
    +
    +
    Hello.
    What are we doing today?
    +
    + +
    + + + +
    + +
    + +
    + + +
    +
    + + +
    + + {# ── Screen 2: the plan wizard, hosted IN the landing. The agent's + ask_user_choice questions render here as button cards (#v2-plan-ask), + NOT in the chat panel. The plan assembles on the right as you pick. #} +
    +
    + +
    +
    Gently · planning
    +
    Let's design your run
    +
    + +
    +
    +
    +
    +
    +
    working through the next step…
    + +
    + +
    +
    + + + + +
    +
    + +
    +
    + {% endif %} {% include '_header.html' %} {% include '_navbar.html' %}
    + {% if ux_v2 %} + + {% endif %}
    + {% if ux_v2 %}
    LIVE
    {% endif %} + + {# ux_v2: the agent's current pending ask, dual-rendered here + in the chat. #} + {% if ux_v2 %}{% endif %} +
    + {% if ux_v2 %}{% endif %}

    Welcome to Gently

    @@ -271,7 +392,7 @@

    Embryo Monitoring

    -

    Experiment

    +

    Operations

    @@ -292,6 +413,31 @@

    Experiment

    {% include '_sessions_panel.html' %}
    + +
    +
    +
    +

    Notebook

    +
    + + + + +
    +
    +
    + + +
    + +
    + +
    +
    +
    +
    +
    @@ -299,8 +445,11 @@

    Experiment

    Device state

    + + +
    @@ -335,6 +484,207 @@

    Device

    + + +
    @@ -386,7 +736,10 @@

    Device

    - + +
    @@ -404,6 +757,7 @@

    Device

    +
    @@ -428,7 +782,12 @@

    Device Bottom camera - +
    + + +

    bottom camera live frame @@ -532,6 +891,360 @@

    Properties

    + + + + + + + + +
    +
    +
    + + + @@ -616,25 +1329,36 @@

    Properties

    + + + + + + + + + + + diff --git a/gently/ui/web/templates/settings.html b/gently/ui/web/templates/settings.html index 9e552936..cc1316cb 100644 --- a/gently/ui/web/templates/settings.html +++ b/gently/ui/web/templates/settings.html @@ -52,6 +52,12 @@

    Settings

    Vitals View Default View
    +
    @@ -172,11 +178,12 @@

    Filmstrip View

    Vitals View

    - +
    +
    C. elegans staging reference curve for the vitals chart — not the hardware controller (see Hardware → Thermalizer).
    +
    +

    Thermalizer (ACUITYnano)

    +
    Machine-wide hardware setting, saved on the server (not this browser). Requires control.
    + +
    + +
    Loading…
    +
    + +
    + +
    + + + +
    +
    + +
    +
    +
    +
    + + +
    +
    + +
    + + +
    +
    +
    + +
    +

    Effective config (read-only)

    +
    The server's live configuration, secrets redacted. Editing these needs a restart / env override.
    +
    Loading…
    +
    + +
    +

    Advanced (restart required)

    +
    System tunables persisted to config/settings.local.yml. Saved values take effect on the next server restart. Requires control.
    +
    Loading…
    +
    + +
    +
    +
    +
    + + + + + + +
    diff --git a/gently/ui/web/timelapse_tracker.py b/gently/ui/web/timelapse_tracker.py index f980001d..965e4019 100644 --- a/gently/ui/web/timelapse_tracker.py +++ b/gently/ui/web/timelapse_tracker.py @@ -216,6 +216,8 @@ def handle_event(self, event_type: str, data: dict): emb["stage_y_um"] = data["y"] if data.get("role"): emb["role"] = data["role"] + if "strain" in data: + emb["strain"] = data.get("strain") if data.get("uid"): emb["uid"] = data["uid"] if data.get("user_label"): @@ -486,6 +488,7 @@ def seed_from_experiment(self, experiment) -> int: "x": x, "y": y, "role": getattr(emb, "role", "test"), + "strain": getattr(emb, "strain", None), "user_label": getattr(emb, "user_label", None), "confidence": getattr(emb, "detection_confidence", None), }, diff --git a/launch_gently.py b/launch_gently.py index cea609e6..6afcbe86 100644 --- a/launch_gently.py +++ b/launch_gently.py @@ -231,6 +231,7 @@ async def main( log_level: str = "WARNING", no_browser: bool = False, no_api: bool = False, + no_auth: bool = False, ): # Set up log file in storage directory # Unified with FileStore: logs live under the same root as data @@ -272,9 +273,16 @@ async def main( # ── Accounts / auth ─────────────────────────────────────────── # Self-managed user accounts gate microscope control on the LAN. On first # run we bootstrap an admin and print its one-time password in the banner. - # Set GENTLY_NO_AUTH=1 to disable accounts (legacy localhost-control mode). + # Pass --no-auth (or set GENTLY_NO_AUTH=1) to disable accounts (localhost-control mode). admin_creds = None - if os.environ.get("GENTLY_NO_AUTH", "").strip().lower() not in ("1", "true", "yes"): + auth_disabled = no_auth or os.environ.get("GENTLY_NO_AUTH", "").strip().lower() in ( + "1", + "true", + "yes", + ) + if auth_disabled: + logger.warning("Accounts disabled (--no-auth) — control is open on this host") + if not auth_disabled: try: from gently.ui.web.accounts import AccountStore, set_account_store @@ -647,6 +655,11 @@ def cli_main(): "-v", "--verbose", action="store_true", help="Enable verbose (INFO) logging" ) parser.add_argument("--debug", action="store_true", help="Enable debug logging (most verbose)") + parser.add_argument( + "--no-auth", + action="store_true", + help="Disable accounts/login (localhost-control mode; same as GENTLY_NO_AUTH=1)", + ) parser.add_argument( "--no-browser", action="store_true", @@ -684,6 +697,7 @@ def cli_main(): log_level=log_level, no_browser=args.no_browser, no_api=args.no_api, + no_auth=args.no_auth, ) ) except (KeyboardInterrupt, RuntimeError, SystemExit): diff --git a/pyproject.toml b/pyproject.toml index 1dedeed7..a64b1898 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,6 +36,11 @@ dependencies = [ "tifffile>=2023.0.0", "scipy>=1.10.0", "scikit-image>=0.21.0", + # OpenCV is imported (unguarded) by detection (sam_detection), the device + # layer, analysis steps, and video_maker — so it's a true runtime dep, not + # optional. headless avoids the libGL.so requirement of full opencv-python + # on headless servers/agents. + "opencv-python-headless>=4.8.0", "jinja2>=3.1.0", "pyyaml>=6.0", "matplotlib>=3.7.0", diff --git a/screenshots/occupancy3d-demo.png b/screenshots/occupancy3d-demo.png new file mode 100644 index 00000000..d7688572 Binary files /dev/null and b/screenshots/occupancy3d-demo.png differ diff --git a/screenshots/ux-v2-landing.png b/screenshots/ux-v2-landing.png new file mode 100644 index 00000000..62502ce3 Binary files /dev/null and b/screenshots/ux-v2-landing.png differ diff --git a/tests/test_burst_laser_config.py b/tests/test_burst_laser_config.py new file mode 100644 index 00000000..079651a6 --- /dev/null +++ b/tests/test_burst_laser_config.py @@ -0,0 +1,22 @@ +"""Task 2: BurstAcquisition laser_config threading tests.""" + +from gently.app.orchestration.exclusive import BurstAcquisition + + +class FakeClient: + def __init__(self): + self.calls = [] + + async def acquire_burst(self, **kw): + self.calls.append(kw) + return {"success": True, "request_id": "b1", "frames": []} + + +async def test_burst_passes_laser_config(monkeypatch): + b = BurstAcquisition("emb1", frames=3, mode="1hz", num_slices=1, laser_config="ALL OFF") + assert b._laser_config == "ALL OFF" + + +async def test_burst_laser_config_default_none(): + b = BurstAcquisition("emb1", frames=3, mode="1hz", num_slices=1) + assert b._laser_config is None diff --git a/tests/test_burst_phase_snapshot.py b/tests/test_burst_phase_snapshot.py new file mode 100644 index 00000000..3218f9a0 --- /dev/null +++ b/tests/test_burst_phase_snapshot.py @@ -0,0 +1,127 @@ +"""Task 2: Burst phase field emitted in BURST_START and recorded in snapshot. + +Feeds _replay_timeline (via build_strategy_snapshot over a hand-written +timeline.jsonl) a burst_started event that carries ``phase="during"`` and +asserts the resulting snapshot burst phase dict includes ``"phase": "during"``. +""" + +import json +from datetime import datetime, timedelta +from pathlib import Path + +import yaml + +from gently.ui.web.strategy_snapshot import build_strategy_snapshot + +SESSION_ID = "sess-bp1" +EMBRYO_ID = "e1" +STARTED_AT = datetime(2026, 6, 28, 10, 0, 0) + + +def _ts(delta_s: float) -> str: + return (STARTED_AT + timedelta(seconds=delta_s)).isoformat() + + +def _write_session(session_dir: Path) -> None: + session_dir.mkdir(parents=True, exist_ok=True) + + # Minimal session.yaml + (session_dir / "session.yaml").write_text( + yaml.dump({"name": "burst-phase test"}), encoding="utf-8" + ) + + # timelapse.yaml — minimal; one embryo + timelapse = { + "started_at": STARTED_AT.isoformat(), + "base_interval_seconds": 120, + "embryos": { + EMBRYO_ID: {"interval_seconds": 120}, + }, + } + (session_dir / "timelapse.yaml").write_text(yaml.dump(timelapse), encoding="utf-8") + + # timeline.jsonl + events = [ + { + "event_id": "ev-bs", + "event_type": "timelapse", + "event_subtype": "burst_started", + "timestamp": _ts(10), + "source": "test", + "session_id": SESSION_ID, + "embryo_id": EMBRYO_ID, + "data": {"embryo_id": EMBRYO_ID, "mode": "1hz", "frames": 60, "phase": "during"}, + }, + { + "event_id": "ev-bc", + "event_type": "timelapse", + "event_subtype": "burst_completed", + "timestamp": _ts(70), + "source": "test", + "session_id": SESSION_ID, + "embryo_id": EMBRYO_ID, + "data": {"embryo_id": EMBRYO_ID}, + }, + ] + timeline_path = session_dir / "timeline.jsonl" + with open(timeline_path, "w", encoding="utf-8") as f: + for ev in events: + f.write(json.dumps(ev) + "\n") + + +def test_burst_phase_recorded(tmp_path: Path) -> None: + """Snapshot burst phase dict carries the phase field from BURST_START data.""" + session_dir = tmp_path / "sessions" / "20260628_1000_test" + _write_session(session_dir) + + snap = build_strategy_snapshot(session_dir, SESSION_ID) + + assert snap["embryos"], "no embryos in snapshot" + emb = snap["embryos"][0] + burst_phases = [p for p in emb["phases"] if p.get("mode") == "burst"] + assert burst_phases, "burst phase not found in snapshot" + bp = burst_phases[0] + assert bp.get("phase") == "during", ( + f"expected phase='during', got {bp.get('phase')!r}; full burst dict: {bp}" + ) + + +def test_burst_phase_none_when_absent(tmp_path: Path) -> None: + """A burst_started event without a phase field records phase=None gracefully.""" + session_dir = tmp_path / "sessions" / "20260628_1000_nophase" + session_dir.mkdir(parents=True, exist_ok=True) + + (session_dir / "session.yaml").write_text( + yaml.dump({"name": "burst-nophase test"}), encoding="utf-8" + ) + timelapse = { + "started_at": STARTED_AT.isoformat(), + "base_interval_seconds": 120, + "embryos": {EMBRYO_ID: {"interval_seconds": 120}}, + } + (session_dir / "timelapse.yaml").write_text(yaml.dump(timelapse), encoding="utf-8") + + events = [ + { + "event_id": "ev-bs2", + "event_type": "timelapse", + "event_subtype": "burst_started", + "timestamp": _ts(10), + "source": "test", + "session_id": SESSION_ID, + "embryo_id": EMBRYO_ID, + "data": {"embryo_id": EMBRYO_ID, "mode": "1hz", "frames": 30}, + }, + ] + with open(session_dir / "timeline.jsonl", "w", encoding="utf-8") as f: + for ev in events: + f.write(json.dumps(ev) + "\n") + + snap = build_strategy_snapshot(session_dir, SESSION_ID) + emb = snap["embryos"][0] + burst_phases = [p for p in emb["phases"] if p.get("mode") == "burst"] + assert burst_phases, "burst phase not found" + bp = burst_phases[0] + # "phase" key must exist; value is None when not supplied + assert "phase" in bp, f"'phase' key absent from burst dict: {bp}" + assert bp["phase"] is None, f"expected None, got {bp['phase']!r}" diff --git a/tests/test_dual_camera_factory.py b/tests/test_dual_camera_factory.py new file mode 100644 index 00000000..2eb649f6 --- /dev/null +++ b/tests/test_dual_camera_factory.py @@ -0,0 +1,164 @@ +"""Tests for B2 Task 2 — dual-camera device_factory (HamCam2 defensive registration). + +Verifies: + - FakeCore with "HamCam2" in getLoadedDevices() → devices["camera_b"] created (name == "HamCam2") + - FakeCore without "HamCam2" → camera_b absent, no crash, devices["camera"] still created + - camera_b registration uses core.getLoadedDevices() as the presence check +""" + +import sys +from unittest.mock import MagicMock + +# Patch heavy hardware deps before importing device_factory. +# All device classes use pymmcore / ophyd.status only (no ophyd base classes), +# so patching at the module level is sufficient. +for _mod in ( + "pymmcore", + "ophyd", + "ophyd.status", + "bluesky", + "bluesky.run_engine", + "gently.hardware.console_ui", +): + if _mod not in sys.modules: + sys.modules[_mod] = MagicMock() + +import bluesky as _bs # noqa: E402 + +_bs.RunEngine = MagicMock(name="RunEngine") + +from gently.hardware.dispim.device_factory import create_devices_from_mmcore # noqa: E402 + + +class FakeCore: + """Minimal CMMCore stand-in with configurable loaded-device list.""" + + def __init__(self, loaded_devices=None): + self._loaded = list(loaded_devices or []) + + def getLoadedDevices(self): + return self._loaded + + # stubs that DiSPIMCamera.__init__ may indirectly trigger via ophyd proxies + def getProperty(self, dev, prop): + return "0" + + def setProperty(self, dev, prop, val): + pass + + def getCameraDevice(self): + return "" + + def setCameraDevice(self, name): + pass + + +# --------------------------------------------------------------------------- +# Happy path — HamCam2 present +# --------------------------------------------------------------------------- + + +def test_camera_b_created_when_hamcam2_loaded(): + """HamCam2 in getLoadedDevices → devices['camera_b'] created with name 'HamCam2'.""" + core = FakeCore(loaded_devices=["HamCam1", "HamCam2"]) + devices = create_devices_from_mmcore(core) + assert "camera_b" in devices, "camera_b must be registered when HamCam2 is in loaded devices" + assert devices["camera_b"].name == "HamCam2" + + +def test_camera_b_name_is_hamcam2(): + """The camera_b name attribute is exactly the configured camera_b_name.""" + core = FakeCore(loaded_devices=["HamCam1", "HamCam2", "Scanner:AB:33"]) + devices = create_devices_from_mmcore(core) + assert devices["camera_b"].name == "HamCam2" + + +# --------------------------------------------------------------------------- +# Single-camera rig — HamCam2 absent +# --------------------------------------------------------------------------- + + +def test_camera_b_absent_when_hamcam2_not_loaded(): + """HamCam2 NOT in getLoadedDevices → camera_b absent, no crash.""" + core = FakeCore(loaded_devices=["HamCam1"]) + devices = create_devices_from_mmcore(core) + assert "camera_b" not in devices, "camera_b must be absent on single-camera rigs" + + +def test_camera_still_created_without_hamcam2(): + """Primary camera (HamCam1) is created regardless of HamCam2 presence.""" + core = FakeCore(loaded_devices=["HamCam1"]) + devices = create_devices_from_mmcore(core) + assert "camera" in devices + assert devices["camera"].name == "HamCam1" + + +# --------------------------------------------------------------------------- +# Edge case — empty loaded-device list +# --------------------------------------------------------------------------- + + +def test_camera_b_absent_on_empty_device_list(): + """Empty loaded-device list → camera_b not attempted, no extra error.""" + core = FakeCore(loaded_devices=[]) + # DiSPIMCamera.__init__ only stores name/core; it succeeds even with no devices. + # So "camera" is still created; "camera_b" is skipped. + devices = create_devices_from_mmcore(core) + assert "camera_b" not in devices + assert "camera" in devices + + +# --------------------------------------------------------------------------- +# Side-B optics: scanner_b / piezo_b defensive registration +# --------------------------------------------------------------------------- + + +def test_scanner_b_created_when_present(): + """Scanner:CD:33 in getLoadedDevices → devices['scanner_b'] with that name.""" + core = FakeCore(loaded_devices=["HamCam1", "Scanner:CD:33"]) + devices = create_devices_from_mmcore(core) + assert "scanner_b" in devices, "scanner_b must be registered when Scanner:CD:33 is loaded" + assert devices["scanner_b"].name == "Scanner:CD:33" + + +def test_scanner_b_absent_when_not_loaded(): + """Scanner:CD:33 NOT loaded → scanner_b absent, no crash.""" + core = FakeCore(loaded_devices=["HamCam1"]) + devices = create_devices_from_mmcore(core) + assert "scanner_b" not in devices + + +def test_piezo_b_created_when_present(): + """PiezoStage:Q:35 in getLoadedDevices → devices['piezo_b'] with that name.""" + core = FakeCore(loaded_devices=["HamCam1", "PiezoStage:Q:35"]) + devices = create_devices_from_mmcore(core) + assert "piezo_b" in devices, "piezo_b must be registered when PiezoStage:Q:35 is loaded" + assert devices["piezo_b"].name == "PiezoStage:Q:35" + + +def test_piezo_b_absent_when_not_loaded(): + """PiezoStage:Q:35 NOT loaded → piezo_b absent, no crash.""" + core = FakeCore(loaded_devices=["HamCam1"]) + devices = create_devices_from_mmcore(core) + assert "piezo_b" not in devices + + +def test_scanner_b_piezo_b_both_present_on_dual_side_rig(): + """Full dual-side rig: both scanner_b and piezo_b created; side A unchanged.""" + core = FakeCore(loaded_devices=["HamCam1", "HamCam2", "Scanner:CD:33", "PiezoStage:Q:35"]) + devices = create_devices_from_mmcore(core) + assert "scanner_b" in devices + assert "piezo_b" in devices + # Side-A optics must still be present + assert "scanner" in devices + assert "piezo" in devices + + +def test_scanner_b_piezo_b_both_absent_on_single_side_rig(): + """Single-side rig: neither scanner_b nor piezo_b created; A-side and camera OK.""" + core = FakeCore(loaded_devices=["HamCam1"]) + devices = create_devices_from_mmcore(core) + assert "scanner_b" not in devices + assert "piezo_b" not in devices + assert "scanner" in devices + assert "piezo" in devices diff --git a/tests/test_embryo_strain.py b/tests/test_embryo_strain.py new file mode 100644 index 00000000..3a0c98a1 --- /dev/null +++ b/tests/test_embryo_strain.py @@ -0,0 +1,232 @@ +"""Tests for per-embryo strain field (D2 Task 1). + +Covers: +- register_embryo with strain persists it through get_embryo +- update without strain coalesces (keeps existing value) +- absent strain on create → None +- /api/embryos/positions includes strain +""" + +from unittest.mock import MagicMock + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +# =========================================================================== +# FileStore tests +# =========================================================================== + + +def test_register_embryo_with_strain(file_store): + """register_embryo with strain; get_embryo returns it.""" + file_store.create_session("s1", name="test") + file_store.register_embryo( + "s1", + "embryo_1", + position_coarse={"x": 10.0, "y": 20.0}, + strain="pan-nuclear GFP", + ) + emb = file_store.get_embryo("s1", "embryo_1") + assert emb is not None + assert emb.get("strain") == "pan-nuclear GFP" + + +def test_register_embryo_strain_absent_is_none(file_store): + """register_embryo without strain → strain is None in get_embryo.""" + file_store.create_session("s2", name="test") + file_store.register_embryo("s2", "embryo_1", position_coarse={"x": 0.0, "y": 0.0}) + emb = file_store.get_embryo("s2", "embryo_1") + assert emb is not None + assert emb.get("strain") is None + + +def test_register_embryo_strain_coalesces_on_update(file_store): + """Update without strain keeps the originally set value (coalesce).""" + file_store.create_session("s3", name="test") + # Create with strain + file_store.register_embryo( + "s3", + "embryo_1", + position_coarse={"x": 5.0, "y": 5.0}, + strain="H2B-mCherry", + ) + # Update with a new position but no strain — existing strain must be kept + file_store.register_embryo( + "s3", + "embryo_1", + position_coarse={"x": 6.0, "y": 6.0}, + ) + emb = file_store.get_embryo("s3", "embryo_1") + assert emb is not None + assert emb.get("strain") == "H2B-mCherry" + + +def test_register_embryo_strain_can_be_overwritten(file_store): + """Passing a new strain on update replaces the old value.""" + file_store.create_session("s4", name="test") + file_store.register_embryo( + "s4", "embryo_1", position_coarse={"x": 0.0, "y": 0.0}, strain="wild-type" + ) + file_store.register_embryo( + "s4", "embryo_1", position_coarse={"x": 0.0, "y": 0.0}, strain="pan-nuclear GFP" + ) + emb = file_store.get_embryo("s4", "embryo_1") + assert emb is not None + assert emb.get("strain") == "pan-nuclear GFP" + + +# =========================================================================== +# /api/embryos/positions endpoint test +# =========================================================================== + + +def _build_client_with_tracker(embryos_dict): + """Build a FastAPI TestClient whose server has a timelapse_tracker.""" + from gently.ui.web.routes.data import create_router + + app = FastAPI() + server = MagicMock() + + tracker = MagicMock() + tracker.embryos = embryos_dict + server.timelapse_tracker = tracker + + # store.get_embryo_ids needed by /api/embryos (not under test here) + server.store.get_embryo_ids.return_value = [] + + app.include_router(create_router(server)) + return TestClient(app) + + +def test_positions_endpoint_includes_strain(): + """embryo_positions response includes the strain key from tracker.""" + client = _build_client_with_tracker( + { + "embryo_1": { + "stage_x_um": 10.0, + "stage_y_um": 20.0, + "role": "test", + "strain": "pan-nuclear GFP", + "uid": "abc", + "user_label": None, + "confidence": 0.9, + "cadence_phase": None, + "is_complete": False, + } + } + ) + resp = client.get("/api/embryos/positions") + assert resp.status_code == 200 + data = resp.json() + assert len(data["embryos"]) == 1 + point = data["embryos"][0] + assert "strain" in point + assert point["strain"] == "pan-nuclear GFP" + + +def test_positions_endpoint_strain_none_when_absent(): + """embryo_positions returns strain=None when tracker dict has no strain key.""" + client = _build_client_with_tracker( + { + "embryo_1": { + "stage_x_um": 1.0, + "stage_y_um": 2.0, + "role": "test", + # no "strain" key + } + } + ) + resp = client.get("/api/embryos/positions") + assert resp.status_code == 200 + point = resp.json()["embryos"][0] + assert "strain" in point + assert point["strain"] is None + + +# =========================================================================== +# TimelapseStateTracker – Fix 1 (strain surfaces through tracker) +# =========================================================================== + + +def test_tracker_embryo_detected_sets_strain(): + """EMBRYO_DETECTED with strain populates the tracker embryo dict.""" + from gently.ui.web.timelapse_tracker import TimelapseStateTracker + + tracker = TimelapseStateTracker() + tracker.handle_event( + "EMBRYO_DETECTED", + { + "embryo_id": "e1", + "x": 100.0, + "y": 200.0, + "role": "test", + "strain": "pan-nuclear GFP", + "uid": "abc", + }, + ) + assert tracker.embryos["e1"]["strain"] == "pan-nuclear GFP" + + +def test_tracker_embryo_detected_strain_none_propagates(): + """EMBRYO_DETECTED with explicit strain=None still writes the key.""" + from gently.ui.web.timelapse_tracker import TimelapseStateTracker + + tracker = TimelapseStateTracker() + tracker.handle_event( + "EMBRYO_DETECTED", + { + "embryo_id": "e2", + "x": 0.0, + "y": 0.0, + "role": "test", + "strain": None, + }, + ) + assert "strain" in tracker.embryos["e2"] + assert tracker.embryos["e2"]["strain"] is None + + +def test_tracker_seed_from_experiment_includes_strain(): + """seed_from_experiment copies strain from EmbryoState into tracker.""" + from types import SimpleNamespace + + from gently.ui.web.timelapse_tracker import TimelapseStateTracker + + emb = SimpleNamespace( + stage_position={"x": 50.0, "y": 60.0}, + uid="u1", + role="test", + strain="H2B-mCherry", + user_label=None, + detection_confidence=0.95, + ) + experiment = SimpleNamespace(embryos={"e3": emb}) + + tracker = TimelapseStateTracker() + seeded = tracker.seed_from_experiment(experiment) + + assert seeded == 1 + assert tracker.embryos["e3"]["strain"] == "H2B-mCherry" + + +def test_tracker_seed_from_experiment_strain_absent_is_none(): + """seed_from_experiment with no strain attribute → tracker strain is None.""" + from types import SimpleNamespace + + from gently.ui.web.timelapse_tracker import TimelapseStateTracker + + emb = SimpleNamespace( + stage_position={"x": 10.0, "y": 20.0}, + uid="u2", + role="control", + # no .strain attribute + user_label=None, + detection_confidence=None, + ) + experiment = SimpleNamespace(embryos={"e4": emb}) + + tracker = TimelapseStateTracker() + tracker.seed_from_experiment(experiment) + + assert "strain" in tracker.embryos["e4"] + assert tracker.embryos["e4"]["strain"] is None diff --git a/tests/test_focus_validation.py b/tests/test_focus_validation.py new file mode 100644 index 00000000..ed07366c --- /dev/null +++ b/tests/test_focus_validation.py @@ -0,0 +1,106 @@ +""" +Tests for analysis.focus_validation — offline replay of passively-logged +manual-focus traces (sub-project C). + +Covers: +- evaluate_sweep finds the metric peak and measures error vs the human Z +- interior-peak + contrast flags +- a flat curve is flagged (no interior peak / zero contrast) +- validate aggregates error stats + within-margin fraction +- segment_sweeps groups by sweep_id +- load_focus_traces round-trips a jsonl file (and skips junk) +""" + +import json +import math + +from gently.analysis.focus_validation import ( + FocusSample, + evaluate_sweep, + load_focus_traces, + segment_sweeps, + validate, +) + + +def _gaussian_sweep(z0, lo=80, hi=160, step=2, amp=1000.0, sigma=12.0, base=50.0): + out = [] + z = lo + while z <= hi: + score = base + amp * math.exp(-((z - z0) ** 2) / (2 * sigma**2)) + out.append(FocusSample(z=float(z), score=float(score))) + z += step + return out + + +def test_evaluate_sweep_finds_peak_near_human(): + sw = _gaussian_sweep(z0=120) + res = evaluate_sweep(sw, human_z=121.0) + assert abs(res.predicted_z - 120) <= 2 # argmax at/near the true peak + assert res.error_um <= 3 + assert res.interior_peak is True + assert res.score_contrast > 0.5 + + +def test_flat_curve_flagged(): + sw = [FocusSample(z=float(z), score=100.0) for z in range(80, 161, 5)] + res = evaluate_sweep(sw, human_z=120.0) + assert res.score_contrast == 0.0 + # a flat curve's argmax is the first sample -> not an interior peak + assert res.interior_peak is False + + +def test_validate_aggregates(): + # three sweeps; human settles within a couple µm of each metric peak + sweeps = [_gaussian_sweep(z0=120), _gaussian_sweep(z0=100), _gaussian_sweep(z0=140)] + human = [121.0, 99.0, 141.0] + rep = validate(sweeps, margin_um=5.0, human_zs=human) + assert rep.n_sweeps == 3 + assert rep.median_error_um <= 3 + assert rep.p95_error_um <= 5 + assert rep.within_margin_frac == 1.0 + assert rep.interior_peak_frac == 1.0 + + +def test_validate_flags_out_of_margin(): + sweeps = [_gaussian_sweep(z0=120)] + rep = validate(sweeps, margin_um=2.0, human_zs=[140.0]) # human far from peak + assert rep.within_margin_frac == 0.0 + assert rep.median_error_um > 2.0 + + +def test_segment_sweeps_by_id(): + samples = [ + FocusSample(z=1, score=1, sweep_id="a"), + FocusSample(z=2, score=2, sweep_id="a"), + FocusSample(z=3, score=3, sweep_id="b"), + ] + groups = segment_sweeps(samples) + assert len(groups) == 2 + assert sorted(len(g) for g in groups) == [1, 2] + + +def test_segment_sweeps_default_single(): + samples = [FocusSample(z=1, score=1), FocusSample(z=2, score=2)] + assert len(segment_sweeps(samples)) == 1 + + +def test_load_focus_traces_roundtrip(tmp_path): + p = tmp_path / "focus_traces.jsonl" + lines = [ + {"z": 100.0, "focus_score": 0.4, "t": 1.0, "source": "bottom"}, + {"z": 110.0, "score": 0.9, "source": "bottom"}, # 'score' alias + {"garbage": True}, # missing z/score -> skipped + "not json", # junk -> skipped + ] + with p.open("w") as f: + for ln in lines: + f.write((json.dumps(ln) if isinstance(ln, dict) else ln) + "\n") + samples = load_focus_traces(p) + assert len(samples) == 2 + assert samples[0].z == 100.0 and samples[0].score == 0.4 + assert samples[1].score == 0.9 + + +def test_load_missing_file_returns_empty(tmp_path): + assert load_focus_traces(tmp_path / "nope.jsonl") == [] diff --git a/tests/test_laser_preset_route.py b/tests/test_laser_preset_route.py new file mode 100644 index 00000000..a7ffc05c --- /dev/null +++ b/tests/test_laser_preset_route.py @@ -0,0 +1,132 @@ +"""Tests for B2 Task 1 — POST /api/devices/laser/config (set-preset proxy). + +Verifies: + - POST /api/devices/laser/config calls client.set_laser_config(config) + - Missing config body key → 400 + - Empty string config → 400 + - No client (microscope not connected) → 503 + - require_control gate (403 without override) +""" + +from unittest.mock import AsyncMock, MagicMock + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +import gently.ui.web.auth as auth +from gently.ui.web.routes.data import create_router + + +def _app(client=None): + """Build a TestClient with the data routes wired up. + + Overrides require_control so the TestClient always gets the control role — + isolates route logic from auth, mirroring the pattern in test_lightsheet_routes. + """ + server = MagicMock() + server.agent_bridge.agent.client = client + server.agent_bridge.agent.lightsheet_monitor = None + app = FastAPI() + app.include_router(create_router(server)) + app.dependency_overrides[auth.require_control] = lambda: True + return TestClient(app) + + +# --------------------------------------------------------------------------- +# Happy path +# --------------------------------------------------------------------------- + + +def test_set_laser_config_calls_client(): + """POST /api/devices/laser/config must call client.set_laser_config(config).""" + client = MagicMock() + client.set_laser_config = AsyncMock(return_value={"success": True}) + r = _app(client=client).post( + "/api/devices/laser/config", + json={"config": "488 only"}, + ) + assert r.status_code == 200 + client.set_laser_config.assert_awaited_once_with("488 only") + + +def test_set_laser_config_all_off(): + """POST /api/devices/laser/config works with 'ALL OFF'.""" + client = MagicMock() + client.set_laser_config = AsyncMock(return_value={"success": True}) + r = _app(client=client).post( + "/api/devices/laser/config", + json={"config": "ALL OFF"}, + ) + assert r.status_code == 200 + client.set_laser_config.assert_awaited_once_with("ALL OFF") + + +# --------------------------------------------------------------------------- +# 400 — missing / invalid config +# --------------------------------------------------------------------------- + + +def test_set_laser_config_missing_key_returns_400(): + """POST without 'config' key in body → 400.""" + client = MagicMock() + r = _app(client=client).post( + "/api/devices/laser/config", + json={"wrong_key": "ALL OFF"}, + ) + assert r.status_code == 400 + + +def test_set_laser_config_empty_string_returns_400(): + """POST with empty string config → 400.""" + client = MagicMock() + r = _app(client=client).post( + "/api/devices/laser/config", + json={"config": ""}, + ) + assert r.status_code == 400 + + +def test_set_laser_config_null_returns_400(): + """POST with null config → 400.""" + client = MagicMock() + r = _app(client=client).post( + "/api/devices/laser/config", + json={"config": None}, + ) + assert r.status_code == 400 + + +# --------------------------------------------------------------------------- +# 503 — no client +# --------------------------------------------------------------------------- + + +def test_set_laser_config_no_client_returns_503(): + """POST when microscope not connected → 503.""" + r = _app(client=None).post( + "/api/devices/laser/config", + json={"config": "ALL OFF"}, + ) + assert r.status_code == 503 + + +# --------------------------------------------------------------------------- +# require_control gate +# --------------------------------------------------------------------------- + + +def test_set_laser_config_requires_control(): + """POST /api/devices/laser/config is gated by require_control.""" + server = MagicMock() + client = MagicMock() + client.set_laser_config = AsyncMock(return_value={"success": True}) + server.agent_bridge.agent.client = client + server.agent_bridge.agent.lightsheet_monitor = None + app = FastAPI() + app.include_router(create_router(server)) + # No dependency override — require_control will reject non-loopback hosts + r = TestClient(app, raise_server_exceptions=False).post( + "/api/devices/laser/config", + json={"config": "ALL OFF"}, + ) + assert r.status_code == 403 diff --git a/tests/test_lightsheet_buffer.py b/tests/test_lightsheet_buffer.py new file mode 100644 index 00000000..657376e0 --- /dev/null +++ b/tests/test_lightsheet_buffer.py @@ -0,0 +1,249 @@ +"""Tests for live-view buffer hardening. + +Verifies: + - snap mode (default) uses snapImage()+getImage() — no circular buffer + - snap mode does NOT call startContinuousSequenceAcquisition + - continuous mode calls setCircularBufferMemoryFootprint(256) before starting + - continuous mode calls clearCircularBuffer() after each getLastImage() + - _stop_lightsheet_sequence_sync calls clearCircularBuffer() in both modes +""" + +import sys +from unittest.mock import MagicMock + +import numpy as np + +# Patch heavy hardware deps before importing device_layer +for _mod in ( + "bluesky", + "bluesky.run_engine", + "ophyd", + "ophyd.status", + "pymmcore", + "gently.hardware.console_ui", +): + if _mod not in sys.modules: + sys.modules[_mod] = MagicMock() + +import bluesky as _bs # noqa: E402 + +_bs.RunEngine = MagicMock(name="RunEngine") + +from gently.hardware.dispim.device_layer import DeviceLayerServer # noqa: E402 + +# --------------------------------------------------------------------------- +# Full-featured FakeCore tracking every buffer-related call +# --------------------------------------------------------------------------- + + +class BufferFakeCore: + """CMMCore stub that records every buffer/snap/sequence call.""" + + def __init__(self): + self.cam = None + self.running = False + self.started = 0 + self.stopped = 0 + self.exposure = None + self.footprint_set: int | None = None # value passed to setCircularBufferMemoryFootprint + self.clear_called = 0 + self.snap_called = 0 + self.get_image_called = 0 + self.get_last_image_called = 0 + + def setCameraDevice(self, n): + self.cam = n + + def getCameraDevice(self): + return self.cam or "" + + def setExposure(self, n, ms): + self.exposure = ms + + def isSequenceRunning(self): + return self.running + + def startContinuousSequenceAcquisition(self, interval): + self.running = True + self.started += 1 + + def stopSequenceAcquisition(self): + self.running = False + self.stopped += 1 + + def setCircularBufferMemoryFootprint(self, mb: int): + self.footprint_set = mb + + def clearCircularBuffer(self): + self.clear_called += 1 + + def snapImage(self): + self.snap_called += 1 + + def getImage(self): + self.get_image_called += 1 + return np.zeros((4, 4), dtype=np.uint16) + + def getLastImage(self): + self.get_last_image_called += 1 + return np.zeros((4, 4), dtype=np.uint16) + + +class FakeAxisOffset: + def __init__(self): + self.last_pos = None + + def setPosition(self, val): + self.last_pos = val + + +class FakeScanner: + def __init__(self, name): + self.name = name + self.sa_offset_y = FakeAxisOffset() + + def set_spim_state(self, state): + pass + + +class FakePiezo: + def __init__(self, name): + self.name = name + self.last_pos = None + + def setPosition(self, val): + self.last_pos = val + + def set_spim_state(self, state): + pass + + +def _make_dl_buffer(mode: str = "snap") -> DeviceLayerServer: + """Build a minimal DL for buffer-hardening tests.""" + dl = DeviceLayerServer.__new__(DeviceLayerServer) + core = BufferFakeCore() + dl.system = type("S", (), {"core": core})() + cam_a = type("C", (), {"name": "HamCam1"})() + dl.devices = { + "camera": cam_a, + "scanner": FakeScanner("Scanner:AB:33"), + "piezo": FakePiezo("PiezoStage:P:34"), + } + dl._ls_params = { + "galvo": 0.0, + "piezo": 50.0, + "exposure": 20.0, + "side": "A", + "mode": mode, + } + dl._ls_seq_started = False + dl._ls_applied = {} + dl._ls_interval_sec = 0.0 + dl._ls_parked = {} + dl._ls_spim_idle = False + return dl + + +# --------------------------------------------------------------------------- +# Snap mode — snapImage + getImage, no circular buffer +# --------------------------------------------------------------------------- + + +def test_snap_mode_calls_snapimage_and_getimage(): + """snap mode: _grab_lightsheet_frame_sync calls snapImage() + getImage().""" + dl = _make_dl_buffer(mode="snap") + dl._grab_lightsheet_frame_sync() + core = dl.system.core + assert core.snap_called >= 1, "snapImage() must be called in snap mode" + assert core.get_image_called >= 1, "getImage() must be called in snap mode" + + +def test_snap_mode_does_not_call_get_last_image(): + """snap mode: getLastImage() is NOT called (avoids the never-draining buffer).""" + dl = _make_dl_buffer(mode="snap") + dl._grab_lightsheet_frame_sync() + assert dl.system.core.get_last_image_called == 0 + + +def test_snap_mode_does_not_start_continuous_sequence(): + """snap mode: startContinuousSequenceAcquisition is never called.""" + dl = _make_dl_buffer(mode="snap") + dl._ensure_lightsheet_sequence_sync() + assert dl.system.core.started == 0, ( + "startContinuousSequenceAcquisition must not be called in snap mode" + ) + + +def test_snap_mode_does_not_start_continuous_on_grab(): + """snap mode grab loop never starts continuous acquisition.""" + dl = _make_dl_buffer(mode="snap") + dl._grab_lightsheet_frame_sync() + assert dl.system.core.started == 0 + + +# --------------------------------------------------------------------------- +# Continuous mode — buffer cap + drain +# --------------------------------------------------------------------------- + + +def test_continuous_mode_sets_circular_buffer_footprint(): + """continuous mode: setCircularBufferMemoryFootprint(256) called before start.""" + dl = _make_dl_buffer(mode="continuous") + dl._ensure_lightsheet_sequence_sync() + core = dl.system.core + assert core.footprint_set is not None, ( + "setCircularBufferMemoryFootprint must be called in continuous mode" + ) + assert core.footprint_set == 256, f"footprint must be 256 MB, got {core.footprint_set}" + + +def test_continuous_mode_calls_clear_after_grab(): + """continuous mode: clearCircularBuffer() called after each getLastImage().""" + dl = _make_dl_buffer(mode="continuous") + dl._grab_lightsheet_frame_sync() + core = dl.system.core + assert core.clear_called >= 1, ( + "clearCircularBuffer() must be called after getLastImage() in continuous mode" + ) + + +def test_continuous_mode_starts_sequence(): + """continuous mode: startContinuousSequenceAcquisition is called.""" + dl = _make_dl_buffer(mode="continuous") + dl._ensure_lightsheet_sequence_sync() + assert dl.system.core.started == 1 + + +# --------------------------------------------------------------------------- +# Stop sequence — clearCircularBuffer on every exit path +# --------------------------------------------------------------------------- + + +def test_stop_calls_clear_circular_buffer(): + """_stop_lightsheet_sequence_sync always calls clearCircularBuffer().""" + dl = _make_dl_buffer(mode="continuous") + # Start a sequence first so there's something to stop + dl._ensure_lightsheet_sequence_sync() + clear_before = dl.system.core.clear_called + dl._stop_lightsheet_sequence_sync() + assert dl.system.core.clear_called > clear_before, ( + "clearCircularBuffer must be called in _stop_lightsheet_sequence_sync" + ) + + +def test_stop_resets_seq_started(): + """_stop_lightsheet_sequence_sync resets _ls_seq_started to False.""" + dl = _make_dl_buffer(mode="continuous") + dl._ensure_lightsheet_sequence_sync() + assert dl._ls_seq_started is True + dl._stop_lightsheet_sequence_sync() + assert dl._ls_seq_started is False + + +def test_stop_clears_applied_state(): + """_stop_lightsheet_sequence_sync clears _ls_applied so next start does a full restart.""" + dl = _make_dl_buffer(mode="continuous") + dl._ensure_lightsheet_sequence_sync() + assert dl._ls_applied != {} + dl._stop_lightsheet_sequence_sync() + assert dl._ls_applied == {} diff --git a/tests/test_lightsheet_client.py b/tests/test_lightsheet_client.py new file mode 100644 index 00000000..c02f2129 --- /dev/null +++ b/tests/test_lightsheet_client.py @@ -0,0 +1,62 @@ +from gently.hardware.dispim.client import DiSPIMMicroscope + + +async def test_set_params_posts_body(monkeypatch): + m = DiSPIMMicroscope.__new__(DiSPIMMicroscope) + sent = {} + + async def fake_post(path, body): + sent["path"] = path + sent["body"] = body + return {"params": body} + + m._api_post = fake_post # confirm the real low-level POST helper name + res = await m.set_lightsheet_live_params(galvo=1.0, piezo=42.0, exposure=15.0) + assert sent["path"] == "/api/lightsheet/live/params" + assert sent["body"] == {"galvo": 1.0, "piezo": 42.0, "exposure": 15.0} + assert res == {"params": {"galvo": 1.0, "piezo": 42.0, "exposure": 15.0}} + + +def test_stream_lightsheet_is_async_generator(): + m = DiSPIMMicroscope.__new__(DiSPIMMicroscope) + import inspect + + assert inspect.isasyncgenfunction(m.stream_lightsheet) + + +# --------------------------------------------------------------------------- +# set_laser_config / get_laser_configs +# --------------------------------------------------------------------------- + + +async def test_set_laser_config_posts_correct_path_and_body(): + """set_laser_config posts to /api/laser/config with {"config": }.""" + m = DiSPIMMicroscope.__new__(DiSPIMMicroscope) + sent = {} + + async def fake_post(path, body): + sent["path"] = path + sent["body"] = body + return {"success": True, "config": body["config"]} + + m._api_post = fake_post + res = await m.set_laser_config("ALL OFF") + assert sent["path"] == "/api/laser/config" + assert sent["body"] == {"config": "ALL OFF"} + assert res["success"] is True + assert res["config"] == "ALL OFF" + + +async def test_get_laser_configs_gets_correct_path(): + """get_laser_configs GETs /api/laser/configs.""" + m = DiSPIMMicroscope.__new__(DiSPIMMicroscope) + fetched = {} + + async def fake_get(path): + fetched["path"] = path + return {"configs": ["488 only", "ALL OFF"]} + + m._api_get = fake_get + res = await m.get_laser_configs() + assert fetched["path"] == "/api/laser/configs" + assert res == {"configs": ["488 only", "ALL OFF"]} diff --git a/tests/test_lightsheet_event.py b/tests/test_lightsheet_event.py new file mode 100644 index 00000000..277b14f1 --- /dev/null +++ b/tests/test_lightsheet_event.py @@ -0,0 +1,22 @@ +"""Tests for LIGHTSHEET_FRAME event type""" + +from gently.core.event_bus import _NO_HISTORY_TYPES, EventBus, EventType + + +def test_lightsheet_frame_event_exists(): + """LIGHTSHEET_FRAME event type should exist with correct name""" + assert EventType.LIGHTSHEET_FRAME.name == "LIGHTSHEET_FRAME" + + +def test_lightsheet_frame_excluded_from_history(): + """LIGHTSHEET_FRAME should be excluded from event history (high-volume telemetry)""" + assert EventType.LIGHTSHEET_FRAME in _NO_HISTORY_TYPES + + +def test_lightsheet_frame_publishes_to_subscriber(): + """Publishing LIGHTSHEET_FRAME should reach subscribers""" + bus = EventBus() + seen = [] + bus.subscribe(EventType.LIGHTSHEET_FRAME, lambda e: seen.append(e.data)) + bus.publish(event_type=EventType.LIGHTSHEET_FRAME, data={"jpeg_b64": "x"}, source="t") + assert seen == [{"jpeg_b64": "x"}] diff --git a/tests/test_lightsheet_monitor.py b/tests/test_lightsheet_monitor.py new file mode 100644 index 00000000..5f3c6bf9 --- /dev/null +++ b/tests/test_lightsheet_monitor.py @@ -0,0 +1,33 @@ +""" +Tests for LightSheetStreamMonitor. + +Mirrors test_bottom_camera_monitor.py structure; uses a FakeScope that yields +three synthetic frames so the test never touches the device layer. +""" + +import asyncio + +import pytest + +from gently.app.lightsheet_monitor import LightSheetStreamMonitor +from gently.core.event_bus import EventType, get_event_bus + + +class FakeScope: + async def stream_lightsheet(self): + for i in range(3): + yield {"t": float(i), "jpeg_b64": f"f{i}"} + await asyncio.sleep(0) + + +@pytest.mark.asyncio +async def test_monitor_publishes_frames(): + bus = get_event_bus() + seen = [] + bus.subscribe(EventType.LIGHTSHEET_FRAME, lambda e: seen.append(e.data)) + mon = LightSheetStreamMonitor(FakeScope(), reconnect_delay_sec=0.01) + await mon.start() + await asyncio.sleep(0.05) + await mon.stop() + assert any(d.get("jpeg_b64") == "f0" for d in seen) + assert mon.running is False diff --git a/tests/test_lightsheet_routes.py b/tests/test_lightsheet_routes.py new file mode 100644 index 00000000..5cb9debe --- /dev/null +++ b/tests/test_lightsheet_routes.py @@ -0,0 +1,317 @@ +"""Tests for Task 5 lightsheet browser proxy routes. + +Verifies: + - live/start, live/stop, live/status — mirror bottom_camera routes + - live/params — forwards to client.set_lightsheet_live_params + - led/set, laser/off, camera/led_mode, stage/move + - acquire/burst, acquire/volume + - require_control gate (403 without override) +""" + +from unittest.mock import AsyncMock, MagicMock + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +import gently.ui.web.auth as auth +from gently.ui.web.routes.data import create_router + + +def _app(client=None, monitor=None): + """Build a TestClient with the lightsheet routes wired up. + + Overrides require_control so that the TestClient (host="testclient") + always gets the control role — isolates route logic from auth. + """ + server = MagicMock() + server.agent_bridge.agent.client = client + server.agent_bridge.agent.lightsheet_monitor = monitor + app = FastAPI() + app.include_router(create_router(server)) + # TestClient host is "testclient" (not loopback) → override to force CONTROL + app.dependency_overrides[auth.require_control] = lambda: True + return TestClient(app) + + +# --------------------------------------------------------------------------- +# live start / stop / status +# --------------------------------------------------------------------------- + + +def test_live_status_no_monitor(): + """GET status returns available=False when monitor is None.""" + r = _app(monitor=None).get("/api/devices/lightsheet/live/status") + assert r.status_code == 200 + assert r.json()["available"] is False + + +def test_live_status_with_monitor(): + monitor = MagicMock() + monitor.running = True + monitor._last_frame_ts = "2026-01-01T00:00:00" + r = _app(monitor=monitor).get("/api/devices/lightsheet/live/status") + assert r.status_code == 200 + body = r.json() + assert body["available"] is True + assert body["streaming"] is True + + +def test_live_start_requires_monitor(): + """POST start with no monitor → 503.""" + r = _app(monitor=None).post("/api/devices/lightsheet/live/start") + assert r.status_code == 503 + + +def test_live_start_calls_monitor_start(): + monitor = MagicMock() + monitor.start = AsyncMock() + monitor.running = True + r = _app(monitor=monitor).post("/api/devices/lightsheet/live/start") + assert r.status_code == 200 + monitor.start.assert_awaited_once() + + +def test_live_stop_no_monitor_returns_200(): + """POST stop with no monitor is idempotent — returns 200 streaming=False.""" + r = _app(monitor=None).post("/api/devices/lightsheet/live/stop") + assert r.status_code == 200 + assert r.json()["streaming"] is False + + +def test_live_stop_calls_monitor_stop(): + monitor = MagicMock() + monitor.stop = AsyncMock() + r = _app(monitor=monitor).post("/api/devices/lightsheet/live/stop") + assert r.status_code == 200 + monitor.stop.assert_awaited_once() + + +# --------------------------------------------------------------------------- +# live/params +# --------------------------------------------------------------------------- + + +def test_live_params_forwards(): + client = MagicMock() + client.set_lightsheet_live_params = AsyncMock(return_value={"params": {}}) + r = _app(client=client).post( + "/api/devices/lightsheet/live/params", + json={"galvo": 1.0, "piezo": 40.0, "exposure": 20.0}, + ) + assert r.status_code == 200 + client.set_lightsheet_live_params.assert_awaited_once_with(galvo=1.0, piezo=40.0, exposure=20.0) + + +def test_live_params_no_client_503(): + server = MagicMock() + server.agent_bridge.agent.client = None + app = FastAPI() + app.include_router(create_router(server)) + app.dependency_overrides[auth.require_control] = lambda: True + r = TestClient(app).post( + "/api/devices/lightsheet/live/params", + json={"galvo": 1.0}, + ) + assert r.status_code == 503 + + +# --------------------------------------------------------------------------- +# led/set +# --------------------------------------------------------------------------- + + +def test_led_set_forwards(): + client = MagicMock() + client.set_led = AsyncMock(return_value={"success": True}) + r = _app(client=client).post("/api/devices/led/set", json={"state": "Open"}) + assert r.status_code == 200 + client.set_led.assert_awaited_once_with("Open") + + +# --------------------------------------------------------------------------- +# laser/off — spec §2.7: must use Laser ALL OFF config, not power setpoint +# --------------------------------------------------------------------------- + + +def test_laser_off_calls_set_laser_config_all_off(): + """laser/off must call set_laser_config("ALL OFF") to gate every line off.""" + client = MagicMock() + client.set_laser_config = AsyncMock(return_value={"success": True}) + r = _app(client=client).post("/api/devices/laser/off") + assert r.status_code == 200 + client.set_laser_config.assert_awaited_once_with("ALL OFF") + + +# --------------------------------------------------------------------------- +# laser/configs — unguarded GET, no require_control +# --------------------------------------------------------------------------- + + +def test_laser_configs_forwards_to_client(): + """GET laser/configs must forward to client.get_laser_configs.""" + client = MagicMock() + client.get_laser_configs = AsyncMock( + return_value={"configs": ["488 only", "561 only", "ALL OFF", "ALL ON"]} + ) + r = _app(client=client).get("/api/devices/laser/configs") + assert r.status_code == 200 + assert r.json()["configs"] == ["488 only", "561 only", "ALL OFF", "ALL ON"] + client.get_laser_configs.assert_awaited_once() + + +def test_laser_configs_no_require_control(): + """GET laser/configs is unguarded — available even without control override.""" + from fastapi import FastAPI + + from gently.ui.web.routes.data import create_router + + server = MagicMock() + client_mock = MagicMock() + client_mock.get_laser_configs = AsyncMock(return_value={"configs": ["ALL OFF"]}) + server.agent_bridge.agent.client = client_mock + server.agent_bridge.agent.lightsheet_monitor = None + app = FastAPI() + app.include_router(create_router(server)) + # Deliberately do NOT override require_control — route must not need it + from fastapi.testclient import TestClient + + r = TestClient(app).get("/api/devices/laser/configs") + assert r.status_code == 200 + + +# --------------------------------------------------------------------------- +# camera/led_mode +# --------------------------------------------------------------------------- + + +def test_camera_led_mode_forwards(): + client = MagicMock() + client.set_camera_led_mode = AsyncMock(return_value={"success": True}) + r = _app(client=client).post("/api/devices/camera/led_mode", json={"use_led": True}) + assert r.status_code == 200 + client.set_camera_led_mode.assert_awaited_once_with(True) + + +# --------------------------------------------------------------------------- +# stage/move +# --------------------------------------------------------------------------- + + +def test_stage_move_forwards(): + client = MagicMock() + client.move_to_position = AsyncMock(return_value={"success": True}) + r = _app(client=client).post("/api/devices/stage/move", json={"x": 100.0, "y": 200.0}) + assert r.status_code == 200 + client.move_to_position.assert_awaited_once_with(100.0, 200.0) + + +def test_stage_move_missing_xy_400(): + client = MagicMock() + client.move_to_position = AsyncMock(return_value={"success": True}) + r = _app(client=client).post("/api/devices/stage/move", json={"x": 1.0}) + assert r.status_code == 400 + + +# --------------------------------------------------------------------------- +# acquire/burst +# --------------------------------------------------------------------------- + + +def test_acquire_burst_forwards(): + """Basic burst forwards without optional params — no laser_config/piezo/galvo in body.""" + client = MagicMock() + client.acquire_burst = AsyncMock(return_value={"success": True, "request_id": "b1"}) + r = _app(client=client).post( + "/api/devices/acquire/burst", + json={"frames": 60, "mode": "1hz", "num_slices": 1, "exposure_ms": 5.0}, + ) + assert r.status_code == 200 and r.json().get("request_id") == "b1" + client.acquire_burst.assert_awaited_once_with( + frames=60, mode="1hz", num_slices=1, exposure_ms=5.0 + ) + + +def test_acquire_burst_forwards_laser_config_and_focal_plane(): + """acquire/burst forwards laser_config, piezo_center, galvo_center when present.""" + client = MagicMock() + client.acquire_burst = AsyncMock(return_value={"success": True}) + r = _app(client=client).post( + "/api/devices/acquire/burst", + json={ + "frames": 10, + "mode": "brightfield", + "num_slices": 50, + "exposure_ms": 20.0, + "laser_config": "ALL OFF", + "piezo_center": 55.0, + "galvo_center": 1.5, + }, + ) + assert r.status_code == 200 + client.acquire_burst.assert_awaited_once_with( + frames=10, + mode="brightfield", + num_slices=50, + exposure_ms=20.0, + laser_config="ALL OFF", + piezo_center=55.0, + galvo_center=1.5, + ) + + +# --------------------------------------------------------------------------- +# acquire/volume +# --------------------------------------------------------------------------- + + +def test_acquire_volume_forwards(): + """Basic volume forwards without optional params.""" + client = MagicMock() + client.acquire_volume = AsyncMock(return_value={"success": True, "request_id": "v1"}) + r = _app(client=client).post( + "/api/devices/acquire/volume", + json={"num_slices": 50, "exposure_ms": 10.0}, + ) + assert r.status_code == 200 + client.acquire_volume.assert_awaited_once_with(num_slices=50, exposure_ms=10.0) + + +def test_acquire_volume_forwards_laser_config_and_focal_plane(): + """acquire/volume forwards laser_config, piezo_center, galvo_center when present.""" + client = MagicMock() + client.acquire_volume = AsyncMock(return_value={"success": True}) + r = _app(client=client).post( + "/api/devices/acquire/volume", + json={ + "num_slices": 50, + "exposure_ms": 20.0, + "laser_config": "ALL OFF", + "piezo_center": 55.0, + "galvo_center": 1.5, + }, + ) + assert r.status_code == 200 + client.acquire_volume.assert_awaited_once_with( + num_slices=50, + exposure_ms=20.0, + laser_config="ALL OFF", + piezo_center=55.0, + galvo_center=1.5, + ) + + +# --------------------------------------------------------------------------- +# require_control gate — 403 WITHOUT override +# --------------------------------------------------------------------------- + + +def test_require_control_gate_403(): + """Without the dependency override, TestClient host is not loopback → 403.""" + server = MagicMock() + server.agent_bridge.agent.client = MagicMock() + server.agent_bridge.agent.lightsheet_monitor = None + app = FastAPI() + app.include_router(create_router(server)) + # Do NOT override require_control here + r = TestClient(app).post("/api/devices/lightsheet/live/start") + assert r.status_code == 403 diff --git a/tests/test_lightsheet_side_param.py b/tests/test_lightsheet_side_param.py new file mode 100644 index 00000000..3f276b76 --- /dev/null +++ b/tests/test_lightsheet_side_param.py @@ -0,0 +1,346 @@ +"""Tests for B2 Task 2 — lightsheet side param (handle_lightsheet_params + +_ensure_lightsheet_sequence_sync + handle_get_cameras). + +Verifies: + - handle_lightsheet_params accepts 'side' ('A'|'B'); invalid values ignored + - side change in _ensure_lightsheet_sequence_sync selects the correct camera + and triggers a sequence restart (mirroring the exposure-change path) + - side B without camera_b falls back to A + no crash + - handle_get_cameras returns ["A"] or ["A","B"] based on devices dict +""" + +import json +import sys +from unittest.mock import AsyncMock, MagicMock + +import pytest + +# Patch heavy hardware deps before importing device_layer +for _mod in ( + "bluesky", + "bluesky.run_engine", + "ophyd", + "ophyd.status", + "pymmcore", + "gently.hardware.console_ui", +): + if _mod not in sys.modules: + sys.modules[_mod] = MagicMock() + +import bluesky as _bs # noqa: E402 + +_bs.RunEngine = MagicMock(name="RunEngine") + +from gently.hardware.dispim.device_layer import DeviceLayerServer # noqa: E402 + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +class FakeCore: + """Minimal CMMCore stub tracking sequence-start/stop calls.""" + + def __init__(self): + self.running = False + self.cam = None + self.started = 0 + self.stopped = 0 + self.exposure = None + + def setCameraDevice(self, n): + self.cam = n + + def getCameraDevice(self): + return self.cam + + def setExposure(self, n, ms): + self.exposure = ms + + def startContinuousSequenceAcquisition(self, interval): + self.running = True + self.started += 1 + + def stopSequenceAcquisition(self): + self.running = False + self.stopped += 1 + + def isSequenceRunning(self): + return self.running + + def setCircularBufferMemoryFootprint(self, mb): + pass # no-op for camera-selection tests + + def clearCircularBuffer(self): + pass # no-op for camera-selection tests + + +class FakeAxisOffset: + """Stub for DiSPIMScanner.sa_offset_y tracking setPosition calls.""" + + def __init__(self): + self.last_pos = None + + def setPosition(self, val: float) -> None: + self.last_pos = val + + +class FakeScanner: + """Stub scanner with sa_offset_y and set_spim_state.""" + + def __init__(self, name: str): + self.name = name + self.sa_offset_y = FakeAxisOffset() + self.spim_state: str | None = None + + def set_spim_state(self, state: str) -> None: + self.spim_state = state + + +class FakePiezo: + """Stub piezo tracking setPosition and set_spim_state calls.""" + + def __init__(self, name: str): + self.name = name + self.last_pos: float | None = None + self.spim_state: str | None = None + + def setPosition(self, val: float) -> None: + self.last_pos = val + + def set_spim_state(self, state: str) -> None: + self.spim_state = state + + +def _make_dl( + with_camera_b: bool = True, + with_scanner_b: bool = False, + with_piezo_b: bool = False, +) -> DeviceLayerServer: + """Build a minimal DeviceLayerServer with faked core + devices. + + mode is set to "continuous" so existing sequence-start assertions remain + meaningful (snap mode is the production default but tested separately). + """ + dl = DeviceLayerServer.__new__(DeviceLayerServer) + dl.system = type("S", (), {"core": FakeCore()})() + cam_a = type("C", (), {"name": "HamCam1"})() + cam_b = type("C", (), {"name": "HamCam2"})() + dl.devices = { + "camera": cam_a, + "scanner": FakeScanner("Scanner:AB:33"), + "piezo": FakePiezo("PiezoStage:P:34"), + } + if with_camera_b: + dl.devices["camera_b"] = cam_b + if with_scanner_b: + dl.devices["scanner_b"] = FakeScanner("Scanner:CD:33") + if with_piezo_b: + dl.devices["piezo_b"] = FakePiezo("PiezoStage:Q:35") + dl._ls_params = { + "galvo": 0.0, + "piezo": 50.0, + "exposure": 20.0, + "side": "A", + "mode": "continuous", # explicit — snap mode tested in test_lightsheet_buffer.py + } + dl._ls_seq_started = False + dl._ls_applied = {} + dl._ls_interval_sec = 0.0 + dl._ls_parked = {} + dl._ls_spim_idle = False + return dl + + +# --------------------------------------------------------------------------- +# _ensure_lightsheet_sequence_sync — camera selection by side +# --------------------------------------------------------------------------- + + +def test_side_a_selects_camera_a(): + """Side A → sequence started on HamCam1.""" + dl = _make_dl() + dl._ls_params["side"] = "A" + dl._ensure_lightsheet_sequence_sync() + assert dl.system.core.cam == "HamCam1" + assert dl.system.core.started == 1 + + +def test_side_b_selects_camera_b(): + """Side B with camera_b present → sequence started on HamCam2.""" + dl = _make_dl(with_camera_b=True) + dl._ls_params["side"] = "B" + dl._ensure_lightsheet_sequence_sync() + assert dl.system.core.cam == "HamCam2" + assert dl.system.core.started == 1 + + +def test_side_b_fallback_to_a_when_no_camera_b(): + """Side B without camera_b → graceful fallback to A (HamCam1), no crash.""" + dl = _make_dl(with_camera_b=False) + dl._ls_params["side"] = "B" + dl._ensure_lightsheet_sequence_sync() + assert dl.system.core.cam == "HamCam1" + assert dl.system.core.started == 1 + + +def test_side_change_triggers_restart(): + """Switching from A to B stops the running sequence and restarts on HamCam2.""" + dl = _make_dl(with_camera_b=True) + # First call — start on A + dl._ls_params["side"] = "A" + dl._ensure_lightsheet_sequence_sync() + assert dl.system.core.started == 1 + assert dl.system.core.cam == "HamCam1" + + # Switch to B — must restart + dl._ls_params["side"] = "B" + dl._ensure_lightsheet_sequence_sync() + assert dl.system.core.stopped >= 1 + assert dl.system.core.started >= 2 + assert dl.system.core.cam == "HamCam2" + + +def test_no_restart_when_side_unchanged(): + """Same side, same exposure → sequence already running, no extra restart.""" + dl = _make_dl() + dl._ls_params["side"] = "A" + dl._ensure_lightsheet_sequence_sync() + starts_after_first = dl.system.core.started + + # Manually mark the applied state (as the real code does) + dl._ls_applied["exposure"] = 20.0 + dl._ls_applied["side"] = "A" + + dl._ensure_lightsheet_sequence_sync() + assert dl.system.core.started == starts_after_first + + +# --------------------------------------------------------------------------- +# handle_lightsheet_params — side accepted / validated +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_handle_lightsheet_params_accepts_side_b(): + """POST {side: 'B'} → _ls_params['side'] updated to 'B'.""" + dl = _make_dl() + req = MagicMock() + req.json = AsyncMock(return_value={"side": "B"}) + await dl.handle_lightsheet_params(req) + assert dl._ls_params["side"] == "B" + + +@pytest.mark.asyncio +async def test_handle_lightsheet_params_accepts_side_a(): + """POST {side: 'A'} when already A → _ls_params['side'] stays 'A'.""" + dl = _make_dl() + req = MagicMock() + req.json = AsyncMock(return_value={"side": "A"}) + await dl.handle_lightsheet_params(req) + assert dl._ls_params["side"] == "A" + + +@pytest.mark.asyncio +async def test_handle_lightsheet_params_ignores_invalid_side(): + """Invalid side value (not 'A'|'B') → _ls_params['side'] unchanged.""" + dl = _make_dl() + dl._ls_params["side"] = "A" + req = MagicMock() + req.json = AsyncMock(return_value={"side": "C"}) + await dl.handle_lightsheet_params(req) + assert dl._ls_params["side"] == "A" + + +@pytest.mark.asyncio +async def test_handle_lightsheet_params_accepts_exposure_and_side(): + """POST with both exposure and side → both applied.""" + dl = _make_dl() + req = MagicMock() + req.json = AsyncMock(return_value={"exposure": 50.0, "side": "B"}) + await dl.handle_lightsheet_params(req) + assert dl._ls_params["exposure"] == 50.0 + assert dl._ls_params["side"] == "B" + + +# --------------------------------------------------------------------------- +# handle_get_cameras — roles endpoint +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_handle_get_cameras_with_camera_b(): + """Both A and B listed when camera_b is registered.""" + dl = _make_dl(with_camera_b=True) + req = MagicMock() + resp = await dl.handle_get_cameras(req) + body = json.loads(resp.body) + assert body["cameras"] == ["A", "B"] + + +@pytest.mark.asyncio +async def test_handle_get_cameras_without_camera_b(): + """Only A listed when camera_b is absent.""" + dl = _make_dl(with_camera_b=False) + req = MagicMock() + resp = await dl.handle_get_cameras(req) + body = json.loads(resp.body) + assert body["cameras"] == ["A"] + + +# --------------------------------------------------------------------------- +# _park_lightsheet_sync — side selects correct scanner/piezo +# --------------------------------------------------------------------------- + + +def test_side_a_parks_scanner_a(): + """Side A → _park_lightsheet_sync drives scanner A's galvo.""" + dl = _make_dl() + dl._ls_params["side"] = "A" + dl._park_lightsheet_sync() + assert dl.devices["scanner"].sa_offset_y.last_pos is not None + + +def test_side_b_parks_scanner_b(): + """Side B with scanner_b present → _park_lightsheet_sync drives scanner B.""" + dl = _make_dl(with_scanner_b=True) + dl._ls_params["side"] = "B" + dl._park_lightsheet_sync() + assert dl.devices["scanner_b"].sa_offset_y.last_pos is not None + # Side A scanner must be untouched + assert dl.devices["scanner"].sa_offset_y.last_pos is None + + +def test_side_b_scanner_b_absent_falls_back_to_scanner_a(): + """Side B without scanner_b → graceful fallback to scanner A, no crash.""" + dl = _make_dl(with_scanner_b=False) + dl._ls_params["side"] = "B" + dl._park_lightsheet_sync() + # Fallback: scanner A gets the park + assert dl.devices["scanner"].sa_offset_y.last_pos is not None + + +def test_side_a_parks_piezo_a(): + """Side A → _park_lightsheet_sync drives piezo A.""" + dl = _make_dl() + dl._ls_params["side"] = "A" + dl._park_lightsheet_sync() + assert dl.devices["piezo"].last_pos is not None + + +def test_side_b_parks_piezo_b(): + """Side B with piezo_b present → _park_lightsheet_sync drives piezo B.""" + dl = _make_dl(with_scanner_b=True, with_piezo_b=True) + dl._ls_params["side"] = "B" + dl._park_lightsheet_sync() + assert dl.devices["piezo_b"].last_pos is not None + # Side A piezo must be untouched + assert dl.devices["piezo"].last_pos is None + + +def test_side_b_piezo_b_absent_falls_back_to_piezo_a(): + """Side B without piezo_b → graceful fallback to piezo A, no crash.""" + dl = _make_dl(with_piezo_b=False) + dl._ls_params["side"] = "B" + dl._park_lightsheet_sync() + assert dl.devices["piezo"].last_pos is not None diff --git a/tests/test_lightsheet_streamer.py b/tests/test_lightsheet_streamer.py new file mode 100644 index 00000000..076d2496 --- /dev/null +++ b/tests/test_lightsheet_streamer.py @@ -0,0 +1,139 @@ +# tests/test_lightsheet_streamer.py +import sys +from unittest.mock import MagicMock + +# Patch heavy hardware deps before importing device_layer +for _mod in ( + "bluesky", + "bluesky.run_engine", + "ophyd", + "ophyd.status", + "pymmcore", + "gently.hardware.console_ui", +): + if _mod not in sys.modules: + sys.modules[_mod] = MagicMock() + +# Patch bluesky.RunEngine specifically +import bluesky as _bs # noqa: E402 + +_bs.RunEngine = MagicMock(name="RunEngine") + +import asyncio # noqa: E402 + +import numpy as np # noqa: E402 +import pytest # noqa: E402 + +from gently.hardware.dispim.device_layer import DeviceLayerServer # noqa: E402 + + +class FakeCore: + def __init__(self): + self.running = False + self.exposure = None + self.cam = None + self._frame = np.full((64, 64), 1000, dtype=np.uint16) + self.started = 0 + self.stopped = 0 + + def setCameraDevice(self, n): + self.cam = n + + def getCameraDevice(self): + return self.cam + + def setExposure(self, n, ms): + self.exposure = ms + + def startContinuousSequenceAcquisition(self, interval): + self.running = True + self.started += 1 + + def stopSequenceAcquisition(self): + self.running = False + self.stopped += 1 + + def isSequenceRunning(self): + return self.running + + def getLastImage(self): + return self._frame + + +class FakeAxis: + def __init__(self): + self.pos = None + + def setPosition(self, v): + self.pos = v + + +class FakeScanner: + def __init__(self): + self.sa_offset_y = FakeAxis() + self.name = "Scanner" + self.state = None + + def set_spim_state(self, s): + self.state = s + + +class FakePiezo(FakeAxis): + def __init__(self): + super().__init__() + self.name = "Piezo" + self.state = None + + def set_spim_state(self, s): + self.state = s + + +def _make_dl(): + dl = DeviceLayerServer.__new__(DeviceLayerServer) + dl.system = type("S", (), {"core": FakeCore()})() + dl.devices = { + "camera": type("C", (), {"name": "HamCam1"})(), + "scanner": FakeScanner(), + "piezo": FakePiezo(), + } + # Park-guard state (park-guard fix: these are checked by _park_lightsheet_sync) + dl._ls_parked = {} + dl._ls_spim_idle = False + return dl + + +@pytest.mark.asyncio +async def test_grab_parks_and_peeks(monkeypatch): + dl = _make_dl() + dl._state_pause_counter = 0 + dl._ls_target_max_dim = 512 + dl._ls_jpeg_quality = 70 + # Explicit continuous mode: this test verifies the sequence-start + getLastImage path. + dl._ls_params = {"galvo": 1.5, "piezo": 40.0, "exposure": 20.0, "mode": "continuous"} + dl._ls_seq_started = False + dl._ls_applied = {} + dl._ls_interval_sec = 0.0 + img = await asyncio.to_thread(dl._grab_lightsheet_frame_sync) + assert img is not None and img.shape == (64, 64) + assert dl.system.core.running is True # sequence started + assert dl.devices["piezo"].pos == 40.0 # piezo parked + assert dl.devices["scanner"].sa_offset_y.pos == 1.5 # galvo parked + + +@pytest.mark.asyncio +async def test_exposure_change_restarts_sequence(): + dl = _make_dl() + dl._state_pause_counter = 0 + dl._ls_target_max_dim = 512 + dl._ls_jpeg_quality = 70 + # Explicit continuous mode: this test verifies restart on exposure change. + dl._ls_params = {"galvo": 0.0, "piezo": 50.0, "exposure": 10.0, "mode": "continuous"} + dl._ls_seq_started = False + dl._ls_applied = {} + dl._ls_interval_sec = 0.0 + await asyncio.to_thread(dl._grab_lightsheet_frame_sync) + starts = dl.system.core.started + dl._ls_params["exposure"] = 30.0 # exposure change + await asyncio.to_thread(dl._grab_lightsheet_frame_sync) + assert dl.system.core.stopped >= 1 and dl.system.core.started == starts + 1 + assert dl.system.core.exposure == 30.0 diff --git a/tests/test_notebook_api.py b/tests/test_notebook_api.py new file mode 100644 index 00000000..408fc423 --- /dev/null +++ b/tests/test_notebook_api.py @@ -0,0 +1,160 @@ +"""Tests for the notebook read API.""" + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from gently.harness.memory.notebook import Note, NoteKind, NoteStatus + + +def _make_app(context_store): + from gently.ui.web.routes.notebook import create_router + + app = FastAPI() + + class _Server: + pass + + server = _Server() + server.context_store = context_store + app.include_router(create_router(server)) + return app + + +def _seed(cs): + nb = cs.notebook + nb.write_note(Note(id="o1", kind=NoteKind.OBSERVATION, body="ring formed", strains=["N2"])) + nb.write_note( + Note( + id="f1", + kind=NoteKind.FINDING, + body="rings by comma", + status=NoteStatus.PROPOSED, + strains=["N2"], + threads=["t1"], + ) + ) + return cs + + +class TestListNotes: + def test_no_store_available_false(self): + client = TestClient(_make_app(None)) + data = client.get("/api/notebook/notes").json() + assert data == {"available": False, "notes": []} + + def test_list_all(self, file_context_store): + client = TestClient(_make_app(_seed(file_context_store))) + data = client.get("/api/notebook/notes").json() + assert data["available"] is True + assert {n["id"] for n in data["notes"]} == {"o1", "f1"} + + def test_filter_by_kind(self, file_context_store): + client = TestClient(_make_app(_seed(file_context_store))) + data = client.get("/api/notebook/notes?kind=finding").json() + assert {n["id"] for n in data["notes"]} == {"f1"} + + def test_filter_by_strain(self, file_context_store): + client = TestClient(_make_app(_seed(file_context_store))) + data = client.get("/api/notebook/notes?strain=N2").json() + assert {n["id"] for n in data["notes"]} == {"o1", "f1"} + + def test_invalid_kind_is_ignored(self, file_context_store): + client = TestClient(_make_app(_seed(file_context_store))) + data = client.get("/api/notebook/notes?kind=bogus").json() + assert {n["id"] for n in data["notes"]} == {"o1", "f1"} + + +class TestGetNote: + def test_get_existing(self, file_context_store): + client = TestClient(_make_app(_seed(file_context_store))) + data = client.get("/api/notebook/notes/o1").json() + assert data["id"] == "o1" + assert data["body"] == "ring formed" + + def test_get_missing_404(self, file_context_store): + client = TestClient(_make_app(_seed(file_context_store))) + resp = client.get("/api/notebook/notes/nope") + assert resp.status_code == 404 + + +class TestThreads: + def test_no_store(self): + client = TestClient(_make_app(None)) + assert client.get("/api/notebook/threads").json() == {"available": False, "threads": []} + + def test_thread_counts(self, file_context_store): + cs = file_context_store + nb = cs.notebook + nb.write_note(Note(id="a", kind=NoteKind.QUESTION, body="q", threads=["t1"])) + nb.write_note(Note(id="b", kind=NoteKind.FINDING, body="f", threads=["t1", "t2"])) + client = TestClient(_make_app(cs)) + data = client.get("/api/notebook/threads").json() + assert data["available"] is True + assert data["threads"] == [{"id": "t1", "count": 2}, {"id": "t2", "count": 1}] + + +class TestLimit: + def test_limit_returns_newest(self, file_context_store): + client = TestClient(_make_app(_seed(file_context_store))) + data = client.get("/api/notebook/notes?limit=1").json() + assert len(data["notes"]) == 1 # newest-first, capped + + +class _AskBlock: + def __init__(self, inp): + self.type = "tool_use" + self.input = inp + + +class _AskResp: + def __init__(self, inp): + self.content = [_AskBlock(inp)] + self.stop_reason = "tool_use" + + +class _AskMessages: + def __init__(self, inp): + self._inp = inp + + async def create(self, **kwargs): + return _AskResp(self._inp) + + +class _AskClient: + def __init__(self, inp): + self.messages = _AskMessages(inp) + + +def _make_app_with_client(context_store, client): + from gently.ui.web.routes.notebook import create_router + + app = FastAPI() + + class _Server: + pass + + server = _Server() + server.context_store = context_store + server.claude_async = client + app.include_router(create_router(server)) + return app + + +class TestAsk: + def test_ask_returns_grounded_answer(self, file_context_store): + cs = _seed(file_context_store) + canned = { + "answer": "A ring formed.", + "points": [{"text": "ring", "note_ids": ["o1"]}], + "suggested_next": [], + "coverage": "covered", + } + client = TestClient(_make_app_with_client(cs, _AskClient(canned))) + resp = client.post("/api/notebook/ask", json={"question": "what happened?"}) + assert resp.status_code == 200 + assert resp.json()["coverage"] == "covered" + + def test_ask_no_store(self): + client = TestClient(_make_app(None)) + resp = client.post("/api/notebook/ask", json={"question": "x"}) + assert resp.json() == {"available": False} diff --git a/tests/test_notebook_ask.py b/tests/test_notebook_ask.py new file mode 100644 index 00000000..0cf664d7 --- /dev/null +++ b/tests/test_notebook_ask.py @@ -0,0 +1,95 @@ +"""Tests for 'Ask the notebook' — retrieval + grounded synthesis.""" + +import asyncio + +from gently.harness.memory.notebook import Note, NoteKind +from gently.harness.memory.notebook_ask import ( + ASK_TOOL, + answer_question, + build_ask_messages, + select_notes, +) + + +def _seed(cs): + nb = cs.notebook + nb.write_note( + Note(id="o1", kind=NoteKind.OBSERVATION, body="ring formed", strains=["N2"], threads=["t1"]) + ) + nb.write_note( + Note(id="f1", kind=NoteKind.FINDING, body="12 min/degC", strains=["N2"], threads=["t1"]) + ) + nb.write_note(Note(id="x1", kind=NoteKind.OBSERVATION, body="unrelated", strains=["OH904"])) + return nb + + +class TestSelectNotes: + def test_scope_by_thread(self, file_context_store): + nb = _seed(file_context_store) + ids = {n.id for n in select_notes(nb, thread="t1")} + assert ids == {"o1", "f1"} + + def test_scope_by_strain(self, file_context_store): + nb = _seed(file_context_store) + ids = {n.id for n in select_notes(nb, strain="OH904")} + assert ids == {"x1"} + + def test_no_scope_returns_recent_capped(self, file_context_store): + nb = _seed(file_context_store) + notes = select_notes(nb, limit=2) + assert len(notes) == 2 # newest-first, capped + + +class _FakeBlock: + def __init__(self, inp): + self.type = "tool_use" + self.input = inp + + +class _FakeResp: + def __init__(self, inp): + self.content = [_FakeBlock(inp)] + self.stop_reason = "tool_use" + + +class _FakeMessages: + def __init__(self, captured, inp): + self._captured, self._inp = captured, inp + + async def create(self, **kwargs): + self._captured.update(kwargs) + return _FakeResp(self._inp) + + +class _FakeClient: + def __init__(self, inp): + self.captured = {} + self.messages = _FakeMessages(self.captured, inp) + + +class TestAnswerQuestion: + def test_build_messages_embeds_note_ids(self): + notes = [Note(id="o1", kind=NoteKind.OBSERVATION, body="ring formed")] + msgs = build_ask_messages("what formed?", notes) + text = msgs[0]["content"] + assert "o1" in text and "ring formed" in text and "what formed?" in text + + def test_answer_returns_structured_and_forces_tool(self): + canned = { + "answer": "A ring formed.", + "points": [{"text": "ring formed", "note_ids": ["o1"]}], + "suggested_next": [], + "coverage": "covered", + } + client = _FakeClient(canned) + notes = [Note(id="o1", kind=NoteKind.OBSERVATION, body="ring formed")] + out = asyncio.run(answer_question(client, "m", "what formed?", notes)) + assert out == canned + assert client.captured["tool_choice"] == {"type": "tool", "name": ASK_TOOL["name"]} + assert client.captured["model"] == "m" + + def test_answer_no_notes_short_circuits_without_api(self): + client = _FakeClient({"should": "not be used"}) + out = asyncio.run(answer_question(client, "m", "anything?", [])) + assert out["coverage"] == "not_in_notebook" + assert client.captured == {} # no API call when nothing to ground on diff --git a/tests/test_notebook_store.py b/tests/test_notebook_store.py new file mode 100644 index 00000000..ff8cc715 --- /dev/null +++ b/tests/test_notebook_store.py @@ -0,0 +1,240 @@ +"""Tests for the shared lab notebook: Note model + NotebookStore.""" + +from datetime import datetime + +from gently.harness.memory.model import Confidence, Learning, Observation +from gently.harness.memory.notebook import ( + Author, + Note, + NotebookStore, + NoteKind, + NoteStatus, + learning_to_note, + note_from_dict, + note_to_dict, + observation_to_note, +) + + +class TestNoteModel: + def test_round_trip_minimal(self): + n = Note(id="abc123", kind=NoteKind.OBSERVATION, body="dim rings at 10 ms") + d = note_to_dict(n) + assert d["kind"] == "observation" + assert d["author"] == "agent" # default + assert d["status"] == "confirmed" # default + back = note_from_dict(d) + assert back == n + + def test_round_trip_full(self): + n = Note( + id="def456", + kind=NoteKind.FINDING, + body="temperature shifts timing ~12 min/degC", + author=Author.AGENT, + title="Temp shifts timing", + status=NoteStatus.PROPOSED, + confidence=Confidence.MEDIUM, + strains=["N2", "OH904"], + embryos=["emb_0007"], + sessions=["20260615_1432_x"], + threads=["q_division_temp"], + basis=["obs_1", "obs_2"], + links=[{"rel": "supports", "to": "q_division_temp"}], + artifacts=[{"kind": "projection", "session": "s1", "embryo": "emb_0007", "t": 42}], + created_at=datetime(2026, 6, 16, 11, 0, 0), + updated_at=datetime(2026, 6, 16, 11, 0, 0), + ) + back = note_from_dict(note_to_dict(n)) + assert back == n + assert note_to_dict(n)["confidence"] == "medium" + + +class TestNotebookStoreReadWrite: + def test_write_assigns_id_and_persists(self, tmp_path): + store = NotebookStore(tmp_path / "notebook") + n = Note(id="", kind=NoteKind.OBSERVATION, body="bean stage at t40") + note_id = store.write_note(n) + assert note_id # non-empty id assigned + files = list((tmp_path / "notebook" / "notes").glob("*.yaml")) + assert len(files) == 1 + assert files[0].name.startswith(note_id + "_") + + def test_get_note_round_trip(self, tmp_path): + store = NotebookStore(tmp_path / "notebook") + n = Note(id="", kind=NoteKind.FINDING, body="x", strains=["N2"], threads=["t1"]) + note_id = store.write_note(n) + got = store.get_note(note_id) + assert got is not None + assert got.id == note_id + assert got.kind == NoteKind.FINDING + assert got.strains == ["N2"] + + def test_get_missing_returns_none(self, tmp_path): + store = NotebookStore(tmp_path / "notebook") + assert store.get_note("nope") is None + + +class TestNotebookIndex: + def test_index_updated_on_write(self, tmp_path): + store = NotebookStore(tmp_path / "notebook") + a = store.write_note(Note(id="", kind=NoteKind.OBSERVATION, body="a", strains=["N2"])) + b = store.write_note( + Note(id="", kind=NoteKind.OBSERVATION, body="b", strains=["N2", "OH904"]) + ) + assert set(store.ids_for_strain("N2")) == {a, b} + assert store.ids_for_strain("OH904") == [b] + assert store.ids_for_strain("missing") == [] + + def test_index_by_embryo_and_thread(self, tmp_path): + store = NotebookStore(tmp_path / "notebook") + a = store.write_note( + Note(id="", kind=NoteKind.FINDING, body="a", embryos=["e1"], threads=["t1"]) + ) + assert store.ids_for_embryo("e1") == [a] + assert store.ids_for_thread("t1") == [a] + + def test_rebuild_index_from_disk(self, tmp_path): + store = NotebookStore(tmp_path / "notebook") + a = store.write_note(Note(id="", kind=NoteKind.OBSERVATION, body="a", strains=["N2"])) + # a fresh store over the same dir must rebuild the index by scanning notes/ + store2 = NotebookStore(tmp_path / "notebook") + assert store2.ids_for_strain("N2") == [a] + + +class TestNotebookQuery: + def _seed(self, tmp_path): + store = NotebookStore(tmp_path / "notebook") + store.write_note(Note(id="o1", kind=NoteKind.OBSERVATION, body="o", strains=["N2"])) + store.write_note( + Note( + id="f1", + kind=NoteKind.FINDING, + body="f", + status=NoteStatus.PROPOSED, + strains=["N2"], + threads=["t1"], + ) + ) + store.write_note( + Note(id="q1", kind=NoteKind.QUESTION, body="q", status=NoteStatus.OPEN, threads=["t1"]) + ) + return store + + def test_query_by_kind(self, tmp_path): + store = self._seed(tmp_path) + ids = {n.id for n in store.query_notes(kind=NoteKind.FINDING)} + assert ids == {"f1"} + + def test_query_by_thread_scope(self, tmp_path): + store = self._seed(tmp_path) + ids = {n.id for n in store.query_notes(thread="t1")} + assert ids == {"f1", "q1"} + + def test_query_by_thread_and_kind(self, tmp_path): + store = self._seed(tmp_path) + ids = {n.id for n in store.query_notes(thread="t1", kind=NoteKind.QUESTION)} + assert ids == {"q1"} + + def test_query_by_status(self, tmp_path): + store = self._seed(tmp_path) + ids = {n.id for n in store.query_notes(status=NoteStatus.OPEN)} + assert ids == {"q1"} + + def test_query_all_sorted_newest_first(self, tmp_path): + store = self._seed(tmp_path) + notes = store.query_notes() + assert len(notes) == 3 + ts = [n.created_at for n in notes] + assert ts == sorted(ts, reverse=True) + + +class TestNotebookLinkSupersede: + def test_link_notes_adds_typed_edge(self, tmp_path): + store = NotebookStore(tmp_path / "notebook") + a = store.write_note(Note(id="", kind=NoteKind.FINDING, body="a")) + b = store.write_note(Note(id="", kind=NoteKind.QUESTION, body="b")) + store.link_notes(a, "supports", b) + got = store.get_note(a) + assert {"rel": "supports", "to": b} in got.links + + def test_supersede_marks_old_and_points_new(self, tmp_path): + store = NotebookStore(tmp_path / "notebook") + old = store.write_note(Note(id="", kind=NoteKind.FINDING, body="old claim")) + new = store.write_note(Note(id="", kind=NoteKind.FINDING, body="better claim")) + store.supersede_note(old, new) + old_n = store.get_note(old) + new_n = store.get_note(new) + assert old_n.status == NoteStatus.SUPERSEDED + assert old_n.superseded_by == new + assert {"rel": "refines", "to": old} in new_n.links + + +class TestConverters: + def test_observation_to_note(self): + obs = Observation( + id="o1", + timestamp=datetime(2026, 6, 16, 9, 0, 0), + type="milestone", + content="nerve ring formed", + embryo_id="e1", + session_id="s1", + relates_to=["o0"], + gently_refs={"kind": "projection", "t": 42}, + ) + n = observation_to_note(obs) + assert n.id == "o1" + assert n.kind == NoteKind.OBSERVATION + assert n.body == "nerve ring formed" + assert n.author == Author.AGENT + assert n.embryos == ["e1"] + assert n.sessions == ["s1"] + assert {"rel": "relates_to", "to": "o0"} in n.links + assert n.artifacts == [{"kind": "projection", "t": 42}] + assert n.created_at == datetime(2026, 6, 16, 9, 0, 0) + + def test_learning_to_note(self): + lrn = Learning(id="l1", content="rings form by comma", confidence=Confidence.HIGH) + n = learning_to_note(lrn) + assert n.id == "l1" + assert n.kind == NoteKind.FINDING + assert n.body == "rings form by comma" + assert n.status == NoteStatus.PROPOSED # agent-drafted, awaits confirm + assert n.confidence == Confidence.HIGH + + +class TestContextStoreNotebook: + def test_notebook_property_rooted_under_agent_dir(self, file_context_store): + nb = file_context_store.notebook + assert nb.root == file_context_store.agent_dir / "notebook" + + def test_notebook_property_is_cached(self, file_context_store): + assert file_context_store.notebook is file_context_store.notebook + + +class TestApplyUpdatesMirror: + def test_apply_updates_mirrors_observations_and_learnings(self, file_context_store): + from gently.harness.memory.model import ContextUpdates + + cs = file_context_store + obs = Observation( + id="o1", + timestamp=datetime(2026, 6, 16, 9, 0, 0), + type="milestone", + content="ring formed", + embryo_id="e1", + ) + lrn = Learning(id="l1", content="rings form by comma", confidence=Confidence.HIGH) + cs.apply_updates(ContextUpdates(new_observations=[obs], new_learnings=[lrn])) + + bodies = {n.body for n in cs.notebook.query_notes()} + assert "ring formed" in bodies + assert "rings form by comma" in bodies + assert cs.notebook.ids_for_embryo("e1") == ["o1"] + + def test_apply_updates_empty_is_noop_for_notebook(self, file_context_store): + from gently.harness.memory.model import ContextUpdates + + cs = file_context_store + cs.apply_updates(ContextUpdates()) + assert cs.notebook.query_notes() == [] diff --git a/tests/test_operation_plan_route.py b/tests/test_operation_plan_route.py new file mode 100644 index 00000000..a197fc84 --- /dev/null +++ b/tests/test_operation_plan_route.py @@ -0,0 +1,86 @@ +from unittest.mock import MagicMock + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from gently.ui.web.routes.operation_plan import create_router + +_SAMPLE_PLAN = { + "tactics": [ + {"id": "t1", "description": "Start acquisition", "status": "done"}, + {"id": "t2", "description": "Monitor drift", "status": "active"}, + ] +} + + +def _server(plan, sessions=(("sess-1",),), context_store_available=True): + gently_store = MagicMock() + gently_store.list_sessions.return_value = [{"session_id": sid} for (sid,) in sessions] + + context_store = MagicMock() if context_store_available else None + if context_store is not None: + context_store.get_operation_plan.return_value = plan + + srv = MagicMock() + srv.gently_store = gently_store + srv.context_store = context_store + return srv, gently_store, context_store + + +def _client(server): + app = FastAPI() + app.include_router(create_router(server)) + return TestClient(app) + + +def test_returns_stored_plan(): + srv, _, cs = _server(_SAMPLE_PLAN) + r = _client(srv).get("/api/operation_plan/sess-1") + assert r.status_code == 200 + body = r.json() + assert body["available"] is True + assert body["session_id"] == "sess-1" + assert body["plan"]["tactics"][0]["id"] == "t1" + cs.get_operation_plan.assert_called_with("sess-1") + + +def test_returns_unavailable_when_no_plan(): + srv, _, cs = _server(None) + r = _client(srv).get("/api/operation_plan/sess-1") + assert r.status_code == 200 + body = r.json() + assert body["available"] is False + assert body["plan"] is None + + +def test_current_resolves_newest(): + srv, gently_store, cs = _server(_SAMPLE_PLAN, sessions=(("newest-session",),)) + r = _client(srv).get("/api/operation_plan/current") + assert r.status_code == 200 + body = r.json() + assert body["session_id"] == "newest-session" + cs.get_operation_plan.assert_called_with("newest-session") + + +def test_current_no_sessions_returns_404(): + srv, gently_store, cs = _server(_SAMPLE_PLAN, sessions=()) + r = _client(srv).get("/api/operation_plan/current") + assert r.status_code == 404 + + +def test_context_store_unavailable_returns_unavailable(): + srv, _, _ = _server(_SAMPLE_PLAN, context_store_available=False) + r = _client(srv).get("/api/operation_plan/sess-1") + assert r.status_code == 200 + body = r.json() + assert body["available"] is False + assert body["plan"] is None + + +def test_context_store_exception_returns_unavailable(): + srv, _, cs = _server(_SAMPLE_PLAN) + cs.get_operation_plan.side_effect = RuntimeError("disk error") + r = _client(srv).get("/api/operation_plan/sess-1") + assert r.status_code == 200 + body = r.json() + assert body["available"] is False diff --git a/tests/test_operation_plan_seeding.py b/tests/test_operation_plan_seeding.py new file mode 100644 index 00000000..f3526fa8 --- /dev/null +++ b/tests/test_operation_plan_seeding.py @@ -0,0 +1,417 @@ +""" +TDD: seed_operation_plan_from_plan_item + +Resolution path under test: + session_id + → get_campaign_ids_for_session (reads session_intents/{session_id}.yaml) + → get_plan_items(campaign_id=cid) + → find PlanItem where session_id ∈ plan_item.session_ids + AND plan_item.imaging_spec.tactics is non-empty + → get_campaign(plan_item.campaign_id) for goal text + → build + write Operation Plan via set_operation_plan + +Scenarios +--------- +1. Two outline tactics → seeded plan with 2 ``planned`` tactics +2. Plan-level fields (goal, plan_item_id, campaign_id) populated from item/campaign +3. Idempotent — existing plan with active tactic: not clobbered, returns None +4. Idempotent — existing plan with done tactic: not clobbered, returns None +5. No campaign linkage → returns None, no write +6. Plan item linked but imaging_spec.tactics is empty → returns None, no write +""" + +from gently.app.tools.operation_plan_seed import seed_operation_plan_from_plan_item + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_campaign_and_item_with_tactics( + cs, + session_id: str, + *, + target: str = "50 hatching events", +): + """Create a campaign + imaging plan item with 2 tactics, linked to *session_id*.""" + cid = cs.create_campaign( + description="Capture hatching events in WT", + shorthand="hatching-wt", + target=target, + ) + spec = { + "strain": "N2", + "num_embryos": 4, + "tactics": [ + {"kind": "standing_timelapse", "name": "Baseline timelapse"}, + { + "kind": "reactive_monitor", + "name": "Hatching monitor", + "target": "embryo_hatching", + "scope": {"mode": "global"}, + }, + ], + } + item_id = cs.create_plan_item( + campaign_id=cid, + type="imaging", + title="WT hatching pilot", + spec=spec, + ) + cs.link_session_campaign(session_id, cid) + cs.link_plan_item_session(item_id, session_id) + return cid, item_id + + +# --------------------------------------------------------------------------- +# Basic seeding +# --------------------------------------------------------------------------- + + +class TestSeedBasic: + def test_returns_plan_dict(self, file_context_store): + """seed returns a non-None dict when a linked plan item with tactics exists.""" + cs = file_context_store + sid = "seed_basic_01" + cs.create_session_intent(sid, planned_intent="hatching pilot") + _make_campaign_and_item_with_tactics(cs, sid) + + plan = seed_operation_plan_from_plan_item(cs, sid) + + assert plan is not None + assert isinstance(plan, dict) + + def test_two_outline_entries_produce_two_tactics(self, file_context_store): + """An outline with 2 entries produces exactly 2 tactics.""" + cs = file_context_store + sid = "seed_basic_02" + cs.create_session_intent(sid) + _make_campaign_and_item_with_tactics(cs, sid) + + plan = seed_operation_plan_from_plan_item(cs, sid) + + assert len(plan["tactics"]) == 2 + + def test_all_seeded_tactics_are_planned(self, file_context_store): + """Every seeded tactic starts in state='planned'.""" + cs = file_context_store + sid = "seed_basic_03" + cs.create_session_intent(sid) + _make_campaign_and_item_with_tactics(cs, sid) + + plan = seed_operation_plan_from_plan_item(cs, sid) + + assert all(t["state"] == "planned" for t in plan["tactics"]) + + def test_tactic_kind_and_name_from_outline(self, file_context_store): + """Tactics carry the kind and name from the outline entries.""" + cs = file_context_store + sid = "seed_basic_04" + cs.create_session_intent(sid) + _make_campaign_and_item_with_tactics(cs, sid) + + plan = seed_operation_plan_from_plan_item(cs, sid) + + names = [t["name"] for t in plan["tactics"]] + kinds = [t["kind"] for t in plan["tactics"]] + assert "Baseline timelapse" in names + assert "Hatching monitor" in names + assert "standing_timelapse" in kinds + assert "reactive_monitor" in kinds + + def test_tactic_ids_generated_and_distinct(self, file_context_store): + """Each tactic has a non-empty unique id.""" + cs = file_context_store + sid = "seed_basic_05" + cs.create_session_intent(sid) + _make_campaign_and_item_with_tactics(cs, sid) + + plan = seed_operation_plan_from_plan_item(cs, sid) + + ids = [t["id"] for t in plan["tactics"]] + assert all(ids) + assert len(set(ids)) == len(ids) + + def test_plan_persisted_in_store(self, file_context_store): + """The seeded plan is retrievable via get_operation_plan.""" + cs = file_context_store + sid = "seed_basic_06" + cs.create_session_intent(sid) + _make_campaign_and_item_with_tactics(cs, sid) + + seed_operation_plan_from_plan_item(cs, sid) + + stored = cs.get_operation_plan(sid) + assert stored is not None + assert len(stored["tactics"]) == 2 + + +# --------------------------------------------------------------------------- +# Plan-level metadata +# --------------------------------------------------------------------------- + + +class TestSeedMetadata: + def test_goal_from_campaign_target(self, file_context_store): + """goal field is taken from campaign.target.""" + cs = file_context_store + sid = "seed_meta_01" + cs.create_session_intent(sid) + _make_campaign_and_item_with_tactics(cs, sid, target="50 hatching events") + + plan = seed_operation_plan_from_plan_item(cs, sid) + + assert plan["goal"] == "50 hatching events" + + def test_goal_falls_back_to_campaign_description(self, file_context_store): + """When campaign.target is None, goal falls back to campaign.description.""" + cs = file_context_store + sid = "seed_meta_02" + cs.create_session_intent(sid) + cid = cs.create_campaign(description="Characterise WT hatching") + spec = {"tactics": [{"kind": "standing_timelapse", "name": "Timelapse"}]} + item_id = cs.create_plan_item(campaign_id=cid, type="imaging", title="Pilot", spec=spec) + cs.link_session_campaign(sid, cid) + cs.link_plan_item_session(item_id, sid) + + plan = seed_operation_plan_from_plan_item(cs, sid) + + assert plan["goal"] == "Characterise WT hatching" + + def test_plan_item_id_set(self, file_context_store): + """plan_item_id on the plan matches the linked plan item's id.""" + cs = file_context_store + sid = "seed_meta_03" + cs.create_session_intent(sid) + cid, item_id = _make_campaign_and_item_with_tactics(cs, sid) + + plan = seed_operation_plan_from_plan_item(cs, sid) + + assert plan["plan_item_id"] == item_id + + def test_campaign_id_set(self, file_context_store): + """campaign_id on the plan matches the campaign.""" + cs = file_context_store + sid = "seed_meta_04" + cs.create_session_intent(sid) + cid, _ = _make_campaign_and_item_with_tactics(cs, sid) + + plan = seed_operation_plan_from_plan_item(cs, sid) + + assert plan["campaign_id"] == cid + + def test_title_from_plan_item(self, file_context_store): + """Plan title comes from the plan item's title.""" + cs = file_context_store + sid = "seed_meta_05" + cs.create_session_intent(sid) + _make_campaign_and_item_with_tactics(cs, sid) + + plan = seed_operation_plan_from_plan_item(cs, sid) + + assert plan["title"] == "WT hatching pilot" + + +# --------------------------------------------------------------------------- +# Idempotency +# --------------------------------------------------------------------------- + + +class TestIdempotency: + def test_active_tactic_not_clobbered(self, file_context_store): + """If the plan has an active tactic, seed returns None and doesn't overwrite.""" + cs = file_context_store + sid = "seed_idem_01" + cs.create_session_intent(sid) + _make_campaign_and_item_with_tactics(cs, sid) + + existing = { + "session_id": sid, + "title": "Agent-declared plan", + "goal": "live goal", + "tactics": [ + { + "id": "t_live", + "name": "Live tactic", + "kind": "standing_timelapse", + "state": "active", + } + ], + "updated_at": "2026-06-28T10:00:00Z", + "updated_reason": "agent declared", + } + cs.set_operation_plan(sid, existing) + + result = seed_operation_plan_from_plan_item(cs, sid) + + assert result is None + stored = cs.get_operation_plan(sid) + assert stored["title"] == "Agent-declared plan" + assert stored["goal"] == "live goal" + + def test_done_tactic_not_clobbered(self, file_context_store): + """If the plan has a done tactic, seed returns None and doesn't overwrite.""" + cs = file_context_store + sid = "seed_idem_02" + cs.create_session_intent(sid) + _make_campaign_and_item_with_tactics(cs, sid) + + existing = { + "session_id": sid, + "title": "Completed plan", + "goal": "done goal", + "tactics": [ + { + "id": "t_done", + "name": "Done tactic", + "kind": "oneshot", + "state": "done", + } + ], + "updated_at": "2026-06-28T10:00:00Z", + "updated_reason": "agent declared", + } + cs.set_operation_plan(sid, existing) + + result = seed_operation_plan_from_plan_item(cs, sid) + + assert result is None + stored = cs.get_operation_plan(sid) + assert stored["title"] == "Completed plan" + + def test_all_planned_existing_plan_adds_missing_tactics(self, file_context_store): + """If existing plan has only planned tactics, new outline entries are added.""" + cs = file_context_store + sid = "seed_idem_03" + cs.create_session_intent(sid) + _make_campaign_and_item_with_tactics(cs, sid) + + # Pre-seed with only the first tactic (by name) + existing = { + "session_id": sid, + "title": "Partial plan", + "goal": "partial goal", + "tactics": [ + { + "id": "t_existing", + "name": "Baseline timelapse", + "kind": "standing_timelapse", + "state": "planned", + } + ], + "updated_at": "2026-06-28T09:00:00Z", + "updated_reason": "partial seed", + } + cs.set_operation_plan(sid, existing) + + result = seed_operation_plan_from_plan_item(cs, sid) + + # The second outline entry ("Hatching monitor") should have been added + assert result is not None + assert len(result["tactics"]) == 2 + names = [t["name"] for t in result["tactics"]] + assert "Hatching monitor" in names + + def test_all_tactics_already_present_returns_none(self, file_context_store): + """If all outline tactics are already in the plan (by name), returns None.""" + cs = file_context_store + sid = "seed_idem_04" + cs.create_session_intent(sid) + _make_campaign_and_item_with_tactics(cs, sid) + + # Pre-seed with both outline names already present + existing = { + "session_id": sid, + "title": "Full plan", + "goal": "full goal", + "tactics": [ + { + "id": "t1", + "name": "Baseline timelapse", + "kind": "standing_timelapse", + "state": "planned", + }, + { + "id": "t2", + "name": "Hatching monitor", + "kind": "reactive_monitor", + "state": "planned", + }, + ], + "updated_at": "2026-06-28T09:00:00Z", + "updated_reason": "already seeded", + } + cs.set_operation_plan(sid, existing) + + result = seed_operation_plan_from_plan_item(cs, sid) + + assert result is None + # Plan unchanged + stored = cs.get_operation_plan(sid) + assert len(stored["tactics"]) == 2 + + +# --------------------------------------------------------------------------- +# No linkage +# --------------------------------------------------------------------------- + + +class TestNoLinkage: + def test_no_campaign_linkage_returns_none(self, file_context_store): + """Session with no campaign linkage → None, nothing written.""" + cs = file_context_store + sid = "seed_nolink_01" + cs.create_session_intent(sid, planned_intent="standalone") + + result = seed_operation_plan_from_plan_item(cs, sid) + + assert result is None + assert cs.get_operation_plan(sid) is None + + def test_no_session_intent_returns_none(self, file_context_store): + """Session with no session_intent YAML → None.""" + cs = file_context_store + + result = seed_operation_plan_from_plan_item(cs, "ghost_session") + + assert result is None + + def test_plan_item_without_tactics_returns_none(self, file_context_store): + """Plan item linked to session but imaging_spec.tactics is empty → None.""" + cs = file_context_store + sid = "seed_notactics_01" + cs.create_session_intent(sid) + + cid = cs.create_campaign(description="Campaign — no tactics in spec") + item_id = cs.create_plan_item( + campaign_id=cid, + type="imaging", + title="Item without tactics", + spec={"strain": "N2"}, # no "tactics" key → empty list on ImagingSpec + ) + cs.link_session_campaign(sid, cid) + cs.link_plan_item_session(item_id, sid) + + result = seed_operation_plan_from_plan_item(cs, sid) + + assert result is None + assert cs.get_operation_plan(sid) is None + + def test_non_imaging_plan_item_returns_none(self, file_context_store): + """Non-imaging plan item (no imaging_spec) → None.""" + cs = file_context_store + sid = "seed_bench_01" + cs.create_session_intent(sid) + + cid = cs.create_campaign(description="Bench work campaign") + item_id = cs.create_plan_item( + campaign_id=cid, + type="bench", + title="Prep samples", + ) + cs.link_session_campaign(sid, cid) + cs.link_plan_item_session(item_id, sid) + + result = seed_operation_plan_from_plan_item(cs, sid) + + assert result is None diff --git a/tests/test_operation_plan_store.py b/tests/test_operation_plan_store.py new file mode 100644 index 00000000..a3b9123c --- /dev/null +++ b/tests/test_operation_plan_store.py @@ -0,0 +1,170 @@ +""" +FileContextStore: OperationPlan domain — set/get round-trip + CONTEXT_UPDATED event. + +Domain: agent/operation_plans/{session_id}.yaml +Methods: set_operation_plan / get_operation_plan +""" + +from gently.core.event_bus import EventType, on + +# --------------------------------------------------------------------------- +# Fixture helpers +# --------------------------------------------------------------------------- + +_PLAN = { + "session_id": "sess_001", + "title": "Temperature-strain protocol", + "goal": "Characterise onset timing under 25 °C ramp", + "tactics": [ + { + "id": "t1", + "name": "Baseline timelapse", + "kind": "standing_timelapse", + "state": "done", + "scope": {"mode": "global"}, + "rationale": "Establish pre-ramp cadence", + "structure": { + "cadence_s": 120, + "per_embryo": [{"embryo_id": "e1", "cadence_phase": "normal", "interval_s": 120}], + }, + "live_bind": ["cadence"], + "relations": {}, + }, + { + "id": "t2", + "name": "Temp ramp monitor", + "kind": "reactive_monitor", + "state": "active", + "scope": {"mode": "global"}, + "rationale": "Detect onset event triggered by ramp", + "structure": { + "watch": "temperature > 24.5", + "reaction": "burst_capture", + "status": "armed", + }, + "live_bind": ["temperature", "signal"], + "relations": {"after": ["t1"]}, + }, + { + "id": "t3", + "name": "Post-ramp survey", + "kind": "oneshot", + "state": "planned", + "scope": {"mode": "embryos", "embryo_ids": ["e1", "e2"]}, + "rationale": "Confirm recovery after ramp completion", + "structure": {"note": "single high-res z-stack per embryo"}, + "live_bind": [], + "relations": {"after": ["t2"]}, + }, + ], + "updated_at": "2026-06-28T10:00:00", + "updated_reason": "initial plan", +} + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + + +class TestOperationPlanRoundTrip: + def test_set_then_get_returns_plan(self, file_context_store): + """set_operation_plan followed by get_operation_plan returns the full dict.""" + cs = file_context_store + cs.set_operation_plan("sess_001", _PLAN) + result = cs.get_operation_plan("sess_001") + assert result is not None + assert result["session_id"] == "sess_001" + assert result["title"] == "Temperature-strain protocol" + + def test_tactics_list_preserved(self, file_context_store): + """All three tactics are returned with their ids and kinds intact.""" + cs = file_context_store + cs.set_operation_plan("sess_001", _PLAN) + result = cs.get_operation_plan("sess_001") + tactics = result["tactics"] + assert len(tactics) == 3 + ids = [t["id"] for t in tactics] + assert ids == ["t1", "t2", "t3"] + kinds = [t["kind"] for t in tactics] + assert kinds == ["standing_timelapse", "reactive_monitor", "oneshot"] + + def test_tactic_states_preserved(self, file_context_store): + """State values (done / active / planned) survive the YAML round-trip.""" + cs = file_context_store + cs.set_operation_plan("sess_001", _PLAN) + result = cs.get_operation_plan("sess_001") + states = {t["id"]: t["state"] for t in result["tactics"]} + assert states == {"t1": "done", "t2": "active", "t3": "planned"} + + def test_nested_structure_preserved(self, file_context_store): + """Nested fields (scope, structure, live_bind, relations) survive intact.""" + cs = file_context_store + cs.set_operation_plan("sess_001", _PLAN) + result = cs.get_operation_plan("sess_001") + t2 = next(t for t in result["tactics"] if t["id"] == "t2") + assert t2["structure"]["status"] == "armed" + assert "temperature" in t2["live_bind"] + assert t2["relations"]["after"] == ["t1"] + + def test_get_missing_returns_none(self, file_context_store): + """get_operation_plan returns None when no plan exists for the session.""" + result = file_context_store.get_operation_plan("no_such_session") + assert result is None + + def test_overwrite_replaces_plan(self, file_context_store): + """A second set_operation_plan replaces the first.""" + cs = file_context_store + cs.set_operation_plan("sess_001", _PLAN) + updated = dict(_PLAN, title="Revised plan", updated_reason="tactic t2 fired") + cs.set_operation_plan("sess_001", updated) + result = cs.get_operation_plan("sess_001") + assert result["title"] == "Revised plan" + assert result["updated_reason"] == "tactic t2 fired" + + def test_independent_sessions(self, file_context_store): + """Plans for different session_ids are stored independently.""" + cs = file_context_store + plan_a = dict(_PLAN, session_id="sess_A", title="Plan A") + plan_b = dict(_PLAN, session_id="sess_B", title="Plan B") + cs.set_operation_plan("sess_A", plan_a) + cs.set_operation_plan("sess_B", plan_b) + assert cs.get_operation_plan("sess_A")["title"] == "Plan A" + assert cs.get_operation_plan("sess_B")["title"] == "Plan B" + + +class TestOperationPlanContextUpdatedEvent: + def test_set_fires_context_updated(self, file_context_store): + """set_operation_plan emits CONTEXT_UPDATED on the global event bus.""" + cs = file_context_store + seen = [] + unsub = on(EventType.CONTEXT_UPDATED, lambda e: seen.append(e)) + try: + cs.set_operation_plan("sess_001", _PLAN) + finally: + unsub() + assert len(seen) >= 1 + + def test_context_updated_kind_is_operation_plan(self, file_context_store): + """The CONTEXT_UPDATED event carries kind='operation_plan'.""" + cs = file_context_store + seen = [] + unsub = on(EventType.CONTEXT_UPDATED, lambda e: seen.append(e)) + try: + cs.set_operation_plan("sess_001", _PLAN) + finally: + unsub() + kinds = [(e.data or {}).get("kind") for e in seen] + assert "operation_plan" in kinds + + def test_get_does_not_fire_event(self, file_context_store): + """get_operation_plan is read-only and must not emit CONTEXT_UPDATED.""" + cs = file_context_store + cs.set_operation_plan("sess_001", _PLAN) + seen = [] + unsub = on(EventType.CONTEXT_UPDATED, lambda e: seen.append(e)) + try: + cs.get_operation_plan("sess_001") + finally: + unsub() + assert len(seen) == 0 diff --git a/tests/test_operation_plan_tool.py b/tests/test_operation_plan_tool.py new file mode 100644 index 00000000..1640df67 --- /dev/null +++ b/tests/test_operation_plan_tool.py @@ -0,0 +1,621 @@ +""" +Tests for the declare_operation_plan agent tool (Task 2). + +TDD — three concerns: + 1. Happy path: calling the tool persists the plan; store returns it. + 2. Error paths: missing store, missing session → error string, no write. + 3. Registration: tool is discoverable in the global registry after import. +""" + +import pytest + +# --------------------------------------------------------------------------- +# Fakes +# --------------------------------------------------------------------------- + + +class FakeContextStore: + """Minimal in-memory stand-in for FileContextStore.""" + + def __init__(self): + self._plans: dict[str, dict] = {} + self.calls: list[tuple] = [] + + def set_operation_plan(self, session_id: str, plan: dict) -> None: + self._plans[session_id] = plan + self.calls.append(("set", session_id, plan)) + + def get_operation_plan(self, session_id: str) -> dict | None: + return self._plans.get(session_id) + + +class FakeAgent: + """Minimal fake agent — carries context_store and session_id.""" + + def __init__(self, *, context_store=None, session_id: str | None = "sess_test_01"): + self.context_store = context_store + self.session_id = session_id + + +def _make_context(*, with_store=True, with_session=True): + store = FakeContextStore() if with_store else None + sid = "sess_test_01" if with_session else None + agent = FakeAgent(context_store=store, session_id=sid) + return {"agent": agent} + + +# --------------------------------------------------------------------------- +# Minimal tactic fixture +# --------------------------------------------------------------------------- + +_TACTICS_MINIMAL = [ + { + "id": "t1", + "name": "Baseline timelapse", + "kind": "standing_timelapse", + "state": "active", + "scope": {"mode": "global"}, + "rationale": "Continuous pre-ramp imaging", + "structure": {"cadence_s": 120, "per_embryo": []}, + "live_bind": ["cadence"], + "relations": {}, + } +] + +_TACTICS_MULTI = [ + { + "id": "t1", + "name": "Baseline timelapse", + "kind": "standing_timelapse", + "state": "done", + "scope": {"mode": "global"}, + "rationale": "Establish pre-ramp cadence", + "structure": {"cadence_s": 120, "per_embryo": []}, + "live_bind": ["cadence"], + "relations": {}, + }, + { + "id": "t2", + "name": "Onset monitor", + "kind": "reactive_monitor", + "state": "active", + "scope": {"mode": "global"}, + "rationale": "Detect onset crossing threshold", + "structure": {"watch": "gfp_signal > 0.3", "reaction": "burst_capture", "status": "armed"}, + "live_bind": ["signal", "current_burst"], + "relations": {"after": ["t1"]}, + }, + { + "id": "t3", + "name": "Final survey", + "kind": "oneshot", + "state": "planned", + "scope": {"mode": "embryos", "embryo_ids": ["e1", "e2"]}, + "rationale": "Confirm recovery after ramp", + "structure": {"note": "single high-res z-stack"}, + "live_bind": [], + "relations": {"after": ["t2"]}, + }, +] + + +# --------------------------------------------------------------------------- +# Happy-path tests +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_plan_is_persisted_to_store(): + """Calling the tool with a valid plan stores it; get_operation_plan returns it.""" + from gently.app.tools.operation_plan_tools import declare_operation_plan + + ctx = _make_context() + result = await declare_operation_plan( + title="Expression-onset survey", + goal="Catch GFP onset under 25 C ramp", + tactics=_TACTICS_MINIMAL, + updated_reason="experiment started", + context=ctx, + ) + + store = ctx["agent"].context_store + stored = store.get_operation_plan("sess_test_01") + assert stored is not None, "Plan should have been persisted" + assert stored["title"] == "Expression-onset survey" + assert stored["goal"] == "Catch GFP onset under 25 C ramp" + assert stored["session_id"] == "sess_test_01" + assert "Error" not in result + + +@pytest.mark.asyncio +async def test_confirmation_string_contains_session_and_title(): + """The return value identifies the session and plan title.""" + from gently.app.tools.operation_plan_tools import declare_operation_plan + + ctx = _make_context() + result = await declare_operation_plan( + title="Temperature-strain protocol", + goal="Characterise onset timing under 25 C ramp", + tactics=_TACTICS_MINIMAL, + context=ctx, + ) + + assert "sess_test_01" in result + assert "Temperature-strain protocol" in result + + +@pytest.mark.asyncio +async def test_tactics_list_preserved_in_store(): + """All tactics are stored with correct ids, kinds, and states.""" + from gently.app.tools.operation_plan_tools import declare_operation_plan + + ctx = _make_context() + await declare_operation_plan( + title="Multi-tactic plan", + goal="Three-phase experiment", + tactics=_TACTICS_MULTI, + updated_reason="t1 done, t2 active", + context=ctx, + ) + + stored = ctx["agent"].context_store.get_operation_plan("sess_test_01") + assert stored is not None + tactics = stored["tactics"] + assert len(tactics) == 3 + assert [t["id"] for t in tactics] == ["t1", "t2", "t3"] + assert [t["state"] for t in tactics] == ["done", "active", "planned"] + assert [t["kind"] for t in tactics] == ["standing_timelapse", "reactive_monitor", "oneshot"] + + +@pytest.mark.asyncio +async def test_updated_at_is_stamped(): + """The plan stored in the store has a non-empty updated_at timestamp.""" + from gently.app.tools.operation_plan_tools import declare_operation_plan + + ctx = _make_context() + await declare_operation_plan( + title="Any plan", + goal="Any goal", + tactics=_TACTICS_MINIMAL, + context=ctx, + ) + + stored = ctx["agent"].context_store.get_operation_plan("sess_test_01") + assert stored is not None + assert stored.get("updated_at"), "updated_at should be stamped" + + +@pytest.mark.asyncio +async def test_updated_reason_stored(): + """updated_reason is carried into the stored plan.""" + from gently.app.tools.operation_plan_tools import declare_operation_plan + + ctx = _make_context() + await declare_operation_plan( + title="Any plan", + goal="Any goal", + tactics=_TACTICS_MINIMAL, + updated_reason="tactic t1 transitioned to active", + context=ctx, + ) + + stored = ctx["agent"].context_store.get_operation_plan("sess_test_01") + assert stored["updated_reason"] == "tactic t1 transitioned to active" + + +@pytest.mark.asyncio +async def test_set_called_exactly_once(): + """set_operation_plan is called exactly once per tool invocation.""" + from gently.app.tools.operation_plan_tools import declare_operation_plan + + ctx = _make_context() + await declare_operation_plan( + title="Any plan", + goal="Any goal", + tactics=_TACTICS_MINIMAL, + context=ctx, + ) + + store = ctx["agent"].context_store + set_calls = [c for c in store.calls if c[0] == "set"] + assert len(set_calls) == 1 + + +@pytest.mark.asyncio +async def test_overwrite_replaces_plan(): + """A second call replaces the first plan in the store.""" + from gently.app.tools.operation_plan_tools import declare_operation_plan + + ctx = _make_context() + await declare_operation_plan( + title="Initial plan", + goal="First framing", + tactics=_TACTICS_MINIMAL, + context=ctx, + ) + await declare_operation_plan( + title="Revised plan", + goal="Updated framing", + tactics=_TACTICS_MULTI, + updated_reason="added two tactics", + context=ctx, + ) + + stored = ctx["agent"].context_store.get_operation_plan("sess_test_01") + assert stored["title"] == "Revised plan" + assert len(stored["tactics"]) == 3 + + +# --------------------------------------------------------------------------- +# Unknown kind → clamped to 'custom' +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_unknown_kind_clamped_to_custom(): + """A tactic with an unrecognised kind is silently clamped to 'custom'.""" + from gently.app.tools.operation_plan_tools import declare_operation_plan + + ctx = _make_context() + tactics = [ + { + "id": "t1", + "name": "Novel tactic", + "kind": "future_kind_v99", + "state": "planned", + "scope": {"mode": "global"}, + "rationale": "Something new", + "structure": {"note": "TBD"}, + "live_bind": [], + "relations": {}, + } + ] + result = await declare_operation_plan( + title="Any plan", + goal="Any goal", + tactics=tactics, + context=ctx, + ) + + assert "Error" not in result + stored = ctx["agent"].context_store.get_operation_plan("sess_test_01") + assert stored["tactics"][0]["kind"] == "custom" + + +# --------------------------------------------------------------------------- +# Error paths — no write must occur +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_no_agent_returns_error_no_write(): + """No agent in context → error string, store is never called.""" + from gently.app.tools.operation_plan_tools import declare_operation_plan + + # No agent key at all + ctx: dict = {} + result = await declare_operation_plan( + title="Any plan", + goal="Any goal", + tactics=_TACTICS_MINIMAL, + context=ctx, + ) + + assert "error" in result.lower() or "Error" in result + + +@pytest.mark.asyncio +async def test_missing_store_returns_error_no_write(): + """Agent has no context_store → error string, nothing persisted.""" + from gently.app.tools.operation_plan_tools import declare_operation_plan + + ctx = _make_context(with_store=False) + result = await declare_operation_plan( + title="Any plan", + goal="Any goal", + tactics=_TACTICS_MINIMAL, + context=ctx, + ) + + assert "Error" in result + assert ctx["agent"].context_store is None # store was never created + + +@pytest.mark.asyncio +async def test_missing_session_id_returns_error_no_write(): + """Agent has no session_id → error string, store.set is never called.""" + from gently.app.tools.operation_plan_tools import declare_operation_plan + + ctx = _make_context(with_session=False) + result = await declare_operation_plan( + title="Any plan", + goal="Any goal", + tactics=_TACTICS_MINIMAL, + context=ctx, + ) + + assert "Error" in result + store = ctx["agent"].context_store + assert store.calls == [], "set_operation_plan must not be called without a session_id" + + +# --------------------------------------------------------------------------- +# Validation errors — no write on invalid tactics +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_missing_tactic_id_returns_error_no_write(): + """A tactic missing 'id' causes an error; nothing is persisted.""" + from gently.app.tools.operation_plan_tools import declare_operation_plan + + ctx = _make_context() + bad_tactics = [{"name": "No id", "kind": "oneshot", "state": "planned"}] + result = await declare_operation_plan( + title="Bad plan", + goal="Any goal", + tactics=bad_tactics, + context=ctx, + ) + + assert "Error" in result + assert ctx["agent"].context_store.calls == [] + + +@pytest.mark.asyncio +async def test_invalid_state_returns_error_no_write(): + """A tactic with a completely unrecognised state causes an error; nothing is persisted.""" + from gently.app.tools.operation_plan_tools import declare_operation_plan + + ctx = _make_context() + bad_tactics = [ + { + "id": "t1", + "name": "Bad state", + "kind": "oneshot", + "state": "completely_unknown_xyz", # not a valid state and not a known synonym + } + ] + result = await declare_operation_plan( + title="Bad plan", + goal="Any goal", + tactics=bad_tactics, + context=ctx, + ) + + assert "Error" in result + assert ctx["agent"].context_store.calls == [] + + +# --------------------------------------------------------------------------- +# Normalization tests (Fix 2) +# --------------------------------------------------------------------------- + + +def test_normalize_kind_synonyms(): + """Kind synonyms are mapped to canonical values before validation.""" + from gently.app.tools.operation_plan_tools import _validate_tactics + + cases = [ + ("monitor", "reactive_monitor"), + ("reactive", "reactive_monitor"), + ("timelapse", "standing_timelapse"), + ("standing", "standing_timelapse"), + ("protocol", "scripted_protocol"), + ("scripted", "scripted_protocol"), + ("burst", "exclusive_burst"), + ("reference", "standing_timelapse"), + ] + for raw_kind, expected in cases: + tactics = [{"id": "t1", "name": "T", "kind": raw_kind, "state": "planned"}] + result = _validate_tactics(tactics) + assert result[0]["kind"] == expected, f"kind '{raw_kind}' should map to '{expected}'" + + +def test_normalize_kind_unknown_to_custom(): + """An unrecognised kind that is not a synonym is clamped to 'custom'.""" + from gently.app.tools.operation_plan_tools import _validate_tactics + + tactics = [{"id": "t1", "name": "T", "kind": "future_kind_v99", "state": "planned"}] + result = _validate_tactics(tactics) + assert result[0]["kind"] == "custom" + + +def test_normalize_scope_bare_list(): + """A bare list scope is converted to {mode:'embryos', embryo_ids:[...]}.""" + from gently.app.tools.operation_plan_tools import _validate_tactics + + tactics = [ + { + "id": "t1", + "name": "T", + "kind": "standing_timelapse", + "state": "planned", + "scope": ["E01", "E02"], + } + ] + result = _validate_tactics(tactics) + assert result[0]["scope"] == {"mode": "embryos", "embryo_ids": ["E01", "E02"]} + + +def test_normalize_scope_global_string(): + """The string 'global' scope is converted to {mode:'global'}.""" + from gently.app.tools.operation_plan_tools import _validate_tactics + + tactics = [ + { + "id": "t1", + "name": "T", + "kind": "standing_timelapse", + "state": "planned", + "scope": "global", + } + ] + result = _validate_tactics(tactics) + assert result[0]["scope"] == {"mode": "global"} + + +def test_normalize_scope_dict_missing_mode_with_embryo_ids(): + """A scope dict with embryo_ids but no mode gets mode:'embryos' injected.""" + from gently.app.tools.operation_plan_tools import _validate_tactics + + tactics = [ + { + "id": "t1", + "name": "T", + "kind": "standing_timelapse", + "state": "planned", + "scope": {"embryo_ids": ["E01"]}, + } + ] + result = _validate_tactics(tactics) + assert result[0]["scope"]["mode"] == "embryos" + assert result[0]["scope"]["embryo_ids"] == ["E01"] + + +def test_normalize_scope_dict_missing_mode_with_role(): + """A scope dict with role but no mode gets mode:'role' injected.""" + from gently.app.tools.operation_plan_tools import _validate_tactics + + tactics = [ + { + "id": "t1", + "name": "T", + "kind": "standing_timelapse", + "state": "planned", + "scope": {"role": "test"}, + } + ] + result = _validate_tactics(tactics) + assert result[0]["scope"]["mode"] == "role" + assert result[0]["scope"]["role"] == "test" + + +def test_normalize_scope_well_formed_dict_unchanged(): + """A correctly formed scope dict passes through unchanged.""" + from gently.app.tools.operation_plan_tools import _validate_tactics + + scope = {"mode": "embryos", "embryo_ids": ["E01", "E02"]} + tactics = [ + { + "id": "t1", + "name": "T", + "kind": "standing_timelapse", + "state": "planned", + "scope": scope, + } + ] + result = _validate_tactics(tactics) + assert result[0]["scope"] == scope + + +def test_normalize_phase_state_pending_to_todo(): + """Phase state 'pending' is normalized to 'todo'.""" + from gently.app.tools.operation_plan_tools import _validate_tactics + + tactics = [ + { + "id": "t1", + "name": "T", + "kind": "scripted_protocol", + "state": "planned", + "live": { + "readouts": [], + "phases": [ + {"name": "ramp", "state": "pending", "count": 0, "pips": []}, + {"name": "hold", "state": "running", "count": 0, "pips": []}, + {"name": "done_phase", "state": "completed", "count": 3, "pips": []}, + ], + }, + } + ] + result = _validate_tactics(tactics) + phases = result[0]["live"]["phases"] + assert phases[0]["state"] == "todo" + assert phases[1]["state"] == "active" + assert phases[2]["state"] == "done" + + +def test_normalize_tactic_state_synonyms(): + """Tactic state synonyms are mapped to canonical values.""" + from gently.app.tools.operation_plan_tools import _validate_tactics + + cases = [ + ("in_progress", "active"), + ("running", "active"), + ("pending", "planned"), + ("queued", "planned"), + ("complete", "done"), + ("completed", "done"), + ] + for raw_state, expected in cases: + tactics = [{"id": "t1", "name": "T", "kind": "oneshot", "state": "planned"}] + # Override state directly + tactics[0]["state"] = raw_state + result = _validate_tactics(tactics) + assert result[0]["state"] == expected, f"state '{raw_state}' should map to '{expected}'" + + +def test_normalize_full_scenario(): + """Combined: kind synonym + bare-list scope + pending phase state + running tactic state.""" + from gently.app.tools.operation_plan_tools import _validate_tactics + + tactics = [ + { + "id": "t1", + "name": "Temp monitor", + "kind": "monitor", # synonym → reactive_monitor + "state": "in_progress", # synonym → active + "scope": ["E01", "E02"], # bare list → {mode:embryos, embryo_ids:[...]} + "rationale": "Watch temp", + "live": { + "readouts": [{"label": "temp", "value": "25 °C"}], + "phases": [ + {"name": "watch", "state": "pending", "count": 0, "pips": []}, + ], + }, + } + ] + result = _validate_tactics(tactics) + t = result[0] + assert t["kind"] == "reactive_monitor" + assert t["state"] == "active" + assert t["scope"] == {"mode": "embryos", "embryo_ids": ["E01", "E02"]} + assert t["live"]["phases"][0]["state"] == "todo" + assert t["live"]["readouts"] == [{"label": "temp", "value": "25 °C"}] + + +# --------------------------------------------------------------------------- +# Registration test +# --------------------------------------------------------------------------- + + +def test_tool_is_registered_in_global_registry(): + """declare_operation_plan is discoverable via the global tool registry after import.""" + import gently.app.tools # noqa: F401 — triggers registration side effects + from gently.harness.tools.registry import get_tool_registry + + registry = get_tool_registry() + names = [t.name for t in registry.list_all()] + assert "declare_operation_plan" in names, ( + f"Tool not found in registry. Registered tools: {names}" + ) + + +def test_tool_schema_has_required_fields(): + """The auto-generated Claude schema has the expected required fields.""" + import gently.app.tools # noqa: F401 + from gently.harness.tools.registry import get_tool_registry + + registry = get_tool_registry() + tool_def = registry.get("declare_operation_plan") + assert tool_def is not None + + schema = tool_def.to_claude_schema() + assert schema["name"] == "declare_operation_plan" + required = schema["input_schema"]["required"] + assert "title" in required + assert "goal" in required + assert "tactics" in required + # updated_reason has a default, so it must NOT be in required + assert "updated_reason" not in required diff --git a/tests/test_operation_plan_updater.py b/tests/test_operation_plan_updater.py new file mode 100644 index 00000000..00afb7da --- /dev/null +++ b/tests/test_operation_plan_updater.py @@ -0,0 +1,272 @@ +"""TDD: OperationPlanUpdater — execution events transition plan tactics. + +Covers: +- BURST_COMPLETE with tactic_id → state=done, bind values recorded +- TEMP_PROTOCOL_COMPLETED with tactic_id → state=done, bind values recorded +- EMBRYO_CADENCE_CHANGED with tactic_id → bind-only (no state change) +- TRIGGER_FIRED with tactic_id → bind-only + last_fired timestamp +- Event without tactic_id → skip (no transition_tactic call) +- Handler exception doesn't propagate to the caller +- on_stop unsubscribes (no further calls after stop) +""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +import pytest + +from gently.app.operation_plan_updater import OperationPlanUpdater +from gently.core.event_bus import EventBus, EventType + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +class FakeContextStore: + """Records all transition_tactic calls.""" + + def __init__(self): + self.calls: list[dict] = [] + + def transition_tactic( + self, session_id: str, tactic_id: str, state: str | None = None, **bind + ) -> bool: + self.calls.append( + {"session_id": session_id, "tactic_id": tactic_id, "state": state, **bind} + ) + return True + + +def make_event(event_type: EventType, data: dict): + """Build a minimal Event-like object that _on_event expects.""" + evt = MagicMock() + evt.event_type = event_type + evt.data = data + evt.timestamp = "2026-06-28T00:00:00+00:00" + return evt + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +def bus(): + """A fresh EventBus for each test (avoids cross-test leakage).""" + return EventBus() + + +@pytest.fixture +def store(): + return FakeContextStore() + + +@pytest.fixture +def session_id(): + return "sess_abc" + + +@pytest.fixture +def updater(store, session_id): + return OperationPlanUpdater(store, lambda: session_id) + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_burst_complete_transitions_tactic_to_done(updater, store, bus, monkeypatch): + """BURST_COMPLETE fires → tactic transitions to done with bind values.""" + monkeypatch.setattr("gently.app.operation_plan_updater.get_event_bus", lambda: bus) + await updater.start() + + bus.publish( + event_type=EventType.BURST_COMPLETE, + data={ + "tactic_id": "t1", + "embryo_id": "e1", + "request_id": "req-001", + "mp4_path": "/data/burst.mp4", + "sustained_hz": 2.5, + "frames_captured": 50, + }, + source="test", + ) + + assert len(store.calls) == 1 + call = store.calls[0] + assert call["tactic_id"] == "t1" + assert call["state"] == "done" + assert call["request_id"] == "req-001" + assert call["mp4_path"] == "/data/burst.mp4" + assert call["sustained_hz"] == 2.5 + assert call["frames_captured"] == 50 + + await updater.stop() + + +@pytest.mark.asyncio +async def test_temp_protocol_completed_transitions_to_done(updater, store, bus, monkeypatch): + """TEMP_PROTOCOL_COMPLETED fires → tactic done with locked/cancelled/error.""" + monkeypatch.setattr("gently.app.operation_plan_updater.get_event_bus", lambda: bus) + await updater.start() + + bus.publish( + event_type=EventType.TEMP_PROTOCOL_COMPLETED, + data={ + "tactic_id": "t2", + "embryo_id": "e1", + "locked": True, + "cancelled": False, + "error": None, + }, + source="test", + ) + + assert len(store.calls) == 1 + call = store.calls[0] + assert call["tactic_id"] == "t2" + assert call["state"] == "done" + assert call["locked"] is True + assert call["cancelled"] is False + + await updater.stop() + + +@pytest.mark.asyncio +async def test_embryo_cadence_changed_bind_only(updater, store, bus, monkeypatch): + """EMBRYO_CADENCE_CHANGED → bind-only (state=None), cadence values bound.""" + monkeypatch.setattr("gently.app.operation_plan_updater.get_event_bus", lambda: bus) + await updater.start() + + bus.publish( + event_type=EventType.EMBRYO_CADENCE_CHANGED, + data={ + "tactic_id": "t3", + "embryo_id": "e1", + "old_phase": "normal", + "new_phase": "dense", + "old_interval_s": 120, + "new_interval_s": 30, + "next_due_at": "2026-06-28T01:00:00+00:00", + }, + source="test", + ) + + assert len(store.calls) == 1 + call = store.calls[0] + assert call["tactic_id"] == "t3" + assert call["state"] is None # bind-only + assert call["new_phase"] == "dense" + assert call["new_interval_s"] == 30 + + await updater.stop() + + +@pytest.mark.asyncio +async def test_trigger_fired_bind_only_with_last_fired(updater, store, bus, monkeypatch): + """TRIGGER_FIRED → bind-only with last_fired timestamp injected.""" + monkeypatch.setattr("gently.app.operation_plan_updater.get_event_bus", lambda: bus) + await updater.start() + + bus.publish( + event_type=EventType.TRIGGER_FIRED, + data={ + "tactic_id": "t4", + "embryo_id": "e1", + "rule_name": "dense_on_signal", + "rule_kind": "interval", + }, + source="test", + ) + + assert len(store.calls) == 1 + call = store.calls[0] + assert call["tactic_id"] == "t4" + assert call["state"] is None + assert "last_fired" in call # timestamp injected by updater + + await updater.stop() + + +@pytest.mark.asyncio +async def test_event_without_tactic_id_is_skipped(updater, store, bus, monkeypatch): + """An event with no tactic_id must not call transition_tactic.""" + monkeypatch.setattr("gently.app.operation_plan_updater.get_event_bus", lambda: bus) + await updater.start() + + bus.publish( + event_type=EventType.BURST_COMPLETE, + data={ + "embryo_id": "e1", + "request_id": "req-999", + # deliberately no tactic_id + }, + source="test", + ) + + assert store.calls == [] + + await updater.stop() + + +@pytest.mark.asyncio +async def test_handler_exception_does_not_propagate(updater, bus, monkeypatch): + """An exception inside _handle must be swallowed — bus caller not affected.""" + monkeypatch.setattr("gently.app.operation_plan_updater.get_event_bus", lambda: bus) + + class BrokenStore: + def transition_tactic(self, *a, **kw): + raise RuntimeError("storage exploded") + + broken_updater = OperationPlanUpdater(BrokenStore(), lambda: "sess_xyz") + await broken_updater.start() + + # Should not raise even though the store raises. + bus.publish( + event_type=EventType.BURST_COMPLETE, + data={"tactic_id": "t1", "request_id": "r1"}, + source="test", + ) + + await broken_updater.stop() + + +@pytest.mark.asyncio +async def test_no_session_skips_transition(store, bus, monkeypatch): + """If session_id_getter returns None, no transition_tactic call is made.""" + monkeypatch.setattr("gently.app.operation_plan_updater.get_event_bus", lambda: bus) + no_session_updater = OperationPlanUpdater(store, lambda: None) + await no_session_updater.start() + + bus.publish( + event_type=EventType.BURST_COMPLETE, + data={"tactic_id": "t1", "request_id": "r1"}, + source="test", + ) + + assert store.calls == [] + + await no_session_updater.stop() + + +@pytest.mark.asyncio +async def test_stop_unsubscribes_from_bus(store, bus, monkeypatch): + """After stop(), further bus events produce no transition_tactic calls.""" + monkeypatch.setattr("gently.app.operation_plan_updater.get_event_bus", lambda: bus) + up = OperationPlanUpdater(store, lambda: "sess_z") + await up.start() + await up.stop() + + bus.publish( + event_type=EventType.BURST_COMPLETE, + data={"tactic_id": "t9", "request_id": "r9"}, + source="test", + ) + + assert store.calls == [] diff --git a/tests/test_plan_context.py b/tests/test_plan_context.py new file mode 100644 index 00000000..acf97ffe --- /dev/null +++ b/tests/test_plan_context.py @@ -0,0 +1,29 @@ +"""Connection B: the running agent's awareness summary carries the plan narrative +(goal + what's next), not just the active item's spec sheet.""" + +from gently.harness.memory.interface import AgentMemory + + +def test_awareness_includes_goal_and_next(file_context_store): + cs = file_context_store + cid = cs.create_campaign( + description="Pioneer guidance", target="how pioneers steer the nerve ring" + ) + active = cs.create_plan_item( + campaign_id=cid, + type="imaging", + title="WT baseline", + spec={"strain": "N2", "num_slices": 50, "laser_wavelength_nm": 488, "laser_power_pct": 8}, + ) + cs.create_plan_item(campaign_id=cid, type="decision_point", title="Go/no-go gate") + + mem = AgentMemory(cs, session_id="s1") + mem.active_plan_item_id = active + summary = mem.get_awareness_summary() + + assert "Goal of the investigation: how pioneers steer the nerve ring" in summary + assert "Next up:" in summary + assert "Go/no-go gate (decision point)" in summary + # spec block still present, incl. laser power when the field is set + assert "WT baseline" in summary + assert "Laser: 488nm at 8%" in summary diff --git a/tests/test_plan_events.py b/tests/test_plan_events.py new file mode 100644 index 00000000..ca28f9a9 --- /dev/null +++ b/tests/test_plan_events.py @@ -0,0 +1,49 @@ +"""Plan writes emit PLAN_UPDATED so the Plans UI can refresh live.""" + +from gently.core.event_bus import EventType, on +from gently.harness.memory.model import PlanItemStatus + + +class TestPlanUpdatedEvent: + def test_plan_writes_emit_plan_updated(self, file_context_store): + cs = file_context_store + seen = [] + unsub = on(EventType.PLAN_UPDATED, lambda e: seen.append(e)) + try: + cid = cs.create_campaign(description="test campaign") + iid = cs.create_plan_item(campaign_id=cid, type="imaging", title="pilot") + cs.update_plan_item(iid, status=PlanItemStatus.IN_PROGRESS, session_id="sess_1") + cs.complete_plan_item(iid, outcome="done") + finally: + unsub() + # create_plan_item + update_plan_item + complete_plan_item(→update) each fire it + assert len(seen) >= 3 + assert any((e.data or {}).get("campaign_id") == cid for e in seen) + + def test_session_campaign_link_emits(self, file_context_store): + cs = file_context_store + seen = [] + unsub = on(EventType.PLAN_UPDATED, lambda e: seen.append(e)) + try: + cid = cs.create_campaign(description="c") + cs.link_session_campaign("sess_9", cid) + finally: + unsub() + assert any((e.data or {}).get("campaign_id") == cid for e in seen) + + +class TestLinkPlanItemSession: + def test_append_many_sessions(self, file_context_store): + cs = file_context_store + cid = cs.create_campaign(description="c") + iid = cs.create_plan_item(campaign_id=cid, type="imaging", title="x") + assert cs.link_plan_item_session(iid, "s1") is True + assert cs.link_plan_item_session(iid, "s2") is True + cs.link_plan_item_session(iid, "s1") # duplicate — no-op + item = cs.get_plan_item(iid) + assert item.session_ids == ["s1", "s2"] # appended, deduped + assert item.session_id == "s2" # latest, for back-compat readers + assert item.status == PlanItemStatus.IN_PROGRESS # PLANNED → IN_PROGRESS on first link + + def test_missing_item_returns_false(self, file_context_store): + assert file_context_store.link_plan_item_session("nope", "s1") is False diff --git a/tests/test_plan_item_patch.py b/tests/test_plan_item_patch.py new file mode 100644 index 00000000..b6303f0c --- /dev/null +++ b/tests/test_plan_item_patch.py @@ -0,0 +1,96 @@ +"""Connection D: inline edits to plan items / imaging specs via PATCH. + +The inspector PATCHes changed fields; the store fires PLAN_UPDATED so the +Plans UI refreshes live. Spec edits merge into the existing spec. +""" + +from fastapi import FastAPI +from fastapi.testclient import TestClient + + +def _make_app(context_store): + from gently.ui.web.routes.campaigns import create_router + + app = FastAPI() + + class _Server: + pass + + server = _Server() + server.context_store = context_store + app.include_router(create_router(server)) + return app + + +def _seed_imaging(cs): + cid = cs.create_campaign(description="c", target="goal") + iid = cs.create_plan_item( + campaign_id=cid, + type="imaging", + title="WT baseline", + spec={"strain": "N2", "laser_wavelength_nm": 488}, + ) + return cid, iid + + +class TestPatchItem: + def test_edit_title_and_status(self, file_context_store): + cid, iid = _seed_imaging(file_context_store) + client = TestClient(_make_app(file_context_store)) + r = client.patch( + f"/api/campaigns/{cid}/items/{iid}", + json={"title": "WT baseline (rev)", "status": "in_progress"}, + ) + assert r.status_code == 200 + assert r.json()["ok"] is True + item = file_context_store.get_plan_item(iid) + assert item.title == "WT baseline (rev)" + assert item.status.value == "in_progress" + + def test_fill_laser_power_merges_spec(self, file_context_store): + cid, iid = _seed_imaging(file_context_store) + client = TestClient(_make_app(file_context_store)) + r = client.patch( + f"/api/campaigns/{cid}/items/{iid}", + json={"spec": {"laser_power_pct": 8}}, + ) + assert r.status_code == 200 + item = file_context_store.get_plan_item(iid) + # the filled field is set... + assert item.imaging_spec.laser_power_pct == 8 + # ...and the pre-existing spec fields survive the merge + assert item.imaging_spec.strain == "N2" + assert item.imaging_spec.laser_wavelength_nm == 488 + + def test_empty_string_clears_field(self, file_context_store): + cid, iid = _seed_imaging(file_context_store) + client = TestClient(_make_app(file_context_store)) + r = client.patch(f"/api/campaigns/{cid}/items/{iid}", json={"spec": {"strain": ""}}) + assert r.status_code == 200 + item = file_context_store.get_plan_item(iid) + assert item.imaging_spec.strain is None + + def test_patch_fires_plan_updated(self, file_context_store): + from gently.core.event_bus import EventType, on + + cid, iid = _seed_imaging(file_context_store) + client = TestClient(_make_app(file_context_store)) + seen = [] + unsub = on(EventType.PLAN_UPDATED, lambda e: seen.append(e)) + try: + client.patch(f"/api/campaigns/{cid}/items/{iid}", json={"title": "x"}) + finally: + unsub() + assert any((e.data or {}).get("campaign_id") == cid for e in seen) + + def test_no_fields_is_400(self, file_context_store): + cid, iid = _seed_imaging(file_context_store) + client = TestClient(_make_app(file_context_store)) + r = client.patch(f"/api/campaigns/{cid}/items/{iid}", json={}) + assert r.status_code == 400 + + def test_missing_item_is_404(self, file_context_store): + cid, _ = _seed_imaging(file_context_store) + client = TestClient(_make_app(file_context_store)) + r = client.patch(f"/api/campaigns/{cid}/items/nope", json={"title": "x"}) + assert r.status_code == 404 diff --git a/tests/test_plan_item_tactics.py b/tests/test_plan_item_tactics.py new file mode 100644 index 00000000..825963aa --- /dev/null +++ b/tests/test_plan_item_tactics.py @@ -0,0 +1,135 @@ +""" +Task 9 — ImagingSpec tactical outline. + +Verifies that `tactics` round-trips through the FileContextStore YAML path +and through the SQLite-backed ContextStore path. +""" + +import dataclasses + +from gently.harness.memory.model import ImagingSpec + +TACTICS_OUTLINE = [ + { + "kind": "standing_timelapse", + "name": "baseline timelapse", + "scope": "all embryos", + }, + { + "kind": "reactive_monitor", + "name": "comma-stage speedup", + "target": "comma", + "structure": {"interval_s": 60}, + }, +] + + +# --------------------------------------------------------------------------- +# 1. Model-level: field exists and defaults to empty list +# --------------------------------------------------------------------------- + + +class TestImagingSpecField: + def test_tactics_field_exists(self): + spec = ImagingSpec() + assert hasattr(spec, "tactics") + + def test_tactics_default_empty_list(self): + spec = ImagingSpec() + assert spec.tactics == [] + + def test_tactics_accepts_outline(self): + spec = ImagingSpec(strain="N2", tactics=TACTICS_OUTLINE) + assert spec.tactics == TACTICS_OUTLINE + + def test_tactics_field_in_dataclass_fields(self): + field_names = {f.name for f in dataclasses.fields(ImagingSpec)} + assert "tactics" in field_names + + +# --------------------------------------------------------------------------- +# 2. FileContextStore (YAML) round-trip +# --------------------------------------------------------------------------- + + +class TestFileStoreTactics: + def test_tactics_survive_create_and_get(self, file_context_store): + """Tactics stored in spec persist through create_plan_item → get_plan_item.""" + cid = file_context_store.create_campaign(description="Tactics test") + iid = file_context_store.create_plan_item( + campaign_id=cid, + type="imaging", + title="Tactic-bearing item", + spec={ + "strain": "OH904", + "interval_s": 180, + "tactics": TACTICS_OUTLINE, + }, + ) + item = file_context_store.get_plan_item(iid) + assert item is not None + assert item.imaging_spec is not None + assert item.imaging_spec.tactics == TACTICS_OUTLINE + + def test_tactics_survive_update(self, file_context_store): + """Tactics set via update_plan_item also persist.""" + cid = file_context_store.create_campaign(description="Update tactics") + iid = file_context_store.create_plan_item( + campaign_id=cid, + type="imaging", + title="No tactics yet", + spec={"strain": "N2"}, + ) + # Initially empty + item = file_context_store.get_plan_item(iid) + assert item.imaging_spec.tactics == [] + + # Add tactics via update + new_spec = {"strain": "N2", "tactics": TACTICS_OUTLINE[:1]} + file_context_store.update_plan_item(iid, spec=new_spec) + item = file_context_store.get_plan_item(iid) + assert item.imaging_spec.tactics == TACTICS_OUTLINE[:1] + + def test_existing_items_without_tactics_default_empty(self, file_context_store): + """Items stored without a tactics key default to [] on read-back.""" + cid = file_context_store.create_campaign(description="Legacy item") + iid = file_context_store.create_plan_item( + campaign_id=cid, + type="imaging", + title="Legacy (no tactics key)", + spec={"strain": "N2", "num_slices": 80}, + ) + # Manually strip tactics from the persisted YAML to simulate a pre-tactics record + loc = file_context_store._find_plan_item_location(iid) + campaign_id_found, items, idx = loc + raw = items[idx] + if isinstance(raw.get("spec"), dict): + raw["spec"].pop("tactics", None) + file_context_store._write_plan_items(campaign_id_found, items) + + item = file_context_store.get_plan_item(iid) + assert item.imaging_spec.tactics == [] + + +# --------------------------------------------------------------------------- +# 3. SQLite ContextStore round-trip +# --------------------------------------------------------------------------- + + +class TestContextStoreTactics: + def test_tactics_survive_sqlite_round_trip(self, context_store): + """Tactics stored in spec dict survive through the SQLite ContextStore.""" + cid = context_store.create_campaign(description="SQLite tactics test") + iid = context_store.create_plan_item( + campaign_id=cid, + type="imaging", + title="SQLite tactic item", + spec={ + "strain": "OH904", + "tactics": TACTICS_OUTLINE, + }, + ) + item = context_store.get_plan_item(iid) + assert item is not None + assert item.imaging_spec is not None + assert item.imaging_spec.tactics == TACTICS_OUTLINE diff --git a/tests/test_record_note.py b/tests/test_record_note.py new file mode 100644 index 00000000..ddad3559 --- /dev/null +++ b/tests/test_record_note.py @@ -0,0 +1,39 @@ +"""Tests for the record_note tool — human notes into the shared notebook.""" + +import asyncio +from types import SimpleNamespace + +import gently.app.tools.memory_tools # noqa: F401 — registers record_note +from gently.harness.tools.registry import get_tool_registry + + +def _handler(): + return get_tool_registry().get("record_note").handler + + +class TestRecordNote: + def test_writes_human_note_tagged_to_session(self, file_context_store): + agent = SimpleNamespace(context_store=file_context_store, session_id="sess_1", memory=None) + out = asyncio.run( + _handler()(text="ring formed cleanly", embryos=["e1"], context={"agent": agent}) + ) + assert "Noted" in out + notes = file_context_store.notebook.query_notes() + assert len(notes) == 1 + n = notes[0] + assert n.author.value == "human" + assert n.body == "ring formed cleanly" + assert n.sessions == ["sess_1"] + assert n.embryos == ["e1"] + + def test_no_session_still_records(self, file_context_store): + agent = SimpleNamespace(context_store=file_context_store, session_id=None, memory=None) + asyncio.run(_handler()(text="general observation", context={"agent": agent})) + notes = file_context_store.notebook.query_notes() + assert len(notes) == 1 + assert notes[0].sessions == [] + + def test_no_store_returns_message(self): + agent = SimpleNamespace(context_store=None, session_id=None, memory=None) + out = asyncio.run(_handler()(text="x", context={"agent": agent})) + assert "No notebook available" in out diff --git a/tests/test_role_scope.py b/tests/test_role_scope.py new file mode 100644 index 00000000..045b5e85 --- /dev/null +++ b/tests/test_role_scope.py @@ -0,0 +1,198 @@ +"""Tests for resolve_scope_embryos resolver + role-scoped tactic validation (Task 3, D2). + +Resolver lives at gently.app.orchestration.role_scope.resolve_scope_embryos. + +Contracts: +- mode='global' → all embryo ids +- mode='embryos' + embryo_ids → exactly those ids +- mode='role' + role='test' → ids of embryos with that role +- mode='role' with unknown role → [] (not an error) +- missing/unknown mode → [] +- embryo dicts use 'embryo_id' key (from /api/embryos/positions) + +Scope validation in declare_operation_plan: +- A tactic with scope.mode='role' and a valid REGISTRY key passes validation. +""" + +import pytest + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +_EMBRYOS = [ + {"embryo_id": "e1", "role": "test"}, + {"embryo_id": "e2", "role": "test"}, + {"embryo_id": "e3", "role": "calibration"}, + {"embryo_id": "e4", "role": "lineaging"}, + {"embryo_id": "e5", "role": "unassigned"}, +] + + +# --------------------------------------------------------------------------- +# Resolver tests +# --------------------------------------------------------------------------- + + +def test_global_scope_returns_all_ids(): + from gently.app.orchestration.role_scope import resolve_scope_embryos + + ids = resolve_scope_embryos({"mode": "global"}, _EMBRYOS) + assert set(ids) == {"e1", "e2", "e3", "e4", "e5"} + + +def test_embryos_scope_returns_explicit_ids(): + from gently.app.orchestration.role_scope import resolve_scope_embryos + + ids = resolve_scope_embryos({"mode": "embryos", "embryo_ids": ["e1", "e3"]}, _EMBRYOS) + assert ids == ["e1", "e3"] + + +def test_role_scope_filters_by_role(): + from gently.app.orchestration.role_scope import resolve_scope_embryos + + ids = resolve_scope_embryos({"mode": "role", "role": "test"}, _EMBRYOS) + assert set(ids) == {"e1", "e2"} + + +def test_role_scope_calibration(): + from gently.app.orchestration.role_scope import resolve_scope_embryos + + ids = resolve_scope_embryos({"mode": "role", "role": "calibration"}, _EMBRYOS) + assert ids == ["e3"] + + +def test_role_scope_lineaging(): + from gently.app.orchestration.role_scope import resolve_scope_embryos + + ids = resolve_scope_embryos({"mode": "role", "role": "lineaging"}, _EMBRYOS) + assert ids == ["e4"] + + +def test_role_scope_unknown_role_returns_empty(): + from gently.app.orchestration.role_scope import resolve_scope_embryos + + ids = resolve_scope_embryos({"mode": "role", "role": "nonexistent_role"}, _EMBRYOS) + assert ids == [] + + +def test_unknown_mode_returns_empty(): + from gently.app.orchestration.role_scope import resolve_scope_embryos + + ids = resolve_scope_embryos({"mode": "burst_only"}, _EMBRYOS) + assert ids == [] + + +def test_missing_mode_returns_empty(): + from gently.app.orchestration.role_scope import resolve_scope_embryos + + ids = resolve_scope_embryos({}, _EMBRYOS) + assert ids == [] + + +def test_none_scope_returns_empty(): + from gently.app.orchestration.role_scope import resolve_scope_embryos + + ids = resolve_scope_embryos(None, _EMBRYOS) + assert ids == [] + + +def test_empty_embryo_list(): + from gently.app.orchestration.role_scope import resolve_scope_embryos + + ids = resolve_scope_embryos({"mode": "global"}, []) + assert ids == [] + + +def test_embryo_id_key_used_not_id(): + """Resolver must read 'embryo_id', not 'id'.""" + from gently.app.orchestration.role_scope import resolve_scope_embryos + + # If resolver accidentally uses 'id', this returns [] + embryos = [{"embryo_id": "eX", "role": "test"}] + ids = resolve_scope_embryos({"mode": "global"}, embryos) + assert "eX" in ids + + +# --------------------------------------------------------------------------- +# declare_operation_plan: role-scoped tactic passes validation +# --------------------------------------------------------------------------- + + +class _FakeContextStore: + def __init__(self): + self._plans = {} + + def set_operation_plan(self, sid, plan): + self._plans[sid] = plan + + def get_operation_plan(self, sid): + return self._plans.get(sid) + + +class _FakeAgent: + def __init__(self): + self.context_store = _FakeContextStore() + self.session_id = "sess_scope_test" + + +@pytest.mark.asyncio +async def test_role_scoped_tactic_passes_declare_validation(): + """A tactic with scope.mode='role' and a valid role key is accepted by + declare_operation_plan.""" + from gently.app.tools.operation_plan_tools import declare_operation_plan + + role_scoped_tactic = { + "id": "t-role", + "name": "Test-embryo timelapse", + "kind": "standing_timelapse", + "state": "active", + "scope": {"mode": "role", "role": "test"}, + "rationale": "Only image the test embryos", + "structure": {"cadence_s": 120, "per_embryo": []}, + "live_bind": ["cadence"], + "relations": {}, + } + + ctx = {"agent": _FakeAgent()} + result = await declare_operation_plan( + title="Role-scoped plan", + goal="Image test embryos only", + tactics=[role_scoped_tactic], + updated_reason="role scope test", + context=ctx, + ) + + assert "Error" not in result, f"Unexpected error: {result}" + stored = ctx["agent"].context_store.get_operation_plan("sess_scope_test") + assert stored is not None + tactic = stored["tactics"][0] + assert tactic["scope"]["mode"] == "role" + assert tactic["scope"]["role"] == "test" + + +@pytest.mark.asyncio +async def test_role_scoped_tactic_all_registry_roles(): + """All four REGISTRY role keys are accepted as valid scope.role values.""" + from gently.app.tools.operation_plan_tools import declare_operation_plan + + for role_name in ("test", "calibration", "lineaging", "unassigned"): + tactic = { + "id": f"t-{role_name}", + "name": f"{role_name} monitor", + "kind": "standing_timelapse", + "state": "planned", + "scope": {"mode": "role", "role": role_name}, + "rationale": f"Target {role_name} embryos", + "structure": {"cadence_s": 300}, + "live_bind": [], + "relations": {}, + } + ctx = {"agent": _FakeAgent()} + result = await declare_operation_plan( + title=f"Plan for {role_name}", + goal="Role-specific imaging", + tactics=[tactic], + context=ctx, + ) + assert "Error" not in result, f"Role {role_name!r} was rejected: {result}" diff --git a/tests/test_roles_registry.py b/tests/test_roles_registry.py new file mode 100644 index 00000000..6e824507 --- /dev/null +++ b/tests/test_roles_registry.py @@ -0,0 +1,173 @@ +""" +Tests for gently/harness/roles.py — EmbryoRole registry. + +Covers: +- role_class field presence and correct values for all built-in roles +- lineaging role entry: existence, role_class, ui_color, ui_icon +- accessor functions: get_role, is_valid_role, list_roles +- existing roles' other fields unchanged +""" + +import pytest + +from gently.harness.roles import ( + DEFAULT_ROLE, + REGISTRY, + EmbryoRole, + get_role, + is_valid_role, + list_roles, +) + +# --------------------------------------------------------------------------- +# role_class field — all existing roles +# --------------------------------------------------------------------------- + + +def test_test_role_class_is_subject(): + assert REGISTRY["test"].role_class == "subject" + + +def test_unassigned_role_class_is_subject(): + assert REGISTRY["unassigned"].role_class == "subject" + + +def test_calibration_role_class_is_reference(): + assert REGISTRY["calibration"].role_class == "reference" + + +# --------------------------------------------------------------------------- +# lineaging role — existence and fields +# --------------------------------------------------------------------------- + + +def test_lineaging_role_exists_in_registry(): + assert "lineaging" in REGISTRY + + +def test_lineaging_role_class_is_reference(): + assert REGISTRY["lineaging"].role_class == "reference" + + +def test_lineaging_ui_color(): + # Must be distinct from calibration (#00cccc) and test (#ff66cc) + color = REGISTRY["lineaging"].ui_color + assert color == "#33cc88" + assert color != REGISTRY["calibration"].ui_color + assert color != REGISTRY["test"].ui_color + + +def test_lineaging_ui_icon(): + assert REGISTRY["lineaging"].ui_icon == "triangle" + + +def test_lineaging_description_contains_lineage(): + desc = REGISTRY["lineaging"].description.lower() + assert "lineage" in desc or "nuclei" in desc + + +def test_lineaging_has_cadence(): + assert REGISTRY["lineaging"].default_cadence_seconds > 0 + + +# --------------------------------------------------------------------------- +# Accessor functions +# --------------------------------------------------------------------------- + + +def test_get_role_lineaging_returns_correct_entry(): + role = get_role("lineaging") + assert isinstance(role, EmbryoRole) + assert role.name == "lineaging" + assert role.role_class == "reference" + + +def test_is_valid_role_lineaging_true(): + assert is_valid_role("lineaging") is True + + +def test_is_valid_role_unknown_false(): + assert is_valid_role("nonexistent_role") is False + + +def test_list_roles_includes_lineaging(): + assert "lineaging" in list_roles() + + +def test_list_roles_sorted(): + roles = list_roles() + assert roles == sorted(roles) + + +def test_get_role_unknown_raises_key_error(): + with pytest.raises(KeyError, match="Unknown embryo role"): + get_role("not_a_role") + + +# --------------------------------------------------------------------------- +# Existing roles — other fields unchanged +# --------------------------------------------------------------------------- + + +def test_test_role_fields_unchanged(): + role = REGISTRY["test"] + assert role.name == "test" + assert role.ui_color == "#ff66cc" + assert role.ui_icon == "star" + assert role.photodose_budget_multiplier == 1.0 + assert role.detector_name == "dopaminergic_signal" + assert role.no_object_consecutive_terminal == 5 + + +def test_calibration_role_fields_unchanged(): + role = REGISTRY["calibration"] + assert role.name == "calibration" + assert role.ui_color == "#00cccc" + assert role.ui_icon == "diamond" + assert role.photodose_budget_multiplier == 10.0 + assert role.detector_name == "perception" + assert role.no_object_consecutive_terminal == 2 + + +def test_unassigned_role_fields_unchanged(): + role = REGISTRY["unassigned"] + assert role.name == "unassigned" + assert role.ui_color == "#888888" + assert role.ui_icon == "circle" + assert role.detector_name is None + assert role.no_object_consecutive_terminal is None + + +# --------------------------------------------------------------------------- +# DEFAULT_ROLE sanity +# --------------------------------------------------------------------------- + + +def test_default_role_is_in_registry(): + assert DEFAULT_ROLE in REGISTRY + + +def test_default_role_is_test(): + assert DEFAULT_ROLE == "test" + + +# --------------------------------------------------------------------------- +# EmbryoRole is frozen (immutable) +# --------------------------------------------------------------------------- + + +def test_embryo_role_is_frozen(): + role = REGISTRY["test"] + with pytest.raises(AttributeError): + role.name = "mutated" # type: ignore[misc] + + +# --------------------------------------------------------------------------- +# role_class field has a safe default for keyword construction +# --------------------------------------------------------------------------- + + +def test_embryo_role_default_role_class(): + """New EmbryoRole without role_class defaults to 'subject'.""" + role = EmbryoRole(name="dummy", description="test only") + assert role.role_class == "subject" diff --git a/tests/test_roles_route.py b/tests/test_roles_route.py new file mode 100644 index 00000000..e81218c0 --- /dev/null +++ b/tests/test_roles_route.py @@ -0,0 +1,86 @@ +"""Tests for GET /api/roles — embryo role registry endpoint (Task 3, D2). + +Verifies: +- Returns all roles in the registry including lineaging and role_class. +- Each entry has the expected fields. +- Never raises a 500 (graceful). +""" + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from gently.ui.web.routes.roles import create_router + + +def _client(): + """Build a minimal TestClient with only the roles router.""" + server = object() # roles route never uses server attributes + app = FastAPI() + app.include_router(create_router(server)) + return TestClient(app) + + +def test_roles_returns_200(): + r = _client().get("/api/roles") + assert r.status_code == 200 + + +def test_roles_body_has_roles_key(): + body = _client().get("/api/roles").json() + assert "roles" in body + assert isinstance(body["roles"], list) + + +def test_roles_includes_all_registry_entries(): + """All four built-in roles must be present.""" + roles = _client().get("/api/roles").json()["roles"] + names = {r["name"] for r in roles} + assert names == {"unassigned", "test", "calibration", "lineaging"} + + +def test_roles_include_lineaging(): + roles = _client().get("/api/roles").json()["roles"] + lineaging = next(r for r in roles if r["name"] == "lineaging") + assert lineaging["role_class"] == "reference" + assert lineaging["ui_color"] == "#33cc88" + assert lineaging["ui_icon"] == "triangle" + + +def test_roles_entry_has_all_required_fields(): + """Every role entry exposes the required fields.""" + required = { + "name", + "description", + "role_class", + "ui_color", + "ui_icon", + "default_cadence_seconds", + } + roles = _client().get("/api/roles").json()["roles"] + for role in roles: + missing = required - role.keys() + assert not missing, f"Role {role.get('name')!r} missing fields: {missing}" + + +def test_test_role_class_is_subject(): + roles = _client().get("/api/roles").json()["roles"] + test_role = next(r for r in roles if r["name"] == "test") + assert test_role["role_class"] == "subject" + + +def test_calibration_role_class_is_reference(): + roles = _client().get("/api/roles").json()["roles"] + cal = next(r for r in roles if r["name"] == "calibration") + assert cal["role_class"] == "reference" + + +def test_cadence_seconds_is_numeric(): + roles = _client().get("/api/roles").json()["roles"] + for role in roles: + assert isinstance(role["default_cadence_seconds"], (int, float)) + + +def test_never_500(): + """The route must never raise a 500 regardless of server state.""" + r = _client().get("/api/roles") + assert r.status_code != 500 diff --git a/tests/test_session_plan_linking_routes.py b/tests/test_session_plan_linking_routes.py new file mode 100644 index 00000000..19571227 --- /dev/null +++ b/tests/test_session_plan_linking_routes.py @@ -0,0 +1,283 @@ +"""Route tests: session ↔ plan link/delink HTTP endpoints (Task 2 of F). + +Endpoints under test: + POST /api/campaigns/{cid}/items/{item_id}/sessions → link session to item + DELETE /api/campaigns/{cid}/items/{item_id}/sessions/{sid} → delink session from item + GET /api/sessions/{session_id}/plans → plan items for a session +""" + +from datetime import datetime +from unittest.mock import MagicMock + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from gently.harness.memory.model import PlanItem, PlanItemStatus, PlanItemType, SessionIntent + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_item( + item_id: str, + campaign_id: str, + title: str, + status: PlanItemStatus = PlanItemStatus.PLANNED, +) -> PlanItem: + return PlanItem( + id=item_id, + campaign_id=campaign_id, + type=PlanItemType.BENCH, + title=title, + status=status, + ) + + +def _make_app(mock_cs): + """FastAPI test app with campaign + session routers wired to mock_cs.""" + from gently.ui.web.routes.campaigns import create_router as campaign_router + from gently.ui.web.routes.sessions import create_router as session_router + + app = FastAPI() + + class _Server: + context_store = mock_cs + agent_bridge = None + gently_store = None + + server = _Server() + app.include_router(campaign_router(server)) + app.include_router(session_router(server)) + return app + + +def _mock_cs(item=None, sessions=None): + """Build a mock context store with sensible defaults.""" + cs = MagicMock() + cs.resolve_campaign.return_value = MagicMock(id="c1") + cs.get_plan_item.return_value = item + cs.get_sessions_for_campaign.return_value = sessions if sessions is not None else [] + cs.unlink_plan_item_session.return_value = True + cs.get_plan_items_for_session.return_value = [] + return cs + + +# --------------------------------------------------------------------------- +# POST /api/campaigns/{cid}/items/{item_id}/sessions +# --------------------------------------------------------------------------- + + +class TestPostLinkSession: + def test_calls_link_plan_item_session(self): + item = _make_item("i1", "c1", "Step A") + cs = _mock_cs(item=item) + client = TestClient(_make_app(cs)) + + r = client.post("/api/campaigns/c1/items/i1/sessions", json={"session_id": "s1"}) + + assert r.status_code == 200 + cs.link_plan_item_session.assert_called_once_with("i1", "s1") + + def test_calls_link_session_campaign_with_resolved_id(self): + item = _make_item("i1", "c1", "Step A") + cs = _mock_cs(item=item) + cs.resolve_campaign.return_value = MagicMock(id="c1") + client = TestClient(_make_app(cs)) + + client.post("/api/campaigns/c1/items/i1/sessions", json={"session_id": "s1"}) + + cs.link_session_campaign.assert_called_once_with("s1", "c1") + + def test_returns_sessions_list(self): + item = _make_item("i1", "c1", "Step A") + cs = _mock_cs(item=item, sessions=[]) + client = TestClient(_make_app(cs)) + + r = client.post("/api/campaigns/c1/items/i1/sessions", json={"session_id": "s1"}) + + assert r.status_code == 200 + body = r.json() + assert "sessions" in body + assert isinstance(body["sessions"], list) + + def test_returns_serialized_session_intents(self): + # Item must have session_ids=["s1"] to simulate the post-link state + # (mock link_plan_item_session doesn't mutate the item; we configure + # get_plan_item to return the post-link view so the item-scoped filter works). + item = PlanItem( + id="i1", + campaign_id="c1", + type=PlanItemType.BENCH, + title="Step A", + status=PlanItemStatus.PLANNED, + session_ids=["s1"], + ) + si = SessionIntent( + session_id="s1", + campaign_ids=["c1"], + created_at=datetime(2026, 6, 1), + ) + cs = _mock_cs(item=item, sessions=[si]) + client = TestClient(_make_app(cs)) + + r = client.post("/api/campaigns/c1/items/i1/sessions", json={"session_id": "s1"}) + + assert r.status_code == 200 + sessions = r.json()["sessions"] + assert len(sessions) == 1 + assert sessions[0]["session_id"] == "s1" + assert sessions[0]["campaign_ids"] == ["c1"] + + def test_missing_campaign_is_404(self): + cs = _mock_cs() + cs.resolve_campaign.return_value = None + client = TestClient(_make_app(cs)) + + r = client.post("/api/campaigns/nope/items/i1/sessions", json={"session_id": "s1"}) + + assert r.status_code == 404 + + def test_missing_item_is_404(self): + cs = _mock_cs(item=None) + client = TestClient(_make_app(cs)) + + r = client.post("/api/campaigns/c1/items/nope/sessions", json={"session_id": "s1"}) + + assert r.status_code == 404 + + def test_missing_session_id_body_is_400(self): + item = _make_item("i1", "c1", "Step A") + cs = _mock_cs(item=item) + client = TestClient(_make_app(cs)) + + r = client.post("/api/campaigns/c1/items/i1/sessions", json={}) + + assert r.status_code == 400 + + +# --------------------------------------------------------------------------- +# DELETE /api/campaigns/{cid}/items/{item_id}/sessions/{session_id} +# --------------------------------------------------------------------------- + + +class TestDeleteUnlinkSession: + def test_calls_unlink_plan_item_session(self): + item = _make_item("i1", "c1", "Step A") + cs = _mock_cs(item=item) + cs.unlink_plan_item_session.return_value = True + client = TestClient(_make_app(cs)) + + r = client.delete("/api/campaigns/c1/items/i1/sessions/s1") + + assert r.status_code == 200 + cs.unlink_plan_item_session.assert_called_once_with("i1", "s1") + + def test_returns_unlinked_true(self): + item = _make_item("i1", "c1", "Step A") + cs = _mock_cs(item=item) + cs.unlink_plan_item_session.return_value = True + client = TestClient(_make_app(cs)) + + r = client.delete("/api/campaigns/c1/items/i1/sessions/s1") + + assert r.status_code == 200 + assert r.json()["unlinked"] is True + + def test_returns_unlinked_false_when_not_linked(self): + item = _make_item("i1", "c1", "Step A") + cs = _mock_cs(item=item) + cs.unlink_plan_item_session.return_value = False + client = TestClient(_make_app(cs)) + + r = client.delete("/api/campaigns/c1/items/i1/sessions/s99") + + assert r.status_code == 200 + assert r.json()["unlinked"] is False + + def test_missing_item_is_404(self): + cs = _mock_cs(item=None) + client = TestClient(_make_app(cs)) + + r = client.delete("/api/campaigns/c1/items/nope/sessions/s1") + + assert r.status_code == 404 + + def test_missing_campaign_is_404(self): + cs = _mock_cs() + cs.resolve_campaign.return_value = None + client = TestClient(_make_app(cs)) + + r = client.delete("/api/campaigns/nope/items/i1/sessions/s1") + + assert r.status_code == 404 + + +# --------------------------------------------------------------------------- +# GET /api/sessions/{session_id}/plans +# --------------------------------------------------------------------------- + + +class TestGetSessionPlans: + def test_returns_mapped_plan_items(self): + cs = _mock_cs() + cs.get_plan_items_for_session.return_value = [ + _make_item("i1", "c1", "Step A", PlanItemStatus.PLANNED), + _make_item("i2", "c2", "Step B", PlanItemStatus.IN_PROGRESS), + ] + client = TestClient(_make_app(cs)) + + r = client.get("/api/sessions/s1/plans") + + assert r.status_code == 200 + plans = r.json()["plans"] + assert len(plans) == 2 + assert plans[0] == { + "id": "i1", + "title": "Step A", + "campaign_id": "c1", + "status": "planned", + } + assert plans[1] == { + "id": "i2", + "title": "Step B", + "campaign_id": "c2", + "status": "in_progress", + } + + def test_empty_list_for_unknown_session(self): + cs = _mock_cs() + cs.get_plan_items_for_session.return_value = [] + client = TestClient(_make_app(cs)) + + r = client.get("/api/sessions/ghost/plans") + + assert r.status_code == 200 + assert r.json()["plans"] == [] + + def test_calls_store_with_session_id(self): + cs = _mock_cs() + client = TestClient(_make_app(cs)) + + client.get("/api/sessions/sess_abc/plans") + + cs.get_plan_items_for_session.assert_called_once_with("sess_abc") + + def test_graceful_when_no_context_store(self): + from gently.ui.web.routes.sessions import create_router as session_router + + app = FastAPI() + + class _ServerNoCS: + context_store = None + agent_bridge = None + gently_store = None + + server = _ServerNoCS() + app.include_router(session_router(server)) + client = TestClient(app) + + r = client.get("/api/sessions/s1/plans") + + assert r.status_code == 200 + assert r.json()["plans"] == [] diff --git a/tests/test_session_plan_linking_store.py b/tests/test_session_plan_linking_store.py new file mode 100644 index 00000000..8dba4a10 --- /dev/null +++ b/tests/test_session_plan_linking_store.py @@ -0,0 +1,209 @@ +""" +FileContextStore: session ↔ plan-item link/delink (Task 1 — data layer). + +Tests: +- link a session to items in 2 different campaigns +- get_plan_items_for_session returns both items +- unlink_plan_item_session removes one (the other remains) +- get_plan_items_for_session returns only the remaining item after unlink +- unlinking a session that isn't linked → False, no side-effects +- back-compat scalar session_id is cleared when the unlinked session matched it +""" + +import pytest + +from gently.harness.memory.model import PlanItemType + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_item(store, campaign_id: str, title: str) -> str: + """Create a bench plan item and return its id.""" + return store.create_plan_item( + campaign_id=campaign_id, + type=PlanItemType.BENCH.value, + title=title, + ) + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture() +def two_campaigns(file_context_store): + """Two active campaigns, each with one plan item. Returns (store, cid1, cid2, item1, item2).""" + store = file_context_store + cid1 = store.create_campaign(description="Campaign Alpha") + cid2 = store.create_campaign(description="Campaign Beta") + item1 = _make_item(store, cid1, "Step A — bench prep") + item2 = _make_item(store, cid2, "Step B — gel run") + return store, cid1, cid2, item1, item2 + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + + +class TestLinkAndReverseQuery: + def test_link_and_get_plan_items_for_session(self, two_campaigns): + """Linking a session to 2 items across 2 campaigns; reverse query returns both.""" + store, _cid1, _cid2, item1, item2 = two_campaigns + session_id = "sess_abc" + + store.link_plan_item_session(item1, session_id, set_in_progress=False) + store.link_plan_item_session(item2, session_id, set_in_progress=False) + + items = store.get_plan_items_for_session(session_id) + ids = {it.id for it in items} + assert item1 in ids + assert item2 in ids + assert len(ids) == 2 + + def test_session_not_in_unrelated_item(self, two_campaigns): + """Items not linked to the session don't appear in the reverse query.""" + store, _cid1, _cid2, item1, _item2 = two_campaigns + session_id = "sess_xyz" + + store.link_plan_item_session(item1, session_id, set_in_progress=False) + items = store.get_plan_items_for_session(session_id) + assert len(items) == 1 + assert items[0].id == item1 + + def test_unknown_session_returns_empty(self, two_campaigns): + """A session with no links → empty list, no error.""" + store, *_ = two_campaigns + result = store.get_plan_items_for_session("sess_ghost") + assert result == [] + + +class TestUnlink: + def test_unlink_removes_from_one_campaign(self, two_campaigns): + """Unlinking a session from one item leaves the other item still linked.""" + store, _cid1, _cid2, item1, item2 = two_campaigns + session_id = "sess_def" + + store.link_plan_item_session(item1, session_id, set_in_progress=False) + store.link_plan_item_session(item2, session_id, set_in_progress=False) + + result = store.unlink_plan_item_session(item1, session_id) + assert result is True + + remaining = store.get_plan_items_for_session(session_id) + ids = {it.id for it in remaining} + assert item1 not in ids + assert item2 in ids + + def test_unlink_idempotent_returns_false(self, two_campaigns): + """Unlinking a session that isn't linked returns False without crashing.""" + store, _cid1, _cid2, item1, _item2 = two_campaigns + result = store.unlink_plan_item_session(item1, "sess_nothere") + assert result is False + + def test_unlink_unknown_item_returns_false(self, two_campaigns): + """Unlinking from an item that doesn't exist returns False.""" + store, *_ = two_campaigns + result = store.unlink_plan_item_session("item_ghost", "sess_x") + assert result is False + + def test_unlink_no_side_effect_when_not_linked(self, two_campaigns): + """After a no-op unlink, the item's session_ids are unchanged.""" + store, _cid1, _cid2, item1, _item2 = two_campaigns + session_id = "sess_linked" + store.link_plan_item_session(item1, session_id, set_in_progress=False) + + store.unlink_plan_item_session(item1, "sess_other") # different session — no-op + + item = store.get_plan_item(item1) + assert session_id in item.session_ids + + def test_full_unlink_clears_all_sessions(self, two_campaigns): + """After unlinking all sessions, get_plan_items_for_session returns nothing.""" + store, _cid1, _cid2, item1, _item2 = two_campaigns + session_id = "sess_ghi" + + store.link_plan_item_session(item1, session_id, set_in_progress=False) + store.unlink_plan_item_session(item1, session_id) + + remaining = store.get_plan_items_for_session(session_id) + assert remaining == [] + + +class TestBackCompatSessionId: + def test_back_compat_cleared_on_unlink_when_matched(self, two_campaigns): + """Back-compat scalar session_id is set to None when the last session is unlinked.""" + store, _cid1, _cid2, item1, _item2 = two_campaigns + session_id = "sess_jkl" + + store.link_plan_item_session(item1, session_id, set_in_progress=False) + item_before = store.get_plan_item(item1) + assert item_before.session_id == session_id # back-compat set by link + + store.unlink_plan_item_session(item1, session_id) + + item_after = store.get_plan_item(item1) + assert item_after.session_id is None + assert item_after.session_ids == [] + + def test_back_compat_set_to_most_recent_remaining(self, two_campaigns): + """When one of two sessions is unlinked, back-compat session_id = remaining session.""" + store, _cid1, _cid2, item1, _item2 = two_campaigns + sid1 = "sess_first" + sid2 = "sess_second" + + store.link_plan_item_session(item1, sid1, set_in_progress=False) + store.link_plan_item_session(item1, sid2, set_in_progress=False) + + store.unlink_plan_item_session(item1, sid1) + + item = store.get_plan_item(item1) + assert sid2 in item.session_ids + assert item.session_id == sid2 # most recent remaining + assert sid1 not in item.session_ids + + +class TestBackCompatLegacyScalar: + """Back-compat path: items written before session_ids list existed (scalar only).""" + + def _inject_legacy_item(self, store, campaign_id: str, item_id: str, session_id: str): + """Overwrite a plan item's YAML record to simulate legacy format: + session_id set but session_ids absent/empty — as written by pre-list code.""" + loc = store._find_plan_item_location(item_id) + assert loc is not None, "item must exist before injecting legacy format" + cid, items, idx = loc + items[idx]["session_id"] = session_id + items[idx]["session_ids"] = [] # empty list = pre-list legacy state + store._write_plan_items(cid, items) + + def test_legacy_scalar_found_by_reverse_query(self, two_campaigns): + """get_plan_items_for_session finds an item with only scalar session_id set.""" + store, cid1, _cid2, item1, _item2 = two_campaigns + session_id = "sess_legacy" + + # Inject legacy-format record (scalar session_id, empty session_ids) + self._inject_legacy_item(store, cid1, item1, session_id) + + items = store.get_plan_items_for_session(session_id) + assert any(it.id == item1 for it in items), ( + "expected item1 in results for legacy scalar session_id" + ) + + def test_legacy_scalar_unlinked_by_unlink(self, two_campaigns): + """unlink_plan_item_session removes a legacy-scalar item and returns True.""" + store, cid1, _cid2, item1, _item2 = two_campaigns + session_id = "sess_legacy2" + + self._inject_legacy_item(store, cid1, item1, session_id) + + result = store.unlink_plan_item_session(item1, session_id) + assert result is True + + # Must no longer appear in reverse query + remaining = store.get_plan_items_for_session(session_id) + assert not any(it.id == item1 for it in remaining), ( + "item1 should be removed after unlinking legacy scalar session" + ) diff --git a/tests/test_tactic_executor.py b/tests/test_tactic_executor.py new file mode 100644 index 00000000..c30ac70d --- /dev/null +++ b/tests/test_tactic_executor.py @@ -0,0 +1,203 @@ +""" +Tests for app.orchestration.tactic_executor — the deterministic mapping from a +declarative tactic to orchestrator actions (the first caller of +resolve_scope_embryos). + +Covers: scope resolution (embryos/role/global/empty), kind dispatch +(standing_timelapse + monitoring, reactive_monitor, exclusive_burst, oneshot), +unknown-kind handling, and the transition_tactic 'active' side effect. +""" + +import types + +from gently.app.orchestration.tactic_executor import execute_tactic + + +class _Emb: + def __init__(self, role): + self.role = role + + +class _Orch: + def __init__(self): + self.start_calls = [] + self.monitor_calls = [] + self.burst_calls = [] + + async def start( + self, + embryo_ids=None, + stop_condition="manual", + base_interval_seconds=120.0, + condition_value=None, + ): + self.start_calls.append( + { + "embryo_ids": embryo_ids, + "stop_condition": stop_condition, + "interval": base_interval_seconds, + "condition_value": condition_value, + } + ) + return "started" + + def enable_monitoring_mode(self, name, embryo_ids=None, **kw): + self.monitor_calls.append({"name": name, "embryo_ids": embryo_ids}) + return f"monitor:{name}" + + def queue_burst(self, embryo_id, frames=60, mode="1hz", num_slices=1, tactic_id=None, **kw): + self.burst_calls.append({"embryo_id": embryo_id, "frames": frames, "tactic_id": tactic_id}) + return f"Burst queued for {embryo_id}" + + +class _CS: + def __init__(self): + self.transitions = [] + + def transition_tactic(self, session_id, tactic_id, state=None, **bind): + self.transitions.append((tactic_id, state)) + return True + + +def _agent(embryos): + exp = types.SimpleNamespace(embryos=embryos) + return types.SimpleNamespace( + experiment=exp, + timelapse_orchestrator=_Orch(), + context_store=_CS(), + session_id="sess1", + ) + + +def _roster3(): + return {"embryo_1": _Emb("test"), "embryo_2": _Emb("test"), "embryo_3": _Emb("calibration")} + + +async def test_standing_timelapse_starts_scoped(): + agent = _agent(_roster3()) + t = { + "id": "t1", + "name": "TL", + "kind": "standing_timelapse", + "state": "planned", + "scope": {"mode": "embryos", "embryo_ids": ["embryo_1", "embryo_2"]}, + "structure": {"cadence_s": 90, "stop_condition": "manual", "monitoring_mode": "idle"}, + } + res = await execute_tactic(agent, t) + assert res["ok"] is True + assert res["embryo_ids"] == ["embryo_1", "embryo_2"] + sc = agent.timelapse_orchestrator.start_calls + assert len(sc) == 1 + assert sc[0]["embryo_ids"] == ["embryo_1", "embryo_2"] and sc[0]["interval"] == 90 + # idle → no monitoring installed + assert agent.timelapse_orchestrator.monitor_calls == [] + # tactic marked active + assert ("t1", "active") in agent.context_store.transitions + + +async def test_standing_timelapse_with_monitoring(): + agent = _agent(_roster3()) + t = { + "id": "t2", + "name": "TL", + "kind": "standing_timelapse", + "state": "planned", + "scope": {"mode": "role", "role": "test"}, + "structure": {"interval": 120, "monitoring_mode": "expression_monitoring"}, + } + res = await execute_tactic(agent, t) + assert res["ok"] is True + # role scope → the two 'test' embryos + assert sorted(res["embryo_ids"]) == ["embryo_1", "embryo_2"] + assert agent.timelapse_orchestrator.monitor_calls[0]["name"] == "expression_monitoring" + + +async def test_reactive_monitor(): + agent = _agent(_roster3()) + t = { + "id": "t3", + "name": "M", + "kind": "reactive_monitor", + "state": "planned", + "scope": {"mode": "global"}, + "structure": {"monitoring_mode": "pre_terminal_monitoring"}, + } + res = await execute_tactic(agent, t) + assert res["ok"] is True + assert sorted(res["embryo_ids"]) == ["embryo_1", "embryo_2", "embryo_3"] # global = all + assert agent.timelapse_orchestrator.monitor_calls[0]["name"] == "pre_terminal_monitoring" + + +async def test_exclusive_burst_per_embryo(): + agent = _agent(_roster3()) + t = { + "id": "t4", + "name": "B", + "kind": "exclusive_burst", + "state": "planned", + "scope": {"mode": "embryos", "embryo_ids": ["embryo_1", "embryo_3"]}, + "structure": {"frames": 30}, + } + res = await execute_tactic(agent, t) + assert res["ok"] is True + bc = agent.timelapse_orchestrator.burst_calls + assert [b["embryo_id"] for b in bc] == ["embryo_1", "embryo_3"] + assert all(b["frames"] == 30 and b["tactic_id"] == "t4" for b in bc) + + +async def test_oneshot_recorded_even_without_scope(): + agent = _agent(_roster3()) + t = { + "id": "t5", + "name": "one", + "kind": "oneshot", + "state": "planned", + "scope": {"mode": "embryos", "embryo_ids": []}, + } + res = await execute_tactic(agent, t) + assert res["ok"] is True # oneshot tolerated with empty scope + assert agent.timelapse_orchestrator.start_calls == [] + + +async def test_empty_scope_fails_for_timelapse(): + agent = _agent(_roster3()) + t = { + "id": "t6", + "name": "TL", + "kind": "standing_timelapse", + "state": "planned", + "scope": {"mode": "embryos", "embryo_ids": []}, + "structure": {}, + } + res = await execute_tactic(agent, t) + assert res["ok"] is False + assert "no embryos" in res["message"] + assert agent.timelapse_orchestrator.start_calls == [] + + +async def test_unknown_kind(): + agent = _agent(_roster3()) + t = { + "id": "t7", + "name": "x", + "kind": "warp_drive", + "state": "planned", + "scope": {"mode": "global"}, + } + res = await execute_tactic(agent, t) + assert res["ok"] is False + assert "unknown tactic kind" in res["message"] + + +async def test_no_orchestrator(): + agent = types.SimpleNamespace( + experiment=types.SimpleNamespace(embryos=_roster3()), + timelapse_orchestrator=None, + context_store=_CS(), + session_id="s", + ) + res = await execute_tactic( + agent, + {"id": "t", "kind": "standing_timelapse", "scope": {"mode": "global"}, "structure": {}}, + ) + assert res["ok"] is False and res["message"] == "no orchestrator" diff --git a/tests/test_tactic_library_route.py b/tests/test_tactic_library_route.py new file mode 100644 index 00000000..9d3603f6 --- /dev/null +++ b/tests/test_tactic_library_route.py @@ -0,0 +1,58 @@ +from unittest.mock import MagicMock + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from gently.ui.web.routes.tactic_library import create_router + +_SAMPLE_TACTICS = [ + {"id": "tac-1", "name": "Monitor drift", "description": "Watch Z drift every 5 min"}, + {"id": "tac-2", "name": "Adjust laser", "description": "Drop 488 nm by 10%"}, +] + + +def _server(tactics, context_store_available=True): + context_store = MagicMock() if context_store_available else None + if context_store is not None: + context_store.list_tactics.return_value = tactics + + srv = MagicMock() + srv.context_store = context_store + return srv, context_store + + +def _client(server): + app = FastAPI() + app.include_router(create_router(server)) + return TestClient(app) + + +def test_returns_tactic_library(): + srv, cs = _server(_SAMPLE_TACTICS) + r = _client(srv).get("/api/tactic_library") + assert r.status_code == 200 + body = r.json() + assert body["tactics"] == _SAMPLE_TACTICS + cs.list_tactics.assert_called_once() + + +def test_returns_empty_when_no_tactics(): + srv, cs = _server([]) + r = _client(srv).get("/api/tactic_library") + assert r.status_code == 200 + assert r.json() == {"tactics": []} + + +def test_returns_empty_when_context_store_absent(): + srv, _ = _server(_SAMPLE_TACTICS, context_store_available=False) + r = _client(srv).get("/api/tactic_library") + assert r.status_code == 200 + assert r.json() == {"tactics": []} + + +def test_returns_empty_when_list_tactics_raises(): + srv, cs = _server(_SAMPLE_TACTICS) + cs.list_tactics.side_effect = RuntimeError("disk error") + r = _client(srv).get("/api/tactic_library") + assert r.status_code == 200 + assert r.json() == {"tactics": []} diff --git a/tests/test_tactic_library_store.py b/tests/test_tactic_library_store.py new file mode 100644 index 00000000..125672a4 --- /dev/null +++ b/tests/test_tactic_library_store.py @@ -0,0 +1,316 @@ +""" +FileContextStore — Tactic Library store domain TDD tests. + +Tests: save_tactic / list_tactics / get_tactic / apply_tactic + +Round-trip: save → list → get → apply returns a fresh planned tactic +(new id != template id, state="planned", no "live" key). +get/apply unknown → None. +""" + +import copy + +# --------------------------------------------------------------------------- +# Shared tactic fixture +# --------------------------------------------------------------------------- + +_TACTIC = { + "id": "t_original", + "name": "Baseline timelapse", + "kind": "standing_timelapse", + "state": "active", + "scope": {"mode": "global"}, + "rationale": "Establish pre-ramp cadence", + "structure": {"cadence_s": 120}, + "live_bind": ["cadence"], + "relations": {}, + "live": {"request_id": "req_abc", "started_at": "2026-06-28T10:00:00"}, +} + + +def _fresh_tactic(): + return copy.deepcopy(_TACTIC) + + +# --------------------------------------------------------------------------- +# save_tactic +# --------------------------------------------------------------------------- + + +class TestSaveTactic: + def test_returns_string_id(self, file_context_store): + tid = file_context_store.save_tactic(_fresh_tactic()) + assert isinstance(tid, str) + assert len(tid) > 0 + + def test_id_differs_from_original(self, file_context_store): + """Template id must not equal the source tactic's original id.""" + tid = file_context_store.save_tactic(_fresh_tactic()) + assert tid != "t_original" + + def test_name_param_overrides_tactic_name(self, file_context_store): + tid = file_context_store.save_tactic(_fresh_tactic(), name="Custom Name") + tmpl = file_context_store.get_tactic(tid) + assert tmpl["name"] == "Custom Name" + + def test_name_falls_back_to_tactic_name(self, file_context_store): + tid = file_context_store.save_tactic(_fresh_tactic()) + tmpl = file_context_store.get_tactic(tid) + assert tmpl["name"] == "Baseline timelapse" + + def test_live_state_stripped(self, file_context_store): + """The 'live' key must not appear in the saved template.""" + tid = file_context_store.save_tactic(_fresh_tactic()) + tmpl = file_context_store.get_tactic(tid) + assert "live" not in tmpl + + def test_run_state_not_preserved(self, file_context_store): + """'state' from the source tactic (active) is not stored as the template state.""" + tid = file_context_store.save_tactic(_fresh_tactic()) + tmpl = file_context_store.get_tactic(tid) + # Template itself doesn't carry 'state'; it's restored on apply + assert tmpl.get("state") is None or tmpl.get("state") == "planned" + + def test_structure_preserved(self, file_context_store): + tid = file_context_store.save_tactic(_fresh_tactic()) + tmpl = file_context_store.get_tactic(tid) + assert tmpl["structure"] == {"cadence_s": 120} + + def test_kind_preserved(self, file_context_store): + tid = file_context_store.save_tactic(_fresh_tactic()) + tmpl = file_context_store.get_tactic(tid) + assert tmpl["kind"] == "standing_timelapse" + + def test_file_created(self, file_context_store): + """A YAML file must exist in tactic_library/ after saving.""" + tid = file_context_store.save_tactic(_fresh_tactic()) + tl_dir = file_context_store.agent_dir / "tactic_library" + files = list(tl_dir.iterdir()) + assert any(f.stem.startswith(tid) for f in files) + + def test_fires_context_updated(self, file_context_store): + """save_tactic must fire CONTEXT_UPDATED.""" + from gently.core.event_bus import EventType, on + + seen = [] + unsub = on(EventType.CONTEXT_UPDATED, lambda e: seen.append(e)) + try: + file_context_store.save_tactic(_fresh_tactic()) + finally: + unsub() + assert len(seen) >= 1 + + +# --------------------------------------------------------------------------- +# list_tactics +# --------------------------------------------------------------------------- + + +class TestListTactics: + def test_empty_when_none_saved(self, file_context_store): + assert file_context_store.list_tactics() == [] + + def test_one_entry_after_save(self, file_context_store): + file_context_store.save_tactic(_fresh_tactic()) + result = file_context_store.list_tactics() + assert len(result) == 1 + + def test_two_entries_after_two_saves(self, file_context_store): + file_context_store.save_tactic(_fresh_tactic(), name="Alpha") + file_context_store.save_tactic(_fresh_tactic(), name="Beta") + result = file_context_store.list_tactics() + assert len(result) == 2 + + def test_entries_contain_id_and_name(self, file_context_store): + tid = file_context_store.save_tactic(_fresh_tactic()) + result = file_context_store.list_tactics() + assert result[0]["id"] == tid + assert result[0]["name"] == "Baseline timelapse" + + def test_ordered_newest_first(self, file_context_store): + file_context_store.save_tactic(_fresh_tactic(), name="First") + file_context_store.save_tactic(_fresh_tactic(), name="Second") + result = file_context_store.list_tactics() + # Newest should appear first (sorted by created_at desc) + names = [r["name"] for r in result] + assert names[0] == "Second" + + +# --------------------------------------------------------------------------- +# get_tactic +# --------------------------------------------------------------------------- + + +class TestGetTactic: + def test_get_by_id(self, file_context_store): + tid = file_context_store.save_tactic(_fresh_tactic()) + result = file_context_store.get_tactic(tid) + assert result is not None + assert result["id"] == tid + + def test_get_by_name(self, file_context_store): + file_context_store.save_tactic(_fresh_tactic()) + result = file_context_store.get_tactic("Baseline timelapse") + assert result is not None + assert result["kind"] == "standing_timelapse" + + def test_get_unknown_returns_none(self, file_context_store): + result = file_context_store.get_tactic("no_such_tactic") + assert result is None + + def test_get_unknown_id_returns_none(self, file_context_store): + file_context_store.save_tactic(_fresh_tactic()) + result = file_context_store.get_tactic("deadbeef") + assert result is None + + +# --------------------------------------------------------------------------- +# apply_tactic +# --------------------------------------------------------------------------- + + +class TestApplyTactic: + def test_apply_by_id_returns_dict(self, file_context_store): + tid = file_context_store.save_tactic(_fresh_tactic()) + applied = file_context_store.apply_tactic(tid) + assert isinstance(applied, dict) + + def test_apply_by_name_returns_dict(self, file_context_store): + file_context_store.save_tactic(_fresh_tactic()) + applied = file_context_store.apply_tactic("Baseline timelapse") + assert isinstance(applied, dict) + + def test_apply_unknown_returns_none(self, file_context_store): + result = file_context_store.apply_tactic("no_such_tactic") + assert result is None + + def test_apply_unknown_id_returns_none(self, file_context_store): + file_context_store.save_tactic(_fresh_tactic()) + result = file_context_store.apply_tactic("deadbeef") + assert result is None + + def test_applied_id_differs_from_template_id(self, file_context_store): + """apply_tactic must return a FRESH id, distinct from the template id.""" + tid = file_context_store.save_tactic(_fresh_tactic()) + applied = file_context_store.apply_tactic(tid) + assert applied["id"] != tid + + def test_applied_id_differs_from_source_tactic_id(self, file_context_store): + """Applied id must not equal the original source tactic's id either.""" + tid = file_context_store.save_tactic(_fresh_tactic()) + applied = file_context_store.apply_tactic(tid) + assert applied["id"] != "t_original" + + def test_applied_state_is_planned(self, file_context_store): + """Returned tactic must have state='planned' regardless of template source state.""" + tid = file_context_store.save_tactic(_fresh_tactic()) + applied = file_context_store.apply_tactic(tid) + assert applied["state"] == "planned" + + def test_applied_has_no_live_key(self, file_context_store): + """Returned tactic must not carry any 'live' runtime state.""" + tid = file_context_store.save_tactic(_fresh_tactic()) + applied = file_context_store.apply_tactic(tid) + assert "live" not in applied + + def test_applied_structure_matches_template(self, file_context_store): + tid = file_context_store.save_tactic(_fresh_tactic()) + applied = file_context_store.apply_tactic(tid) + assert applied["structure"] == {"cadence_s": 120} + + def test_applied_kind_matches_template(self, file_context_store): + tid = file_context_store.save_tactic(_fresh_tactic()) + applied = file_context_store.apply_tactic(tid) + assert applied["kind"] == "standing_timelapse" + + def test_applied_is_independent_copy(self, file_context_store): + """Mutating the applied tactic must not affect a second apply call.""" + tid = file_context_store.save_tactic(_fresh_tactic()) + first = file_context_store.apply_tactic(tid) + first["structure"]["cadence_s"] = 999 + second = file_context_store.apply_tactic(tid) + assert second["structure"]["cadence_s"] == 120 + + def test_two_applies_have_different_ids(self, file_context_store): + """Each apply call returns a fresh id.""" + tid = file_context_store.save_tactic(_fresh_tactic()) + a1 = file_context_store.apply_tactic(tid) + a2 = file_context_store.apply_tactic(tid) + assert a1["id"] != a2["id"] + + def test_applied_no_slug_metadata(self, file_context_store): + """Template-internal 'slug' must not appear in the applied tactic.""" + tid = file_context_store.save_tactic(_fresh_tactic()) + applied = file_context_store.apply_tactic(tid) + assert "slug" not in applied + + def test_applied_no_created_at(self, file_context_store): + """Template 'created_at' must not bleed into the applied tactic.""" + tid = file_context_store.save_tactic(_fresh_tactic()) + applied = file_context_store.apply_tactic(tid) + assert "created_at" not in applied + + +# --------------------------------------------------------------------------- +# Fix 1 — scope round-trip +# --------------------------------------------------------------------------- + + +class TestScopeRoundTrip: + def test_applied_scope_matches_source(self, file_context_store): + """scope from source tactic must survive the save→apply round-trip.""" + tid = file_context_store.save_tactic(_fresh_tactic()) + applied = file_context_store.apply_tactic(tid) + assert applied["scope"] == {"mode": "global"} + + def test_applied_no_scope_when_source_has_none(self, file_context_store): + """If source tactic has no scope, applied tactic must not gain a scope key.""" + tactic = _fresh_tactic() + tactic.pop("scope") + tid = file_context_store.save_tactic(tactic) + applied = file_context_store.apply_tactic(tid) + assert "scope" not in applied + + +# --------------------------------------------------------------------------- +# Fix 2 — rationale preservation +# --------------------------------------------------------------------------- + + +class TestRationalePreservation: + def test_rationale_survives_save_apply(self, file_context_store): + """rationale from source tactic must survive the save→apply round-trip.""" + tid = file_context_store.save_tactic(_fresh_tactic()) + applied = file_context_store.apply_tactic(tid) + assert applied.get("rationale") == "Establish pre-ramp cadence" + + def test_rationale_in_template(self, file_context_store): + """rationale must be stored in the saved template, not only in description.""" + tid = file_context_store.save_tactic(_fresh_tactic()) + tmpl = file_context_store.get_tactic(tid) + assert tmpl.get("rationale") == "Establish pre-ramp cadence" + + +# --------------------------------------------------------------------------- +# Fix 3 — deterministic name lookup (newest-wins on collision) +# --------------------------------------------------------------------------- + + +class TestGetTacticDeterministic: + def test_name_collision_returns_newest(self, file_context_store): + """When two tactics share a name, get_tactic(name) must return the newest.""" + import time + + t1 = _fresh_tactic() + t1["structure"] = {"cadence_s": 60} + file_context_store.save_tactic(t1, name="Shared Name") + # Small pause to guarantee distinct created_at timestamps + time.sleep(0.002) + t2 = _fresh_tactic() + t2["structure"] = {"cadence_s": 120} + file_context_store.save_tactic(t2, name="Shared Name") + + result = file_context_store.get_tactic("Shared Name") + assert result is not None + # Newest entry (cadence_s=120) must win + assert result["structure"]["cadence_s"] == 120 diff --git a/tests/test_tactic_library_tools.py b/tests/test_tactic_library_tools.py new file mode 100644 index 00000000..e6ee147e --- /dev/null +++ b/tests/test_tactic_library_tools.py @@ -0,0 +1,374 @@ +""" +TDD tests for gently/app/tools/tactic_library_tools.py + +Tests: +- save_tactic: calls store.save_tactic and returns a confirmation with the id +- list_tactics: returns readable summary from store.list_tactics +- apply_tactic: fetches template, appends fresh planned tactic to Operation Plan +- apply_tactic unknown id: returns error, does NOT call set_operation_plan +- missing context_store: returns error for all three tools +- tools are importable and registered in the tool registry +""" + +import copy + +import pytest + +# --------------------------------------------------------------------------- +# Shared tactic fixture +# --------------------------------------------------------------------------- + +_TACTIC = { + "id": "t_original", + "name": "Baseline timelapse", + "kind": "standing_timelapse", + "state": "active", + "scope": {"mode": "global"}, + "rationale": "Continuous pre-ramp imaging", + "structure": {"cadence_s": 120, "per_embryo": []}, + "live_bind": ["cadence"], + "relations": {}, +} + + +def _fresh_tactic(): + return copy.deepcopy(_TACTIC) + + +# --------------------------------------------------------------------------- +# Fake context store +# --------------------------------------------------------------------------- + + +class FakeContextStore: + """Minimal context store double for tool tests.""" + + def __init__(self): + self._library: dict[str, dict] = {} # id -> template + self._plans: dict[str, dict] = {} # session_id -> plan + self.save_tactic_calls: list = [] + self.set_operation_plan_calls: list = [] + + def save_tactic(self, tactic: dict, name: str | None = None) -> str: + tid = f"tpl_{len(self._library) + 1}" + name = name or tactic.get("name") or "unnamed" + template = { + "id": tid, + "name": name, + "kind": tactic.get("kind", "unknown"), + "description": tactic.get("description") or tactic.get("rationale"), + } + self._library[tid] = template + self.save_tactic_calls.append({"tactic": tactic, "name": name, "returned_id": tid}) + return tid + + def list_tactics(self) -> list[dict]: + return list(self._library.values()) + + def get_tactic(self, id_or_name: str) -> dict | None: + for t in self._library.values(): + if t.get("id") == id_or_name or t.get("name") == id_or_name: + return copy.deepcopy(t) + return None + + def apply_tactic(self, id_or_name: str) -> dict | None: + tmpl = self.get_tactic(id_or_name) + if tmpl is None: + return None + # Return a fresh planned tactic with a new id + fresh = copy.deepcopy(tmpl) + fresh["id"] = f"run_{id_or_name}" + fresh["state"] = "planned" + return fresh + + def get_operation_plan(self, session_id: str) -> dict | None: + return copy.deepcopy(self._plans.get(session_id)) + + def set_operation_plan(self, session_id: str, plan: dict) -> None: + self._plans[session_id] = copy.deepcopy(plan) + self.set_operation_plan_calls.append( + {"session_id": session_id, "plan": copy.deepcopy(plan)} + ) + + +# --------------------------------------------------------------------------- +# Fake agent +# --------------------------------------------------------------------------- + + +class FakeAgent: + def __init__(self, session_id="sess_001", context_store=None): + self.session_id = session_id + self.context_store = context_store or FakeContextStore() + + +def _make_context(agent): + return {"agent": agent} + + +# --------------------------------------------------------------------------- +# Import the tools under test +# --------------------------------------------------------------------------- + + +from gently.app.tools.tactic_library_tools import ( # noqa: E402 + apply_tactic, + list_tactics, + save_tactic, +) + +# --------------------------------------------------------------------------- +# save_tactic tests +# --------------------------------------------------------------------------- + + +class TestSaveTactic: + @pytest.mark.asyncio + async def test_calls_store_save_tactic(self): + """save_tactic must call context_store.save_tactic once.""" + cs = FakeContextStore() + agent = FakeAgent(context_store=cs) + await save_tactic( + name="Baseline timelapse", + tactic=_fresh_tactic(), + context=_make_context(agent), + ) + assert len(cs.save_tactic_calls) == 1 + + @pytest.mark.asyncio + async def test_passes_name_through(self): + """The name passed to the tool must reach store.save_tactic.""" + cs = FakeContextStore() + agent = FakeAgent(context_store=cs) + await save_tactic( + name="My custom tactic", + tactic=_fresh_tactic(), + context=_make_context(agent), + ) + call = cs.save_tactic_calls[0] + assert call["name"] == "My custom tactic" + + @pytest.mark.asyncio + async def test_returns_confirmation_with_id(self): + """Return value must contain the new template id.""" + cs = FakeContextStore() + agent = FakeAgent(context_store=cs) + result = await save_tactic( + name="Baseline timelapse", + tactic=_fresh_tactic(), + context=_make_context(agent), + ) + # The id returned by the fake store is "tpl_1" + assert "tpl_1" in result + + @pytest.mark.asyncio + async def test_description_merged_into_tactic(self): + """If description is provided it should be in the tactic passed to the store.""" + cs = FakeContextStore() + agent = FakeAgent(context_store=cs) + await save_tactic( + name="Baseline timelapse", + tactic=_fresh_tactic(), + description="Override description", + context=_make_context(agent), + ) + saved = cs.save_tactic_calls[0]["tactic"] + assert saved.get("description") == "Override description" + + @pytest.mark.asyncio + async def test_missing_context_store_returns_error(self): + """If context_store is None, return an error string.""" + agent = FakeAgent(context_store=None) + agent.context_store = None + result = await save_tactic( + name="x", + tactic=_fresh_tactic(), + context=_make_context(agent), + ) + assert result.startswith("Error:") + + @pytest.mark.asyncio + async def test_missing_agent_returns_error(self): + result = await save_tactic(name="x", tactic=_fresh_tactic(), context={}) + assert result.startswith("Error:") + + +# --------------------------------------------------------------------------- +# list_tactics tests +# --------------------------------------------------------------------------- + + +class TestListTactics: + @pytest.mark.asyncio + async def test_returns_readable_summary(self): + """list_tactics should include id, name, kind in the output.""" + cs = FakeContextStore() + agent = FakeAgent(context_store=cs) + # Pre-populate library + cs.save_tactic(_fresh_tactic(), name="Baseline timelapse") + result = await list_tactics(context=_make_context(agent)) + assert "tpl_1" in result + assert "Baseline timelapse" in result + assert "standing_timelapse" in result + + @pytest.mark.asyncio + async def test_empty_library_message(self): + cs = FakeContextStore() + agent = FakeAgent(context_store=cs) + result = await list_tactics(context=_make_context(agent)) + assert "empty" in result.lower() or "no saved" in result.lower() + + @pytest.mark.asyncio + async def test_missing_context_store_returns_error(self): + agent = FakeAgent(context_store=None) + agent.context_store = None + result = await list_tactics(context=_make_context(agent)) + assert result.startswith("Error:") + + @pytest.mark.asyncio + async def test_missing_agent_returns_error(self): + result = await list_tactics(context={}) + assert result.startswith("Error:") + + +# --------------------------------------------------------------------------- +# apply_tactic tests +# --------------------------------------------------------------------------- + + +class TestApplyTactic: + @pytest.mark.asyncio + async def test_appends_tactic_to_existing_plan(self): + """apply_tactic must append the fresh tactic to the session's Operation Plan.""" + cs = FakeContextStore() + agent = FakeAgent(context_store=cs) + # Seed library + existing plan + cs.save_tactic(_fresh_tactic(), name="Baseline timelapse") + cs._plans["sess_001"] = { + "session_id": "sess_001", + "title": "Test plan", + "goal": "Test goal", + "tactics": [], + } + + await apply_tactic(id_or_name="Baseline timelapse", context=_make_context(agent)) + + assert len(cs.set_operation_plan_calls) == 1 + saved_plan = cs.set_operation_plan_calls[0]["plan"] + assert len(saved_plan["tactics"]) == 1 + assert saved_plan["tactics"][0]["state"] == "planned" + + @pytest.mark.asyncio + async def test_creates_minimal_plan_if_none_exists(self): + """If no plan exists yet, apply_tactic creates a minimal one.""" + cs = FakeContextStore() + agent = FakeAgent(context_store=cs) + cs.save_tactic(_fresh_tactic(), name="Baseline timelapse") + + await apply_tactic(id_or_name="Baseline timelapse", context=_make_context(agent)) + + assert len(cs.set_operation_plan_calls) == 1 + plan = cs.set_operation_plan_calls[0]["plan"] + assert plan["session_id"] == "sess_001" + assert len(plan["tactics"]) == 1 + + @pytest.mark.asyncio + async def test_appends_not_replaces(self): + """apply_tactic appends; an existing tactic in the plan must remain.""" + cs = FakeContextStore() + agent = FakeAgent(context_store=cs) + cs.save_tactic(_fresh_tactic(), name="Baseline timelapse") + cs._plans["sess_001"] = { + "session_id": "sess_001", + "title": "", + "goal": "", + "tactics": [ + {"id": "existing_t", "name": "Prior tactic", "kind": "oneshot", "state": "done"} + ], + } + + await apply_tactic(id_or_name="Baseline timelapse", context=_make_context(agent)) + + plan = cs.set_operation_plan_calls[0]["plan"] + assert len(plan["tactics"]) == 2 + ids = [t["id"] for t in plan["tactics"]] + assert "existing_t" in ids + + @pytest.mark.asyncio + async def test_unknown_id_returns_error_no_plan_write(self): + """Unknown tactic id → error string, set_operation_plan must NOT be called.""" + cs = FakeContextStore() + agent = FakeAgent(context_store=cs) + + result = await apply_tactic(id_or_name="nonexistent", context=_make_context(agent)) + + assert result.startswith("Error:") + assert len(cs.set_operation_plan_calls) == 0 + + @pytest.mark.asyncio + async def test_missing_context_store_returns_error(self): + agent = FakeAgent(context_store=None) + agent.context_store = None + result = await apply_tactic(id_or_name="anything", context=_make_context(agent)) + assert result.startswith("Error:") + + @pytest.mark.asyncio + async def test_missing_session_id_returns_error(self): + cs = FakeContextStore() + agent = FakeAgent(context_store=cs) + agent.session_id = None + result = await apply_tactic(id_or_name="anything", context=_make_context(agent)) + assert result.startswith("Error:") + + @pytest.mark.asyncio + async def test_missing_agent_returns_error(self): + result = await apply_tactic(id_or_name="anything", context={}) + assert result.startswith("Error:") + + @pytest.mark.asyncio + async def test_return_value_names_tactic_and_session(self): + """Confirmation message should mention the tactic name and session id.""" + cs = FakeContextStore() + agent = FakeAgent(context_store=cs) + cs.save_tactic(_fresh_tactic(), name="Baseline timelapse") + + result = await apply_tactic(id_or_name="Baseline timelapse", context=_make_context(agent)) + + assert "Baseline timelapse" in result + assert "sess_001" in result + + +# --------------------------------------------------------------------------- +# Registration smoke test +# --------------------------------------------------------------------------- + + +class TestRegistration: + def test_tools_are_importable(self): + """The three tools must import without error.""" + from gently.app.tools.tactic_library_tools import ( + apply_tactic, + list_tactics, + save_tactic, + ) + + assert callable(save_tactic) + assert callable(list_tactics) + assert callable(apply_tactic) + + def test_module_in_tools_package(self): + """tactic_library_tools must be in tools.__init__ exports.""" + import gently.app.tools as tools_pkg + + assert hasattr(tools_pkg, "tactic_library_tools") + + def test_tools_registered_in_registry(self): + """The three tools must appear by name in the tool registry.""" + import gently.app.tools # noqa: F401 — ensure registration side-effect + from gently.harness.tools.registry import get_tool_registry + + registry = get_tool_registry() + names = {entry.name for entry in registry.list_all()} + assert "save_tactic" in names, f"save_tactic not in registry: {names}" + assert "list_tactics" in names, f"list_tactics not in registry: {names}" + assert "apply_tactic" in names, f"apply_tactic not in registry: {names}" diff --git a/tests/test_temp_protocol_driver.py b/tests/test_temp_protocol_driver.py new file mode 100644 index 00000000..cd2b5f30 --- /dev/null +++ b/tests/test_temp_protocol_driver.py @@ -0,0 +1,86 @@ +import pytest + +from gently.app.orchestration.temperature_protocol import run_temp_change_burst_protocol +from gently.core.event_bus import EventType + + +class FakeClient: + def __init__(self): + self.laser = None + self.led = None + self.setpoint = None + self._poll = 0 + + async def set_laser_config(self, c): + self.laser = c + + async def set_led(self, s): + self.led = s + + async def set_temperature(self, t): + self.setpoint = t + + async def get_temperature(self): + self._poll += 1 + return {"state": "[ SYSTEM LOCKED ]" if self._poll >= 2 else "[ HEATING ]"} + + +class FakeOrch: + def __init__(self, client): + self._client = client + self._temperature_provider = lambda: None + self.events = [] + + @property + def client(self): + return self._client + + def _emit_event(self, et, data): + self.events.append((et, data)) + + +@pytest.mark.asyncio +async def test_phase_order_and_brightfield(monkeypatch): + client = FakeClient() + orch = FakeOrch(client) + bursts = [] + + async def runner(b): + bursts.append({"phase": getattr(b, "_phase", None), "laser": b._laser_config}) + + res = await run_temp_change_burst_protocol( + orch, + "emb1", + 25.0, + frames=3, + bursts_before=1, + bursts_after=1, + lock_timeout_s=5.0, + poll_s=0.001, + burst_runner=runner, + ) + assert client.laser == "ALL OFF" and client.led == "Open" + assert client.setpoint == 25.0 + assert all(b["laser"] == "ALL OFF" for b in bursts) # every burst brightfield + assert len(bursts) >= 3 # before + >=1 during + after + ets = [e[0] for e in orch.events] + assert EventType.TEMP_PROTOCOL_STARTED in ets + assert EventType.TEMPERATURE_SETPOINT_CHANGED in ets + assert EventType.TEMP_PROTOCOL_COMPLETED in ets + assert res["locked"] is True + + # Phase order: first burst must be "before", last must be "after" + phases = [b["phase"] for b in bursts] + assert phases[0] == "before", f"Expected first burst phase 'before', got {phases[0]!r}" + assert phases[-1] == "after", f"Expected last burst phase 'after', got {phases[-1]!r}" + + # Event order: STARTED < SETPOINT_CHANGED < COMPLETED + idx_started = ets.index(EventType.TEMP_PROTOCOL_STARTED) + idx_changed = ets.index(EventType.TEMPERATURE_SETPOINT_CHANGED) + idx_completed = ets.index(EventType.TEMP_PROTOCOL_COMPLETED) + assert idx_started < idx_changed, ( + "TEMP_PROTOCOL_STARTED must precede TEMPERATURE_SETPOINT_CHANGED" + ) + assert idx_changed < idx_completed, ( + "TEMPERATURE_SETPOINT_CHANGED must precede TEMP_PROTOCOL_COMPLETED" + ) diff --git a/tests/test_temp_protocol_events.py b/tests/test_temp_protocol_events.py new file mode 100644 index 00000000..62883fae --- /dev/null +++ b/tests/test_temp_protocol_events.py @@ -0,0 +1,18 @@ +"""Test for temperature protocol event types (Task 1 of temp-change-burst-tactic)""" + +from gently.core.event_bus import EventType + + +def test_new_event_types_exist(): + """Verify the three new EventType members exist""" + for n in ("TEMPERATURE_SETPOINT_CHANGED", "TEMP_PROTOCOL_STARTED", "TEMP_PROTOCOL_COMPLETED"): + assert getattr(EventType, n).name == n + + +def test_timeline_maps_the_subtypes(): + """Verify timeline.py contains the mapping entries with correct subtypes""" + from gently.harness.session import timeline as tl + + src = open(tl.__file__, encoding="utf-8").read() + for sub in ("temp_protocol_started", "temp_protocol_completed", "setpoint_changed"): + assert sub in src diff --git a/tests/test_temp_protocol_snapshot.py b/tests/test_temp_protocol_snapshot.py new file mode 100644 index 00000000..2210c1f5 --- /dev/null +++ b/tests/test_temp_protocol_snapshot.py @@ -0,0 +1,188 @@ +"""Task 6: temp-protocol band + setpoint changes visible in strategy_snapshot. + +Feed _replay_timeline (via build_strategy_snapshot over a hand-written +timeline.jsonl) a sequence: + temp_protocol_started → setpoint_changed(to=25) → + burst_started → burst_completed → temp_protocol_completed + +Assert the returned snapshot exposes: + embryos[0]["temp_protocol"] — a band with start < end, correct params + embryos[0]["setpoint_changes"] — [{t: ..., to: 25}] +""" + +import json +from datetime import datetime, timedelta +from pathlib import Path + +import pytest +import yaml + +from gently.ui.web.strategy_snapshot import build_strategy_snapshot + +SESSION_ID = "sess-tp1" +EMBRYO_ID = "e1" +STARTED_AT = datetime(2025, 1, 1, 10, 0, 0) +TARGET_C = 25.0 + + +def _ts(delta_s: float) -> str: + return (STARTED_AT + timedelta(seconds=delta_s)).isoformat() + + +def _write_session(session_dir: Path) -> None: + session_dir.mkdir(parents=True, exist_ok=True) + + # Minimal session.yaml + (session_dir / "session.yaml").write_text( + yaml.dump({"name": "temp-protocol test"}), encoding="utf-8" + ) + + # timelapse.yaml — minimal; one embryo + timelapse = { + "started_at": STARTED_AT.isoformat(), + "base_interval_seconds": 120, + "embryos": { + EMBRYO_ID: {"interval_seconds": 120}, + }, + } + (session_dir / "timelapse.yaml").write_text(yaml.dump(timelapse), encoding="utf-8") + + # timeline.jsonl — the sequence under test + events = [ + { + "event_id": "ev-tps", + "event_type": "tactic", + "event_subtype": "temp_protocol_started", + "timestamp": _ts(10), + "source": "test", + "session_id": SESSION_ID, + "embryo_id": EMBRYO_ID, + "data": { + "embryo_id": EMBRYO_ID, + "target_setpoint_c": TARGET_C, + "frames": 60, + "bursts_before": 1, + "bursts_after": 1, + }, + }, + { + "event_id": "ev-sc", + "event_type": "temperature", + "event_subtype": "setpoint_changed", + "timestamp": _ts(20), + "source": "test", + "session_id": SESSION_ID, + "embryo_id": EMBRYO_ID, + "data": {"embryo_id": EMBRYO_ID, "to": TARGET_C}, + }, + { + "event_id": "ev-bs", + "event_type": "timelapse", + "event_subtype": "burst_started", + "timestamp": _ts(30), + "source": "test", + "session_id": SESSION_ID, + "embryo_id": EMBRYO_ID, + "data": {"embryo_id": EMBRYO_ID, "mode": "1hz", "frames": 60}, + }, + { + "event_id": "ev-bc", + "event_type": "timelapse", + "event_subtype": "burst_completed", + "timestamp": _ts(40), + "source": "test", + "session_id": SESSION_ID, + "embryo_id": EMBRYO_ID, + "data": {"embryo_id": EMBRYO_ID}, + }, + { + "event_id": "ev-tpc", + "event_type": "tactic", + "event_subtype": "temp_protocol_completed", + "timestamp": _ts(50), + "source": "test", + "session_id": SESSION_ID, + "embryo_id": EMBRYO_ID, + "data": {"embryo_id": EMBRYO_ID, "locked": True, "cancelled": False, "error": None}, + }, + ] + timeline_path = session_dir / "timeline.jsonl" + with open(timeline_path, "w", encoding="utf-8") as f: + for ev in events: + f.write(json.dumps(ev) + "\n") + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + + +def test_temp_protocol_band_present(tmp_path: Path) -> None: + """Snapshot exposes a temp_protocol band with start and end on the embryo.""" + session_dir = tmp_path / "sessions" / "20250101_1000_test" + _write_session(session_dir) + + snap = build_strategy_snapshot(session_dir, SESSION_ID) + + assert snap["embryos"], "no embryos in snapshot" + emb = snap["embryos"][0] + + tp = emb.get("temp_protocol") + assert tp is not None, "temp_protocol key missing from embryo" + assert tp["start"] == pytest.approx(10.0), f"expected start=10s, got {tp['start']}" + assert tp["end"] == pytest.approx(50.0), f"expected end=50s, got {tp['end']}" + assert tp["target_setpoint_c"] == pytest.approx(TARGET_C) + + +def test_setpoint_changes_recorded(tmp_path: Path) -> None: + """Snapshot exposes setpoint_changes list with one entry {t, to}.""" + session_dir = tmp_path / "sessions" / "20250101_1000_test" + _write_session(session_dir) + + snap = build_strategy_snapshot(session_dir, SESSION_ID) + + emb = snap["embryos"][0] + sc = emb.get("setpoint_changes") + assert sc is not None, "setpoint_changes key missing from embryo" + assert len(sc) == 1, f"expected 1 setpoint change, got {len(sc)}" + assert sc[0]["t"] == pytest.approx(20.0) + assert sc[0]["to"] == pytest.approx(TARGET_C) + + +def test_burst_phase_still_present(tmp_path: Path) -> None: + """Existing burst phase handling is not disrupted by temp-protocol events.""" + session_dir = tmp_path / "sessions" / "20250101_1000_test" + _write_session(session_dir) + + snap = build_strategy_snapshot(session_dir, SESSION_ID) + + emb = snap["embryos"][0] + burst_phases = [p for p in emb["phases"] if p.get("mode") == "burst"] + assert burst_phases, "burst phase disappeared after temp-protocol changes" + bp = burst_phases[0] + assert bp["start"] == pytest.approx(30.0) + # end is extended to now_offset_s by the tail-close sweep when no + # cadence_changed follows — just confirm it's set and >= burst start + assert bp["end"] is not None and bp["end"] >= 30.0 + + +def test_temp_protocol_fields_initialized_even_without_events(tmp_path: Path) -> None: + """Embryo dicts always have temp_protocol and setpoint_changes, even with no events.""" + session_dir = tmp_path / "sessions" / "20250101_1000_empty" + session_dir.mkdir(parents=True, exist_ok=True) + (session_dir / "session.yaml").write_text("{}", encoding="utf-8") + timelapse = { + "started_at": STARTED_AT.isoformat(), + "base_interval_seconds": 120, + "embryos": {EMBRYO_ID: {"interval_seconds": 120}}, + } + (session_dir / "timelapse.yaml").write_text(yaml.dump(timelapse), encoding="utf-8") + # No timeline.jsonl + + snap = build_strategy_snapshot(session_dir, SESSION_ID) + + emb = snap["embryos"][0] + assert "temp_protocol" in emb, "temp_protocol key absent with no events" + assert "setpoint_changes" in emb, "setpoint_changes key absent with no events" + assert emb["temp_protocol"] is None + assert emb["setpoint_changes"] == [] diff --git a/tests/test_temp_protocol_tool.py b/tests/test_temp_protocol_tool.py new file mode 100644 index 00000000..d9fe748e --- /dev/null +++ b/tests/test_temp_protocol_tool.py @@ -0,0 +1,207 @@ +""" +Tests for the run_temp_change_burst_protocol agent tool (Task 5). + +TDD: write failing tests first, then implement the tool. +""" + +from unittest.mock import MagicMock, patch + +import pytest + +# --------------------------------------------------------------------------- +# Fakes +# --------------------------------------------------------------------------- + + +class FakeClient: + """Minimal fake microscope client.""" + + async def set_temperature(self, t): + return {"success": True, "temperature_c": t, "state": "[ HEATING ]"} + + async def get_temperature(self): + return {"success": True, "temperature_c": 20.0, "state": "[ SYSTEM LOCKED ]"} + + +class FakeOrchestrator: + """Minimal fake timelapse orchestrator with a client attribute.""" + + def __init__(self, client): + self.client = client + + def _emit_event(self, *args, **kwargs): + pass + + +class FakeAgent: + """Minimal fake agent — carries timelapse_orchestrator.""" + + def __init__(self, orchestrator): + self.timelapse_orchestrator = orchestrator + + +def _make_context(*, with_client=True, with_orchestrator=True): + """Build a fake context dict.""" + client = FakeClient() if with_client else None + orchestrator = FakeOrchestrator(client) if with_orchestrator else None + agent = FakeAgent(orchestrator) if with_orchestrator else FakeAgent(None) + return {"agent": agent, "client": client} + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_tool_creates_task_and_returns_started(): + """Happy path: context has agent+orchestrator+client → task is created, 'started' returned.""" + from gently.app.tools.temperature_protocol_tools import run_temp_change_burst_protocol_tool + + context = _make_context(with_client=True, with_orchestrator=True) + created_tasks = [] + + def fake_create_task(coro, **kwargs): + # Cancel the coroutine immediately so there's no dangling task + coro.close() + mock_task = MagicMock() + created_tasks.append(mock_task) + return mock_task + + with patch("asyncio.create_task", side_effect=fake_create_task): + result = await run_temp_change_burst_protocol_tool( + embryo_id="emb1", + target_setpoint_c=25.0, + frames=30, + bursts_before=1, + bursts_after=1, + context=context, + ) + + assert len(created_tasks) == 1, "Expected exactly one asyncio task to be created" + assert "started" in result.lower(), f"Expected 'started' in result, got: {result!r}" + assert "emb1" in result, f"Expected embryo_id in result, got: {result!r}" + assert "25.0" in result or "25" in result, f"Expected setpoint in result, got: {result!r}" + + +@pytest.mark.asyncio +async def test_tool_no_client_returns_error_no_task(): + """No client in context → returns error string, no asyncio task created.""" + from gently.app.tools.temperature_protocol_tools import run_temp_change_burst_protocol_tool + + context = _make_context(with_client=False, with_orchestrator=True) + created_tasks = [] + + def fake_create_task(coro, **kwargs): + coro.close() + mock_task = MagicMock() + created_tasks.append(mock_task) + return mock_task + + with patch("asyncio.create_task", side_effect=fake_create_task): + result = await run_temp_change_burst_protocol_tool( + embryo_id="emb1", + target_setpoint_c=25.0, + context=context, + ) + + assert len(created_tasks) == 0, "No task should be created when client is absent" + assert "error" in result.lower() or "not connected" in result.lower(), ( + f"Expected error message, got: {result!r}" + ) + + +@pytest.mark.asyncio +async def test_tool_no_orchestrator_returns_error_no_task(): + """No timelapse orchestrator → returns error string, no asyncio task created.""" + from gently.app.tools.temperature_protocol_tools import run_temp_change_burst_protocol_tool + + context = _make_context(with_client=True, with_orchestrator=False) + created_tasks = [] + + def fake_create_task(coro, **kwargs): + coro.close() + mock_task = MagicMock() + created_tasks.append(mock_task) + return mock_task + + with patch("asyncio.create_task", side_effect=fake_create_task): + result = await run_temp_change_burst_protocol_tool( + embryo_id="emb1", + target_setpoint_c=25.0, + context=context, + ) + + assert len(created_tasks) == 0, "No task should be created when orchestrator is absent" + assert "error" in result.lower() or "not initialized" in result.lower(), ( + f"Expected error message, got: {result!r}" + ) + + +@pytest.mark.asyncio +async def test_tool_no_agent_context_returns_error(): + """No agent in context → returns error immediately.""" + from gently.app.tools.temperature_protocol_tools import run_temp_change_burst_protocol_tool + + context = {"client": FakeClient()} # no "agent" key + created_tasks = [] + + def fake_create_task(coro, **kwargs): + coro.close() + mock_task = MagicMock() + created_tasks.append(mock_task) + return mock_task + + with patch("asyncio.create_task", side_effect=fake_create_task): + result = await run_temp_change_burst_protocol_tool( + embryo_id="emb1", + target_setpoint_c=25.0, + context=context, + ) + + assert len(created_tasks) == 0 + assert "error" in result.lower(), f"Expected error message, got: {result!r}" + + +def test_tool_is_registered(): + """The tool should be discoverable in the global tool registry after package import.""" + import gently.app.tools # noqa: F401 — triggers registration + from gently.harness.tools.registry import get_tool_registry + + registry = get_tool_registry() + tool_names = [t.name for t in registry.list_all()] + assert "run_temp_change_burst_protocol" in tool_names, ( + f"Tool not found in registry. Registered tools: {tool_names}" + ) + + +@pytest.mark.asyncio +async def test_tool_refuses_during_active_timelapse(): + """If orchestrator._status == RUNNING, refuse without creating a task.""" + from gently.app.orchestration.timelapse_models import TimelapseStatus + from gently.app.tools.temperature_protocol_tools import run_temp_change_burst_protocol_tool + + context = _make_context(with_client=True, with_orchestrator=True) + # Simulate an active timelapse + context["agent"].timelapse_orchestrator._status = TimelapseStatus.RUNNING + + created_tasks = [] + + def fake_create_task(coro, **kwargs): + coro.close() + mock_task = MagicMock() + created_tasks.append(mock_task) + return mock_task + + with patch("asyncio.create_task", side_effect=fake_create_task): + result = await run_temp_change_burst_protocol_tool( + embryo_id="emb1", + target_setpoint_c=25.0, + context=context, + ) + + assert len(created_tasks) == 0, "No task should be created when timelapse is running" + assert "refusing" in result.lower(), f"Expected refusal message, got: {result!r}" + assert "timelapse" in result.lower(), ( + f"Expected 'timelapse' in refusal message, got: {result!r}" + ) diff --git a/tests/test_temperature_event.py b/tests/test_temperature_event.py new file mode 100644 index 00000000..80e6601f --- /dev/null +++ b/tests/test_temperature_event.py @@ -0,0 +1,21 @@ +""" +Test suite for TEMPERATURE_UPDATE event type +""" + +from gently.core.event_bus import EventBus, EventType + + +def test_temperature_update_event_exists(): + """Verify TEMPERATURE_UPDATE enum member exists with correct name""" + assert hasattr(EventType, "TEMPERATURE_UPDATE") + event_type = EventType.TEMPERATURE_UPDATE + assert event_type.name == "TEMPERATURE_UPDATE" + + +def test_temperature_update_publishes_to_subscriber(): + """Verify publishing TEMPERATURE_UPDATE reaches subscribers""" + bus = EventBus() + seen = [] + bus.subscribe(EventType.TEMPERATURE_UPDATE, lambda e: seen.append(e.data)) + bus.publish(event_type=EventType.TEMPERATURE_UPDATE, data={"x": 1}, source="t") + assert seen == [{"x": 1}] diff --git a/tests/test_temperature_route.py b/tests/test_temperature_route.py new file mode 100644 index 00000000..5e71a9ea --- /dev/null +++ b/tests/test_temperature_route.py @@ -0,0 +1,64 @@ +from pathlib import Path +from unittest.mock import MagicMock + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from gently.ui.web.routes.temperature import create_router + + +def _server(samples, sessions=(("sess-1", True),)): + store = MagicMock() + store.list_sessions.return_value = [{"session_id": sid} for sid, _ in sessions] + store._session_dir.side_effect = lambda sid: ( + Path("/x") if any(sid == s for s, _ in sessions) else None + ) + store.read_temperature_log.return_value = samples + srv = MagicMock() + srv.gently_store = store + return srv, store + + +def _client(server): + app = FastAPI() + app.include_router(create_router(server)) + return TestClient(app) + + +def test_history_returns_samples(): + srv, store = _server( + [ + { + "t": "2026-06-27T10:00:00+00:00", + "water_c": 28.0, + "setpoint_c": 32.0, + "state": "heating", + } + ] + ) + r = _client(srv).get("/api/temperature/sess-1/history") + assert r.status_code == 200 + body = r.json() + assert body["session_id"] == "sess-1" + assert body["samples"][0]["water_c"] == 28.0 + + +def test_history_passes_since_through(): + srv, store = _server([]) + _client(srv).get("/api/temperature/sess-1/history?since=2026-06-27T10:00:01+00:00") + store.read_temperature_log.assert_called_with("sess-1", since="2026-06-27T10:00:01+00:00") + + +def test_history_current_resolves_newest(): + srv, store = _server([], sessions=(("newest", True),)) + r = _client(srv).get("/api/temperature/current/history") + assert r.status_code == 200 + assert r.json()["session_id"] == "newest" + + +def test_history_unknown_session_404(): + srv, store = _server([], sessions=(("sess-1", True),)) + # _session_dir returns None for unknown -> 404 + store._session_dir.side_effect = lambda sid: None + r = _client(srv).get("/api/temperature/ghost/history") + assert r.status_code == 404 diff --git a/tests/test_temperature_sampler.py b/tests/test_temperature_sampler.py new file mode 100644 index 00000000..8a221aef --- /dev/null +++ b/tests/test_temperature_sampler.py @@ -0,0 +1,118 @@ +"""Tests for TemperatureSampler service. + +Adaptation note: create_session() requires session_id as its first positional +argument (confirmed from file_store.py:328). The brief's +`file_store.create_session(name="s")` is adapted to +`file_store.create_session(str(uuid.uuid4()), name="s")` to match the real API. +""" + +import uuid + +from gently.app.temperature_sampler import TemperatureSampler, temperature_stamp +from gently.core.event_bus import EventBus, EventType + + +class FakeScope: + def __init__(self, resp): + self.resp = resp + self.calls = 0 + + async def get_temperature(self): + self.calls += 1 + if isinstance(self.resp, Exception): + raise self.resp + return self.resp + + +def _capture(bus): + seen = [] + bus.subscribe(EventType.TEMPERATURE_UPDATE, lambda e: seen.append(e.data)) + return seen + + +async def test_tick_appends_emits_and_sets_latest(file_store): + sid = file_store.create_session(str(uuid.uuid4()), name="s") + scope = FakeScope( + {"success": True, "temperature_c": 28.4, "setpoint_c": 32.0, "state": "heating"} + ) + bus = EventBus() + seen = _capture(bus) + s = TemperatureSampler(scope, file_store, lambda: sid) + await s._tick(bus) + rows = file_store.read_temperature_log(sid) + assert len(rows) == 1 and rows[0]["water_c"] == 28.4 + assert s.latest["water_c"] == 28.4 + assert seen and seen[0]["sample"]["water_c"] == 28.4 and seen[0]["session_id"] == sid + + +async def test_tick_no_active_session_is_noop(file_store): + scope = FakeScope({"success": True, "temperature_c": 1.0, "setpoint_c": 2.0, "state": "x"}) + bus = EventBus() + seen = _capture(bus) + s = TemperatureSampler(scope, file_store, lambda: None) + await s._tick(bus) + assert scope.calls == 0 and s.latest is None and seen == [] + + +async def test_tick_poll_failure_is_a_gap_not_a_crash(file_store): + sid = file_store.create_session(str(uuid.uuid4()), name="s") + scope = FakeScope(RuntimeError("device down")) + bus = EventBus() + s = TemperatureSampler(scope, file_store, lambda: sid) + # _tick propagates the poll exception; _run's except-Exception swallows it. + # Assert the gap (no rows). + try: + await s._tick(bus) + except RuntimeError: + pass # _tick may raise; the loop in _run catches it + assert file_store.read_temperature_log(sid) == [] + + +def test_temperature_stamp_shapes(): + assert temperature_stamp(None) is None + assert temperature_stamp( + {"t": "2026-06-27T10:00:00+00:00", "water_c": 28.4, "setpoint_c": 32.0, "state": "heating"} + ) == { + "water_c": 28.4, + "setpoint_c": 32.0, + "state": "heating", + "sampled_at": "2026-06-27T10:00:00+00:00", + } + + +async def test_stale_latest_cleared_when_no_active_session(file_store): + """After a successful tick, a subsequent tick with no active session resets latest to None.""" + sid = file_store.create_session(str(uuid.uuid4()), name="s") + scope = FakeScope( + {"success": True, "temperature_c": 28.4, "setpoint_c": 32.0, "state": "heating"} + ) + bus = EventBus() + + # Successful first tick. + s = TemperatureSampler(scope, file_store, lambda: sid) + await s._tick(bus) + assert s.latest is not None and s.latest["water_c"] == 28.4 + + # Second tick with no active session — latest must be cleared. + s._session_id_getter = lambda: None + await s._tick(bus) + assert s.latest is None + + +async def test_stale_latest_cleared_on_poll_failure(file_store): + """After a successful tick, a failing poll resets latest to None.""" + sid = file_store.create_session(str(uuid.uuid4()), name="s") + bus = EventBus() + + # Successful first tick. + good_scope = FakeScope( + {"success": True, "temperature_c": 28.4, "setpoint_c": 32.0, "state": "heating"} + ) + s = TemperatureSampler(good_scope, file_store, lambda: sid) + await s._tick(bus) + assert s.latest is not None + + # Swap to a scope that returns falsy (empty dict) — simulates device dropout. + s._microscope = FakeScope({}) + await s._tick(bus) + assert s.latest is None diff --git a/tests/test_temperature_sampler_wiring.py b/tests/test_temperature_sampler_wiring.py new file mode 100644 index 00000000..3eec8148 --- /dev/null +++ b/tests/test_temperature_sampler_wiring.py @@ -0,0 +1,25 @@ +"""Wiring guard: asserts that agent.py contains the TemperatureSampler lifecycle hooks. + +This is an intentionally lightweight source-text guard — a full agent boot is a +heavy integration concern verified end-to-end in a later task. DeviceStateMonitor's +own wiring is likewise not unit-tested at this level. +""" + + +def test_agent_initializes_temperature_sampler_attribute(): + """agent.py must declare the attribute *and* construct the sampler. + + Uses find_spec to locate the source file without executing the module + (agent.py has heavy runtime deps like anthropic that aren't present in the + test environment). + """ + import importlib.util + from pathlib import Path + + spec = importlib.util.find_spec("gently.app.agent") + assert spec is not None and spec.origin is not None, ( + "Could not locate gently/app/agent.py via importlib" + ) + text = Path(spec.origin).read_text(encoding="utf-8") + assert "temperature_sampler" in text, "agent.py has no 'temperature_sampler' attribute" + assert "TemperatureSampler(" in text, "agent.py never constructs a TemperatureSampler" diff --git a/tests/test_temperature_stamp.py b/tests/test_temperature_stamp.py new file mode 100644 index 00000000..4b88fcdb --- /dev/null +++ b/tests/test_temperature_stamp.py @@ -0,0 +1,118 @@ +"""Tests for Task 6 & Task 7: acquisition temperature stamp. + +Concerns: +1. temperature_stamp(None) returns None (pure helper, no I/O). +2. Volume metadata round-trip: a stamp injected via put_volume(metadata={"temperature": + stamp}) lands correctly in the sidecar YAML and is readable via get_volume_meta. +3. Burst stamp: _persist_burst_to_disk injects temperature into both burst.yaml and + each frame's .meta.yaml when a temperature_provider is supplied. + +Confirmed from file_store.py: +- create_session(session_id, name=None, ...) — session_id is the first positional arg. +- register_embryo(session_id, embryo_id, ...) — embryo_id is the key; returns None. +- put_volume(session_id, embryo_id, timepoint, volume, metadata=None) — nests `metadata` + under the "metadata" key in the sidecar YAML. +- get_volume_meta(session_id, embryo_id, timepoint) — added by Task 6; reads sidecar YAML. + +Note: tifffile is not installed in the test environment; it is mocked so put_volume / +_persist_burst_to_disk write only the YAML files (the contracts under test). +""" + +import sys +import uuid +from datetime import datetime, timezone +from unittest.mock import MagicMock, patch + +import numpy as np +import yaml + +from gently.app.temperature_sampler import temperature_stamp + + +def test_stamp_none_when_no_reading(): + assert temperature_stamp(None) is None + + +def test_volume_metadata_carries_temperature(file_store): + sid = file_store.create_session(str(uuid.uuid4()), name="s") + emb = "embryo_1" + file_store.register_embryo(sid, emb, position_x=0.0, position_y=0.0) + stamp = temperature_stamp( + { + "t": "2026-06-27T10:00:00+00:00", + "water_c": 28.4, + "setpoint_c": 32.0, + "state": "heating", + } + ) + vol = np.zeros((2, 4, 4), dtype="uint16") + + # tifffile is not installed in this environment; mock it so put_volume can + # proceed to writing the sidecar YAML (the part under test). + tifffile_mock = MagicMock() + with patch.dict(sys.modules, {"tifffile": tifffile_mock}): + with patch.object(file_store, "_generate_projection", return_value=None): + file_store.put_volume( + sid, emb, timepoint=0, volume=vol, metadata={"temperature": stamp} + ) + + meta = file_store.get_volume_meta(sid, emb, 0) + assert meta["metadata"]["temperature"]["water_c"] == 28.4 + + +def test_burst_stamp_writes_temperature(file_store): + """_persist_burst_to_disk stamps temperature into burst.yaml and frame .meta.yaml.""" + from gently.app.orchestration.exclusive import _persist_burst_to_disk + + sid = file_store.create_session(str(uuid.uuid4()), name="burst-stamp-test") + embryo_id = "embryo_burst" + file_store.register_embryo(sid, embryo_id, position_x=0.0, position_y=0.0) + + # Minimal orchestrator stand-in: only _store and _session_id are needed. + orch = MagicMock() + orch._store = file_store + orch._session_id = sid + + # Minimal embryo stand-in. + embryo = MagicMock() + embryo.stage_position = {"x": 0.0, "y": 0.0} + embryo.num_slices = 2 + embryo.exposure_ms = 50.0 + + frames_data = [{"volume": np.zeros((2, 4, 4), dtype="uint16"), "acquired_at_epoch": None}] + + tifffile_mock = MagicMock() + with patch.dict(sys.modules, {"tifffile": tifffile_mock}): + burst_dir = _persist_burst_to_disk( + orchestrator=orch, + embryo=embryo, + embryo_id=embryo_id, + request_id="req-burst-001", + mode="1hz", + frames_requested=1, + frames_data=frames_data, + loop_start=datetime(2026, 6, 27, 10, 0, 0, tzinfo=timezone.utc), + duration_s=1.0, + sustained_hz=1.0, + galvo_amplitude=100.0, + galvo_center=0.0, + piezo_amplitude=50.0, + piezo_center=0.0, + laser_power_488_pct=10.0, + temperature_provider=lambda: { + "t": "2026-06-27T10:00:00+00:00", + "water_c": 28.4, + "setpoint_c": 32.0, + "state": "heating", + }, + ) + + assert burst_dir is not None, "_persist_burst_to_disk returned None — check store/session setup" + + # burst.yaml must have a top-level temperature with water_c == 28.4. + manifest = yaml.safe_load((burst_dir / "burst.yaml").read_text()) + assert manifest["temperature"]["water_c"] == 28.4 + + # Frame meta.yaml must have metadata.temperature.water_c == 28.4. + frame_meta = yaml.safe_load((burst_dir / "t0001.meta.yaml").read_text()) + assert frame_meta["metadata"]["temperature"]["water_c"] == 28.4 diff --git a/tests/test_temperature_store.py b/tests/test_temperature_store.py new file mode 100644 index 00000000..dfc36fd9 --- /dev/null +++ b/tests/test_temperature_store.py @@ -0,0 +1,71 @@ +"""Tests for FileStore temperature log methods.""" + + +def _new_session(file_store): + """Create a test session and return its ID.""" + # Use UUID-based session ID for uniqueness + import uuid + + session_id = str(uuid.uuid4()) + return file_store.create_session(session_id, name="temp-test") + + +def test_append_and_read_roundtrip(file_store): + """Test appending and reading temperature samples.""" + sid = _new_session(file_store) + file_store.append_temperature_sample( + sid, + {"t": "2026-06-27T10:00:00+00:00", "water_c": 28.0, "setpoint_c": 32.0, "state": "heating"}, + ) + file_store.append_temperature_sample( + sid, + {"t": "2026-06-27T10:00:01+00:00", "water_c": 28.3, "setpoint_c": 32.0, "state": "heating"}, + ) + rows = file_store.read_temperature_log(sid) + assert [r["water_c"] for r in rows] == [28.0, 28.3] + + +def test_read_since_filters(file_store): + """Test that read_temperature_log filters by since parameter.""" + sid = _new_session(file_store) + for i, t in enumerate( + ["2026-06-27T10:00:00+00:00", "2026-06-27T10:00:01+00:00", "2026-06-27T10:00:02+00:00"] + ): + file_store.append_temperature_sample( + sid, {"t": t, "water_c": 28.0 + i, "setpoint_c": 32.0, "state": "heating"} + ) + rows = file_store.read_temperature_log(sid, since="2026-06-27T10:00:01+00:00") + assert [r["water_c"] for r in rows] == [29.0, 30.0] + + +def test_read_unknown_session_is_empty(file_store): + """Test that reading from a non-existent session returns empty list.""" + assert file_store.read_temperature_log("does-not-exist") == [] + + +def test_truncated_trailing_line_is_skipped(file_store, tmp_path): + """A truncated trailing line (e.g. after a crash mid-append) is skipped gracefully.""" + + sid = _new_session(file_store) + + # Append two valid samples via the normal API. + file_store.append_temperature_sample( + sid, + {"t": "2026-06-27T10:00:01+00:00", "water_c": 28.1, "setpoint_c": 32.0, "state": "heating"}, + ) + file_store.append_temperature_sample( + sid, + {"t": "2026-06-27T10:00:02+00:00", "water_c": 28.2, "setpoint_c": 32.0, "state": "heating"}, + ) + + # Append a raw truncated line directly to the JSONL file. + # Locate the temperature.jsonl via the store's internal path. + sd = file_store._session_dir(sid) + log_path = sd / "temperature.jsonl" + with open(log_path, "a", encoding="utf-8") as f: + f.write('{"t": "2026-06-27T10:00:03+00:00", "water_c":\n') + + # read_temperature_log must return only the two valid rows without raising. + rows = file_store.read_temperature_log(sid) + assert len(rows) == 2 + assert [r["water_c"] for r in rows] == [28.1, 28.2] diff --git a/tests/test_timelapse_start_route.py b/tests/test_timelapse_start_route.py new file mode 100644 index 00000000..7ef66140 --- /dev/null +++ b/tests/test_timelapse_start_route.py @@ -0,0 +1,282 @@ +"""Tests for B2 Task 3 — POST /api/devices/timelapse/start (manual timelapse proxy). + +Verifies: + - POST with valid params calls orchestrator.start with the right args → 200 + - monitoring_mode is forwarded to orchestrator.enable_monitoring_mode + - interval_seconds <= 0 → 400 + - num_slices < 1 → 400 + - missing interval_seconds uses default 120.0 (no error) + - orchestrator not initialised → 503 + - require_control gate (403 without override) + +Orchestrator access path: + server.agent_bridge.agent.timelapse_orchestrator + (mirroring the `require_timelapse_orchestrator(agent)` helper in harness/tools/helpers.py) +""" + +from unittest.mock import AsyncMock, MagicMock + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +import gently.ui.web.auth as auth +from gently.ui.web.routes.data import create_router + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _app(orchestrator=None): + """Build a TestClient wired with the data routes. + + The mock server exposes `agent_bridge.agent.timelapse_orchestrator`. + `require_control` is overridden so route logic is isolated from auth. + """ + server = MagicMock() + server.agent_bridge.agent.timelapse_orchestrator = orchestrator + # Satisfy other routes that call _resolve_client() or store + server.agent_bridge.agent.client = MagicMock() + server.agent_bridge.agent.lightsheet_monitor = None + app = FastAPI() + app.include_router(create_router(server)) + app.dependency_overrides[auth.require_control] = lambda: True + return TestClient(app) + + +def _make_orchestrator(start_return="Timelapse started."): + """Return a mock orchestrator with an async start and sync enable_monitoring_mode.""" + orch = MagicMock() + orch.start = AsyncMock(return_value=start_return) + orch.enable_monitoring_mode = MagicMock(return_value="Monitoring mode enabled.") + return orch + + +# --------------------------------------------------------------------------- +# Happy path — minimal valid payload +# --------------------------------------------------------------------------- + + +def test_timelapse_start_minimal(): + """POST with just interval_seconds calls orchestrator.start; returns 200 with started=True.""" + orch = _make_orchestrator() + r = _app(orch).post( + "/api/devices/timelapse/start", + json={"interval_seconds": 120}, + ) + assert r.status_code == 200 + body = r.json() + assert body["started"] is True + orch.start.assert_awaited_once() + + +def test_timelapse_start_calls_start_with_correct_args(): + """orchestrator.start receives the right interval, stop_condition, embryo_ids, + condition_value.""" + orch = _make_orchestrator() + _app(orch).post( + "/api/devices/timelapse/start", + json={ + "interval_seconds": 60, + "stop_condition": "timepoints", + "embryo_ids": ["e1", "e2"], + "condition_value": 10, + }, + ) + orch.start.assert_awaited_once_with( + embryo_ids=["e1", "e2"], + stop_condition="timepoints", + base_interval_seconds=60.0, + condition_value=10, + ) + + +def test_timelapse_start_uses_default_interval(): + """Omitting interval_seconds uses the default (120.0) without error.""" + orch = _make_orchestrator() + r = _app(orch).post( + "/api/devices/timelapse/start", + json={}, + ) + assert r.status_code == 200 + _, kwargs = orch.start.call_args + assert kwargs["base_interval_seconds"] == 120.0 + + +def test_timelapse_start_result_in_response(): + """The orchestrator.start return value appears in the response.""" + orch = _make_orchestrator(start_return="Timelapse running — 3 embryos.") + r = _app(orch).post( + "/api/devices/timelapse/start", + json={"interval_seconds": 90}, + ) + assert r.json()["result"] == "Timelapse running — 3 embryos." + + +# --------------------------------------------------------------------------- +# Monitoring mode +# --------------------------------------------------------------------------- + + +def test_timelapse_start_enables_monitoring_mode(): + """monitoring_mode != 'idle' triggers enable_monitoring_mode on the orchestrator.""" + orch = _make_orchestrator() + r = _app(orch).post( + "/api/devices/timelapse/start", + json={"interval_seconds": 120, "monitoring_mode": "expression_monitoring"}, + ) + assert r.status_code == 200 + orch.enable_monitoring_mode.assert_called_once_with("expression_monitoring") + assert r.json()["monitoring_mode_result"] == "Monitoring mode enabled." + + +def test_timelapse_start_idle_mode_skips_enable(): + """monitoring_mode='idle' does NOT call enable_monitoring_mode.""" + orch = _make_orchestrator() + r = _app(orch).post( + "/api/devices/timelapse/start", + json={"interval_seconds": 120, "monitoring_mode": "idle"}, + ) + assert r.status_code == 200 + orch.enable_monitoring_mode.assert_not_called() + + +def test_timelapse_start_no_mode_skips_enable(): + """Omitting monitoring_mode does NOT call enable_monitoring_mode.""" + orch = _make_orchestrator() + _app(orch).post("/api/devices/timelapse/start", json={"interval_seconds": 120}) + orch.enable_monitoring_mode.assert_not_called() + + +# --------------------------------------------------------------------------- +# Volume geometry passed through in response config +# --------------------------------------------------------------------------- + + +def test_timelapse_start_volume_geometry_in_config(): + """Volume geometry fields appear in response['config']['volume_geometry'].""" + orch = _make_orchestrator() + r = _app(orch).post( + "/api/devices/timelapse/start", + json={ + "interval_seconds": 120, + "num_slices": 80, + "exposure_ms": 15.0, + "galvo_amplitude": 0.7, + "galvo_center": 0.1, + "piezo_amplitude": 30.0, + "piezo_center": 55.0, + "laser_config": "488 only", + }, + ) + assert r.status_code == 200 + vg = r.json()["config"]["volume_geometry"] + assert vg["num_slices"] == 80 + assert vg["exposure_ms"] == 15.0 + assert vg["laser_config"] == "488 only" + + +# --------------------------------------------------------------------------- +# 400 — validation failures +# --------------------------------------------------------------------------- + + +def test_timelapse_start_interval_zero_returns_400(): + """interval_seconds = 0 → 400.""" + orch = _make_orchestrator() + r = _app(orch).post( + "/api/devices/timelapse/start", + json={"interval_seconds": 0}, + ) + assert r.status_code == 400 + assert "interval_seconds" in r.json()["detail"] + + +def test_timelapse_start_negative_interval_returns_400(): + """interval_seconds < 0 → 400.""" + orch = _make_orchestrator() + r = _app(orch).post( + "/api/devices/timelapse/start", + json={"interval_seconds": -10}, + ) + assert r.status_code == 400 + + +def test_timelapse_start_num_slices_zero_returns_400(): + """num_slices = 0 → 400.""" + orch = _make_orchestrator() + r = _app(orch).post( + "/api/devices/timelapse/start", + json={"interval_seconds": 120, "num_slices": 0}, + ) + assert r.status_code == 400 + assert "num_slices" in r.json()["detail"] + + +def test_timelapse_start_num_slices_negative_returns_400(): + """num_slices < 0 → 400.""" + orch = _make_orchestrator() + r = _app(orch).post( + "/api/devices/timelapse/start", + json={"interval_seconds": 120, "num_slices": -5}, + ) + assert r.status_code == 400 + + +def test_timelapse_start_non_numeric_interval_returns_400(): + """Non-numeric interval_seconds → 400.""" + orch = _make_orchestrator() + r = _app(orch).post( + "/api/devices/timelapse/start", + json={"interval_seconds": "fast"}, + ) + assert r.status_code == 400 + + +# --------------------------------------------------------------------------- +# 503 — orchestrator not reachable +# --------------------------------------------------------------------------- + + +def test_timelapse_start_no_orchestrator_returns_503(): + """orchestrator is None (agent not running / no session) → 503.""" + r = _app(orchestrator=None).post( + "/api/devices/timelapse/start", + json={"interval_seconds": 120}, + ) + assert r.status_code == 503 + + +def test_timelapse_start_no_agent_bridge_returns_503(): + """agent_bridge missing entirely → 503.""" + server = MagicMock(spec=[]) # no agent_bridge attribute + app = FastAPI() + app.include_router(create_router(server)) + app.dependency_overrides[auth.require_control] = lambda: True + r = TestClient(app).post( + "/api/devices/timelapse/start", + json={"interval_seconds": 120}, + ) + assert r.status_code == 503 + + +# --------------------------------------------------------------------------- +# require_control gate +# --------------------------------------------------------------------------- + + +def test_timelapse_start_requires_control(): + """POST /api/devices/timelapse/start is gated by require_control (403 without override).""" + orch = _make_orchestrator() + server = MagicMock() + server.agent_bridge.agent.timelapse_orchestrator = orch + server.agent_bridge.agent.client = MagicMock() + server.agent_bridge.agent.lightsheet_monitor = None + app = FastAPI() + app.include_router(create_router(server)) + # No dependency override — require_control will reject non-loopback hosts + r = TestClient(app, raise_server_exceptions=False).post( + "/api/devices/timelapse/start", + json={"interval_seconds": 120}, + ) + assert r.status_code == 403 diff --git a/tests/test_tool_tactic_linkage.py b/tests/test_tool_tactic_linkage.py new file mode 100644 index 00000000..7b260bdb --- /dev/null +++ b/tests/test_tool_tactic_linkage.py @@ -0,0 +1,619 @@ +""" +Task 7: tactic_id threading + start-edge marking in execution tools. + +Verifies: + 1. enable_monitoring_mode with tactic_id → transition_tactic("active") called. + 2. enable_monitoring_mode without tactic_id → no transition call. + 3. queue_burst (tool) with tactic_id → transition_tactic("active") called. + 4. queue_burst (tool) without tactic_id → no transition call. + 5. stop_timelapse with tactic_id → transition_tactic("done") called. + 6. pause_timelapse with tactic_id → transition_tactic("paused") called. + 7. BurstAcquisition(tactic_id=...) stores _tactic_id and includes it in BURST_START data. + 8. BurstAcquisition(tactic_id=...) includes tactic_id in BURST_COMPLETE data. + 9. run_temp_change_burst_protocol_tool with tactic_id → transition_tactic("active") called. + 10. run_temp_change_burst_protocol_tool without tactic_id → no transition call. + 11. TEMP_PROTOCOL_STARTED and TEMP_PROTOCOL_COMPLETED carry tactic_id. +""" + +import asyncio + +import pytest + +# --------------------------------------------------------------------------- +# Fakes +# --------------------------------------------------------------------------- + + +class FakeContextStore: + """Capture calls to transition_tactic.""" + + def __init__(self): + self.transitions: list[tuple] = [] + + def transition_tactic(self, session_id: str, tactic_id: str, state: str | None = None, **bind): + self.transitions.append((session_id, tactic_id, state)) + return True + + +class FakeOrchestrator: + """Minimal orchestrator for tool-layer tests.""" + + def __init__(self): + self.monitoring_modes_enabled: list[str] = [] + self.bursts_queued: list[dict] = [] + self.stopped = False + self.paused = False + + def enable_monitoring_mode(self, name: str, **kwargs) -> str: + self.monitoring_modes_enabled.append(name) + return f"Activated monitoring mode '{name}'" + + def queue_burst( + self, + embryo_id: str, + *, + frames: int = 60, + mode: str = "1hz", + num_slices: int = 1, + force: bool = False, + laser_config=None, + tactic_id=None, + ) -> str: + self.bursts_queued.append({"embryo_id": embryo_id, "tactic_id": tactic_id}) + return f"Burst queued for {embryo_id}" + + async def stop(self, reason: str = "user_request") -> str: + self.stopped = True + return "Timelapse stopped." + + async def pause(self) -> str: + self.paused = True + return "Timelapse paused." + + +class FakeAgent: + """Carries context_store, session_id, and timelapse_orchestrator.""" + + def __init__(self, *, cs=None, session_id="sess_t7", orchestrator=None): + self.context_store = cs + self.session_id = session_id + self.timelapse_orchestrator = orchestrator + + +class FakeMicroscope: + """Satisfies the requires_microscope client check in the tool registry.""" + + pass + + +def _make_agent(with_cs=True, with_orchestrator=True): + cs = FakeContextStore() if with_cs else None + orch = FakeOrchestrator() if with_orchestrator else None + agent = FakeAgent(cs=cs, orchestrator=orch) + return agent, cs, orch + + +def _ctx(agent, with_client=False): + ctx = {"agent": agent} + if with_client: + ctx["client"] = FakeMicroscope() + return ctx + + +# --------------------------------------------------------------------------- +# enable_monitoring_mode — tool-layer tactic transitions +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_enable_monitoring_mode_with_tactic_id_calls_transition(): + """enable_monitoring_mode with tactic_id flips the tactic to active.""" + from gently.app.tools.timelapse_tools import enable_monitoring_mode + + agent, cs, _orch = _make_agent() + await enable_monitoring_mode( + mode_name="expression_monitoring", tactic_id="t1", context=_ctx(agent) + ) + + assert cs is not None + assert any(t == ("sess_t7", "t1", "active") for t in cs.transitions), ( + f"Expected transition ('sess_t7', 't1', 'active'), got {cs.transitions}" + ) + + +@pytest.mark.asyncio +async def test_enable_monitoring_mode_without_tactic_id_no_transition(): + """enable_monitoring_mode without tactic_id must not call transition_tactic.""" + from gently.app.tools.timelapse_tools import enable_monitoring_mode + + agent, cs, _orch = _make_agent() + await enable_monitoring_mode(mode_name="expression_monitoring", context=_ctx(agent)) + + assert cs.transitions == [], f"Expected no transitions, got {cs.transitions}" + + +@pytest.mark.asyncio +async def test_enable_monitoring_mode_no_cs_no_crash(): + """Missing context store must not crash — the transition is a guarded no-op.""" + from gently.app.tools.timelapse_tools import enable_monitoring_mode + + agent, _cs, _orch = _make_agent(with_cs=False) + result = await enable_monitoring_mode( + mode_name="expression_monitoring", tactic_id="t1", context=_ctx(agent) + ) + # No exception; result is the mode activation string + assert "expression_monitoring" in result + + +# --------------------------------------------------------------------------- +# queue_burst — tool-layer tactic transitions +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_queue_burst_with_tactic_id_calls_transition(): + """queue_burst (tool) with tactic_id flips the tactic to active.""" + from gently.app.tools.timelapse_tools import queue_burst + + agent, cs, _orch = _make_agent() + await queue_burst(embryo_id="emb1", tactic_id="t2", context=_ctx(agent, with_client=True)) + + assert any(t == ("sess_t7", "t2", "active") for t in cs.transitions), ( + f"Expected transition ('sess_t7', 't2', 'active'), got {cs.transitions}" + ) + + +@pytest.mark.asyncio +async def test_queue_burst_without_tactic_id_no_transition(): + """queue_burst (tool) without tactic_id must not call transition_tactic.""" + from gently.app.tools.timelapse_tools import queue_burst + + agent, cs, _orch = _make_agent() + await queue_burst(embryo_id="emb1", context=_ctx(agent, with_client=True)) + + assert cs.transitions == [], f"Expected no transitions, got {cs.transitions}" + + +@pytest.mark.asyncio +async def test_queue_burst_passes_tactic_id_to_orchestrator(): + """queue_burst (tool) passes tactic_id into orchestrator.queue_burst.""" + from gently.app.tools.timelapse_tools import queue_burst + + agent, _cs, orch = _make_agent() + await queue_burst(embryo_id="emb1", tactic_id="t2", context=_ctx(agent, with_client=True)) + + assert orch.bursts_queued, "orchestrator.queue_burst must have been called" + assert orch.bursts_queued[0]["tactic_id"] == "t2" + + +@pytest.mark.asyncio +async def test_queue_burst_soft_reject_does_not_transition(): + """queue_burst (tool) must NOT flip the tactic to active on a soft-reject. + + orchestrator.queue_burst returns a rejection sentence (not starting with + "Burst queued for") when the embryo already has a queued burst, already + had a burst this session, or is not in the active timelapse. The tool + must treat those as non-events and leave transition_tactic uncalled. + """ + from gently.app.tools.timelapse_tools import queue_burst + + class RejectingOrchestrator(FakeOrchestrator): + def queue_burst( + self, + embryo_id, + *, + frames=60, + mode="1hz", + num_slices=1, + force=False, + laser_config=None, + tactic_id=None, + ) -> str: + # Simulates "already has a queued burst" soft-reject + return f"Embryo '{embryo_id}' already has a queued burst." + + cs = FakeContextStore() + orch = RejectingOrchestrator() + agent = FakeAgent(cs=cs, orchestrator=orch) + + await queue_burst(embryo_id="emb1", tactic_id="t2", context=_ctx(agent, with_client=True)) + + assert cs.transitions == [], ( + f"transition_tactic must not be called on soft-reject; got {cs.transitions}" + ) + + +# --------------------------------------------------------------------------- +# stop_timelapse — mark done +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_stop_timelapse_with_tactic_id_calls_done(): + """stop_timelapse with tactic_id flips the tactic to done.""" + from gently.app.tools.timelapse_tools import stop_timelapse + + agent, cs, _orch = _make_agent() + await stop_timelapse(tactic_id="t1", context=_ctx(agent, with_client=True)) + + assert any(t == ("sess_t7", "t1", "done") for t in cs.transitions), ( + f"Expected transition to done, got {cs.transitions}" + ) + + +@pytest.mark.asyncio +async def test_stop_timelapse_without_tactic_id_no_transition(): + """stop_timelapse without tactic_id must not call transition_tactic.""" + from gently.app.tools.timelapse_tools import stop_timelapse + + agent, cs, _orch = _make_agent() + await stop_timelapse(context=_ctx(agent, with_client=True)) + + assert cs.transitions == [] + + +# --------------------------------------------------------------------------- +# pause_timelapse — mark paused +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_pause_timelapse_with_tactic_id_calls_paused(): + """pause_timelapse with tactic_id flips the tactic to paused.""" + from gently.app.tools.timelapse_tools import pause_timelapse + + agent, cs, _orch = _make_agent() + await pause_timelapse(tactic_id="t1", context=_ctx(agent, with_client=True)) + + assert any(t == ("sess_t7", "t1", "paused") for t in cs.transitions), ( + f"Expected transition to paused, got {cs.transitions}" + ) + + +@pytest.mark.asyncio +async def test_pause_timelapse_without_tactic_id_no_transition(): + """pause_timelapse without tactic_id must not call transition_tactic.""" + from gently.app.tools.timelapse_tools import pause_timelapse + + agent, cs, _orch = _make_agent() + await pause_timelapse(context=_ctx(agent, with_client=True)) + + assert cs.transitions == [] + + +# --------------------------------------------------------------------------- +# BurstAcquisition — tactic_id stored and appears in event data (unit-level) +# --------------------------------------------------------------------------- + + +def test_burst_acquisition_stores_tactic_id(): + """BurstAcquisition stores tactic_id as _tactic_id.""" + from gently.app.orchestration.exclusive import BurstAcquisition + + b = BurstAcquisition("emb1", frames=10, tactic_id="t3") + assert b._tactic_id == "t3" + + +def test_burst_acquisition_default_tactic_id_none(): + """BurstAcquisition._tactic_id defaults to None (backward compat).""" + from gently.app.orchestration.exclusive import BurstAcquisition + + b = BurstAcquisition("emb1", frames=10) + assert b._tactic_id is None + + +def test_burst_start_event_data_contains_tactic_id(): + """The BURST_START event data dict includes tactic_id.""" + from gently.app.orchestration.exclusive import BurstAcquisition + + emitted: list[dict] = [] + + class FakeOrc: + _embryo_states = {} + + def _emit_event(self, event_type, data): + emitted.append((event_type, data)) + + b = BurstAcquisition("emb1", frames=5, tactic_id="t3") + orch = FakeOrc() + # Trigger just the BURST_START emission by running the coroutine start; since the + # embryo is absent, run() returns an error result after emitting nothing — + # that's the correct path. Instead, directly test what run() would emit. + # We replicate the exact emit call from BurstAcquisition.run to verify the data key. + # Safer: run the coroutine to the first yield so BURST_START fires. + loop = asyncio.new_event_loop() + try: + loop.run_until_complete(b.run(orch)) + finally: + loop.close() + + # When embryo is absent run() returns early (no BURST_START). Confirm _tactic_id + # is wired up and would appear in the event — inspect the attribute directly. + assert b._tactic_id == "t3" + + +def test_burst_start_event_data_dict_includes_tactic_id_key(): + """The BURST_START event dict produced by BurstAcquisition.run includes 'tactic_id'.""" + from gently.app.orchestration.exclusive import BurstAcquisition + from gently.core import EventType + + emitted: list[tuple] = [] + + class FakeClient: + async def move_to_position(self, x, y): ... + async def acquire_burst(self, **kw): + return {"success": True, "frames": []} + + class FakeEmbryo: + calibration = {} + stage_position = {} + exposure_ms = 10.0 + laser_power_488_pct = None + + class FakeOrc: + client = FakeClient() + _embryo_states = {"emb1": FakeEmbryo()} + + def _emit_event(self, event_type, data): + emitted.append((event_type, data)) + + b = BurstAcquisition("emb1", frames=2, tactic_id="t3") + loop = asyncio.new_event_loop() + try: + loop.run_until_complete(b.run(FakeOrc())) + finally: + loop.close() + + burst_starts = [(et, d) for et, d in emitted if et == EventType.BURST_START] + assert burst_starts, f"BURST_START not emitted; all events: {[et for et, _ in emitted]}" + _et, data = burst_starts[0] + assert "tactic_id" in data, f"tactic_id not in BURST_START data: {data}" + assert data["tactic_id"] == "t3" + + +def test_burst_complete_event_data_includes_tactic_id(): + """The BURST_COMPLETE event dict produced by BurstAcquisition.run includes 'tactic_id'.""" + from gently.app.orchestration.exclusive import BurstAcquisition + from gently.core import EventType + + emitted: list[tuple] = [] + + class FakeClient: + async def move_to_position(self, x, y): ... + async def acquire_burst(self, **kw): + return {"success": True, "frames": []} + + class FakeEmbryo: + calibration = {} + stage_position = {} + exposure_ms = 10.0 + laser_power_488_pct = None + + class FakeOrc: + client = FakeClient() + _embryo_states = {"emb1": FakeEmbryo()} + + def _emit_event(self, event_type, data): + emitted.append((event_type, data)) + + b = BurstAcquisition("emb1", frames=2, tactic_id="t3") + loop = asyncio.new_event_loop() + try: + loop.run_until_complete(b.run(FakeOrc())) + finally: + loop.close() + + burst_completes = [(et, d) for et, d in emitted if et == EventType.BURST_COMPLETE] + assert burst_completes, f"BURST_COMPLETE not emitted; all events: {[et for et, _ in emitted]}" + _et, data = burst_completes[0] + assert "tactic_id" in data, f"tactic_id not in BURST_COMPLETE data: {data}" + assert data["tactic_id"] == "t3" + + +# --------------------------------------------------------------------------- +# temperature_protocol_tools — tactic transitions +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_temp_protocol_tool_with_tactic_id_calls_transition(monkeypatch): + """run_temp_change_burst_protocol_tool with tactic_id marks tactic active.""" + from gently.app.tools import temperature_protocol_tools as tpt + + # Patch asyncio.create_task to a no-op + monkeypatch.setattr(asyncio, "create_task", lambda coro: coro.close() or None) + + class FakeOrch: + _status = None + + class FakeClient: + pass + + cs = FakeContextStore() + agent = FakeAgent(cs=cs, orchestrator=FakeOrch()) + agent.timelapse_orchestrator = FakeOrch() + + ctx = { + "agent": agent, + "client": FakeClient(), + } + + await tpt.run_temp_change_burst_protocol_tool( + embryo_id="emb1", + target_setpoint_c=25.0, + tactic_id="t4", + context=ctx, + ) + + assert any(t == ("sess_t7", "t4", "active") for t in cs.transitions), ( + f"Expected active transition, got {cs.transitions}" + ) + + +@pytest.mark.asyncio +async def test_temp_protocol_tool_without_tactic_id_no_transition(monkeypatch): + """run_temp_change_burst_protocol_tool without tactic_id must not call transition.""" + from gently.app.tools import temperature_protocol_tools as tpt + + monkeypatch.setattr(asyncio, "create_task", lambda coro: coro.close() or None) + + class FakeOrch: + _status = None + + class FakeClient: + pass + + cs = FakeContextStore() + agent = FakeAgent(cs=cs, orchestrator=FakeOrch()) + agent.timelapse_orchestrator = FakeOrch() + + ctx = { + "agent": agent, + "client": FakeClient(), + } + + await tpt.run_temp_change_burst_protocol_tool( + embryo_id="emb1", + target_setpoint_c=25.0, + context=ctx, + ) + + assert cs.transitions == [], f"Expected no transitions, got {cs.transitions}" + + +# --------------------------------------------------------------------------- +# temperature_protocol orchestration — tactic_id in event payloads +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_temp_protocol_started_event_carries_tactic_id(): + """TEMP_PROTOCOL_STARTED event data includes tactic_id.""" + from gently.app.orchestration.temperature_protocol import run_temp_change_burst_protocol + from gently.core import EventType + + emitted: list[tuple] = [] + + class FakeClient: + async def set_laser_config(self, cfg): + pass + + async def set_led(self, s): + pass + + async def set_temperature(self, t): + pass + + async def get_temperature(self): + return {"state": "LOCKED"} + + class FakeOrc: + client = FakeClient() + + def _emit_event(self, event_type, data): + emitted.append((event_type, data)) + + bursts_run: list[str] = [] + + async def fake_burst_runner(b): + bursts_run.append(b._phase) + + await run_temp_change_burst_protocol( + FakeOrc(), + "emb1", + 25.0, + bursts_before=0, + bursts_after=0, + burst_runner=fake_burst_runner, + tactic_id="t5", + ) + + started = [(et, d) for et, d in emitted if et == EventType.TEMP_PROTOCOL_STARTED] + assert started, "TEMP_PROTOCOL_STARTED not emitted" + assert started[0][1].get("tactic_id") == "t5" + + +@pytest.mark.asyncio +async def test_temp_protocol_completed_event_carries_tactic_id(): + """TEMP_PROTOCOL_COMPLETED event data includes tactic_id.""" + from gently.app.orchestration.temperature_protocol import run_temp_change_burst_protocol + from gently.core import EventType + + emitted: list[tuple] = [] + + class FakeClient: + async def set_laser_config(self, cfg): + pass + + async def set_led(self, s): + pass + + async def set_temperature(self, t): + pass + + async def get_temperature(self): + return {"state": "LOCKED"} + + class FakeOrc: + client = FakeClient() + + def _emit_event(self, event_type, data): + emitted.append((event_type, data)) + + await run_temp_change_burst_protocol( + FakeOrc(), + "emb1", + 25.0, + bursts_before=0, + bursts_after=0, + burst_runner=lambda b: asyncio.sleep(0), + tactic_id="t5", + ) + + completed = [(et, d) for et, d in emitted if et == EventType.TEMP_PROTOCOL_COMPLETED] + assert completed, "TEMP_PROTOCOL_COMPLETED not emitted" + assert completed[0][1].get("tactic_id") == "t5" + + +@pytest.mark.asyncio +async def test_temp_protocol_tactic_id_none_when_absent(): + """TEMP_PROTOCOL_* events include tactic_id=None when not supplied (backward compat).""" + from gently.app.orchestration.temperature_protocol import run_temp_change_burst_protocol + from gently.core import EventType + + emitted: list[tuple] = [] + + class FakeClient: + async def set_laser_config(self, cfg): + pass + + async def set_led(self, s): + pass + + async def set_temperature(self, t): + pass + + async def get_temperature(self): + return {"state": "LOCKED"} + + class FakeOrc: + client = FakeClient() + + def _emit_event(self, event_type, data): + emitted.append((event_type, data)) + + await run_temp_change_burst_protocol( + FakeOrc(), + "emb1", + 25.0, + bursts_before=0, + bursts_after=0, + burst_runner=lambda b: asyncio.sleep(0), + ) + + started = [(et, d) for et, d in emitted if et == EventType.TEMP_PROTOCOL_STARTED] + completed = [(et, d) for et, d in emitted if et == EventType.TEMP_PROTOCOL_COMPLETED] + assert started and "tactic_id" in started[0][1] + assert started[0][1]["tactic_id"] is None + assert completed and "tactic_id" in completed[0][1] + assert completed[0][1]["tactic_id"] is None diff --git a/tests/test_transition_tactic.py b/tests/test_transition_tactic.py new file mode 100644 index 00000000..0064b8f3 --- /dev/null +++ b/tests/test_transition_tactic.py @@ -0,0 +1,229 @@ +""" +FileContextStore.transition_tactic — TDD tests. + +Verifies: state flip, live bind merge, updated_at stamp, +True/False return values, no-op on missing plan or tactic. +""" + +from gently.core.event_bus import EventType, on + +# --------------------------------------------------------------------------- +# Plan fixture shared across tests +# --------------------------------------------------------------------------- + +_PLAN = { + "session_id": "sess_tt", + "title": "Transition-tactic test plan", + "goal": "Verify atomic tactic transitions", + "tactics": [ + { + "id": "t1", + "name": "Baseline timelapse", + "kind": "standing_timelapse", + "state": "planned", + "scope": {"mode": "global"}, + "rationale": "Establish pre-ramp cadence", + "structure": {"cadence_s": 120}, + "live_bind": ["cadence"], + "relations": {}, + }, + { + "id": "t2", + "name": "Temp ramp monitor", + "kind": "reactive_monitor", + "state": "planned", + "scope": {"mode": "global"}, + "rationale": "Detect onset event", + "structure": {"watch": "temperature > 24.5"}, + "live_bind": ["temperature"], + "relations": {"after": ["t1"]}, + }, + ], + "updated_at": "2026-06-28T00:00:00", + "updated_reason": "initial plan", +} + + +def _fresh_plan(): + """Deep-copy the fixture so mutations don't bleed between tests.""" + import copy + + return copy.deepcopy(_PLAN) + + +# --------------------------------------------------------------------------- +# Happy-path tests +# --------------------------------------------------------------------------- + + +class TestTransitionTacticHappyPath: + def test_returns_true_on_success(self, file_context_store): + cs = file_context_store + cs.set_operation_plan("sess_tt", _fresh_plan()) + result = cs.transition_tactic("sess_tt", "t1", state="active") + assert result is True + + def test_state_updated(self, file_context_store): + """State flips from planned to active after transition.""" + cs = file_context_store + cs.set_operation_plan("sess_tt", _fresh_plan()) + cs.transition_tactic("sess_tt", "t1", state="active") + plan = cs.get_operation_plan("sess_tt") + t1 = next(t for t in plan["tactics"] if t["id"] == "t1") + assert t1["state"] == "active" + + def test_bind_written_to_live(self, file_context_store): + """Bind kwargs land in tactic['live'].""" + cs = file_context_store + cs.set_operation_plan("sess_tt", _fresh_plan()) + cs.transition_tactic("sess_tt", "t1", state="active", request_id="b1") + plan = cs.get_operation_plan("sess_tt") + t1 = next(t for t in plan["tactics"] if t["id"] == "t1") + assert t1["live"]["request_id"] == "b1" + + def test_state_and_bind_together(self, file_context_store): + """State flip and live bind work together in one call.""" + cs = file_context_store + cs.set_operation_plan("sess_tt", _fresh_plan()) + cs.transition_tactic("sess_tt", "t1", state="active", request_id="b1", cadence=90) + plan = cs.get_operation_plan("sess_tt") + t1 = next(t for t in plan["tactics"] if t["id"] == "t1") + assert t1["state"] == "active" + assert t1["live"]["request_id"] == "b1" + assert t1["live"]["cadence"] == 90 + + def test_bind_merges_into_existing_live(self, file_context_store): + """Subsequent bind calls merge, not replace, existing live values.""" + cs = file_context_store + cs.set_operation_plan("sess_tt", _fresh_plan()) + cs.transition_tactic("sess_tt", "t1", request_id="b1") + cs.transition_tactic("sess_tt", "t1", mp4_path="/data/t1.mp4") + plan = cs.get_operation_plan("sess_tt") + t1 = next(t for t in plan["tactics"] if t["id"] == "t1") + assert t1["live"]["request_id"] == "b1" + assert t1["live"]["mp4_path"] == "/data/t1.mp4" + + def test_bind_creates_live_dict_when_absent(self, file_context_store): + """live dict is created if the tactic didn't have one.""" + cs = file_context_store + plan = _fresh_plan() + # Ensure no live key exists + assert "live" not in plan["tactics"][0] + cs.set_operation_plan("sess_tt", plan) + cs.transition_tactic("sess_tt", "t1", setpoint=25.0) + t1 = cs.get_operation_plan("sess_tt")["tactics"][0] + assert t1["live"]["setpoint"] == 25.0 + + def test_state_only_no_bind(self, file_context_store): + """state-only call with no bind kwargs works and adds no live key.""" + cs = file_context_store + cs.set_operation_plan("sess_tt", _fresh_plan()) + cs.transition_tactic("sess_tt", "t1", state="done") + plan = cs.get_operation_plan("sess_tt") + t1 = next(t for t in plan["tactics"] if t["id"] == "t1") + assert t1["state"] == "done" + assert "live" not in t1 + + def test_bind_only_no_state_change(self, file_context_store): + """bind-only call (no state arg) preserves existing state.""" + cs = file_context_store + cs.set_operation_plan("sess_tt", _fresh_plan()) + cs.transition_tactic("sess_tt", "t1", cadence=60) + plan = cs.get_operation_plan("sess_tt") + t1 = next(t for t in plan["tactics"] if t["id"] == "t1") + assert t1["state"] == "planned" # unchanged + assert t1["live"]["cadence"] == 60 + + def test_other_tactic_unaffected(self, file_context_store): + """Transitioning t1 does not alter t2.""" + cs = file_context_store + cs.set_operation_plan("sess_tt", _fresh_plan()) + cs.transition_tactic("sess_tt", "t1", state="active", request_id="b1") + plan = cs.get_operation_plan("sess_tt") + t2 = next(t for t in plan["tactics"] if t["id"] == "t2") + assert t2["state"] == "planned" + assert "live" not in t2 + + def test_updated_at_stamped(self, file_context_store): + """updated_at is refreshed after a successful transition.""" + cs = file_context_store + cs.set_operation_plan("sess_tt", _fresh_plan()) + cs.transition_tactic("sess_tt", "t1", state="active") + plan = cs.get_operation_plan("sess_tt") + assert plan["updated_at"] != "2026-06-28T00:00:00" + + def test_updated_reason_stamped(self, file_context_store): + """updated_reason reflects which tactic was transitioned.""" + cs = file_context_store + cs.set_operation_plan("sess_tt", _fresh_plan()) + cs.transition_tactic("sess_tt", "t1", state="active") + plan = cs.get_operation_plan("sess_tt") + assert "t1" in plan["updated_reason"] + + def test_fires_context_updated_event(self, file_context_store): + """transition_tactic fires CONTEXT_UPDATED (via set_operation_plan).""" + cs = file_context_store + cs.set_operation_plan("sess_tt", _fresh_plan()) + seen = [] + unsub = on(EventType.CONTEXT_UPDATED, lambda e: seen.append(e)) + try: + cs.transition_tactic("sess_tt", "t1", state="active") + finally: + unsub() + assert len(seen) >= 1 + + +# --------------------------------------------------------------------------- +# No-op / guard tests +# --------------------------------------------------------------------------- + + +class TestTransitionTacticNoOp: + def test_absent_plan_returns_false(self, file_context_store): + """Returns False and does not crash when no plan exists.""" + result = file_context_store.transition_tactic("no_such_session", "t1", state="active") + assert result is False + + def test_absent_plan_no_side_effects(self, file_context_store): + """Absent-plan call creates no operation_plan file.""" + cs = file_context_store + cs.transition_tactic("ghost_session", "t1", state="active") + assert cs.get_operation_plan("ghost_session") is None + + def test_unknown_tactic_id_returns_false(self, file_context_store): + """Returns False when the tactic id is not present in the plan.""" + cs = file_context_store + cs.set_operation_plan("sess_tt", _fresh_plan()) + result = cs.transition_tactic("sess_tt", "no_such_tactic", state="active") + assert result is False + + def test_unknown_tactic_plan_unchanged(self, file_context_store): + """Plan is not mutated when the tactic id is not found.""" + cs = file_context_store + cs.set_operation_plan("sess_tt", _fresh_plan()) + original_updated_at = cs.get_operation_plan("sess_tt")["updated_at"] + cs.transition_tactic("sess_tt", "no_such_tactic", state="active") + plan = cs.get_operation_plan("sess_tt") + assert plan["updated_at"] == original_updated_at + + def test_no_event_on_absent_plan(self, file_context_store): + """CONTEXT_UPDATED must not fire when the plan is absent.""" + seen = [] + unsub = on(EventType.CONTEXT_UPDATED, lambda e: seen.append(e)) + try: + file_context_store.transition_tactic("ghost", "t1", state="active") + finally: + unsub() + assert len(seen) == 0 + + def test_no_event_on_unknown_tactic(self, file_context_store): + """CONTEXT_UPDATED must not fire when the tactic id is not found.""" + cs = file_context_store + cs.set_operation_plan("sess_tt", _fresh_plan()) + seen = [] + unsub = on(EventType.CONTEXT_UPDATED, lambda e: seen.append(e)) + try: + cs.transition_tactic("sess_tt", "no_such", state="active") + finally: + unsub() + assert len(seen) == 0 diff --git a/tests/test_wait_for_lock.py b/tests/test_wait_for_lock.py new file mode 100644 index 00000000..c8d7f854 --- /dev/null +++ b/tests/test_wait_for_lock.py @@ -0,0 +1,22 @@ +from gently.app.orchestration.temperature_protocol import wait_for_temperature_lock + + +class FakeClient: + def __init__(self, states): + self.states = list(states) + self.calls = 0 + + async def get_temperature(self): + i = min(self.calls, len(self.states) - 1) + self.calls += 1 + return {"state": self.states[i]} + + +async def test_returns_true_when_locked(): + c = FakeClient(["[ IDLE ]", "[ HEATING ]", "[ SYSTEM LOCKED ]"]) + assert await wait_for_temperature_lock(c, timeout_s=5.0, poll_s=0.001) is True + + +async def test_returns_false_on_timeout(): + c = FakeClient(["[ HEATING ]"]) + assert await wait_for_temperature_lock(c, timeout_s=0.02, poll_s=0.001) is False diff --git a/uv.lock b/uv.lock index 1e82d177..830aaca1 100644 --- a/uv.lock +++ b/uv.lock @@ -2,15 +2,15 @@ version = 1 revision = 3 requires-python = ">=3.10, <3.13" resolution-markers = [ - "python_full_version >= '3.12' and extra != 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu'", - "python_full_version == '3.11.*' and extra != 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu'", - "python_full_version < '3.11' and extra != 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu'", - "python_full_version >= '3.11' and sys_platform != 'darwin' and extra == 'extra-6-gently-torch-cpu' and extra != 'extra-6-gently-torch-gpu'", - "python_full_version >= '3.11' and sys_platform == 'darwin' and extra == 'extra-6-gently-torch-cpu' and extra != 'extra-6-gently-torch-gpu'", - "python_full_version < '3.11' and sys_platform != 'darwin' and extra == 'extra-6-gently-torch-cpu' and extra != 'extra-6-gently-torch-gpu'", - "python_full_version < '3.11' and sys_platform == 'darwin' and extra == 'extra-6-gently-torch-cpu' and extra != 'extra-6-gently-torch-gpu'", - "python_full_version >= '3.11' and extra != 'extra-6-gently-torch-cpu' and extra != 'extra-6-gently-torch-gpu'", - "python_full_version < '3.11' and extra != 'extra-6-gently-torch-cpu' and extra != 'extra-6-gently-torch-gpu'", + "python_full_version >= '3.12' and sys_platform == 'darwin'", + "python_full_version >= '3.12' and platform_machine == 'aarch64' and sys_platform == 'linux'", + "(python_full_version >= '3.12' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version >= '3.12' and sys_platform != 'darwin' and sys_platform != 'linux')", + "python_full_version == '3.11.*' and sys_platform == 'darwin'", + "python_full_version == '3.11.*' and platform_machine == 'aarch64' and sys_platform == 'linux'", + "(python_full_version == '3.11.*' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.11.*' and sys_platform != 'darwin' and sys_platform != 'linux')", + "python_full_version < '3.11' and sys_platform == 'darwin'", + "python_full_version < '3.11' and platform_machine == 'aarch64' and sys_platform == 'linux'", + "(python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform != 'darwin' and sys_platform != 'linux')", ] conflicts = [[ { package = "gently", extra = "torch-cpu" }, @@ -301,10 +301,9 @@ name = "contourpy" version = "1.3.2" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version < '3.11' and extra != 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu'", - "python_full_version < '3.11' and sys_platform != 'darwin' and extra == 'extra-6-gently-torch-cpu' and extra != 'extra-6-gently-torch-gpu'", - "python_full_version < '3.11' and sys_platform == 'darwin' and extra == 'extra-6-gently-torch-cpu' and extra != 'extra-6-gently-torch-gpu'", - "python_full_version < '3.11' and extra != 'extra-6-gently-torch-cpu' and extra != 'extra-6-gently-torch-gpu'", + "python_full_version < '3.11' and sys_platform == 'darwin'", + "python_full_version < '3.11' and platform_machine == 'aarch64' and sys_platform == 'linux'", + "(python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform != 'darwin' and sys_platform != 'linux')", ] dependencies = [ { name = "numpy", marker = "python_full_version < '3.11' or (extra == 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu')" }, @@ -354,11 +353,12 @@ name = "contourpy" version = "1.3.3" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version >= '3.12' and extra != 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu'", - "python_full_version == '3.11.*' and extra != 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu'", - "python_full_version >= '3.11' and sys_platform != 'darwin' and extra == 'extra-6-gently-torch-cpu' and extra != 'extra-6-gently-torch-gpu'", - "python_full_version >= '3.11' and sys_platform == 'darwin' and extra == 'extra-6-gently-torch-cpu' and extra != 'extra-6-gently-torch-gpu'", - "python_full_version >= '3.11' and extra != 'extra-6-gently-torch-cpu' and extra != 'extra-6-gently-torch-gpu'", + "python_full_version >= '3.12' and sys_platform == 'darwin'", + "python_full_version >= '3.12' and platform_machine == 'aarch64' and sys_platform == 'linux'", + "(python_full_version >= '3.12' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version >= '3.12' and sys_platform != 'darwin' and sys_platform != 'linux')", + "python_full_version == '3.11.*' and sys_platform == 'darwin'", + "python_full_version == '3.11.*' and platform_machine == 'aarch64' and sys_platform == 'linux'", + "(python_full_version == '3.11.*' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.11.*' and sys_platform != 'darwin' and sys_platform != 'linux')", ] dependencies = [ { name = "numpy", marker = "python_full_version >= '3.11' or (extra == 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu')" }, @@ -646,6 +646,7 @@ dependencies = [ { name = "jinja2" }, { name = "matplotlib" }, { name = "numpy" }, + { name = "opencv-python-headless" }, { name = "pillow" }, { name = "python-dotenv" }, { name = "pyyaml" }, @@ -701,6 +702,7 @@ requires-dist = [ { name = "matplotlib", specifier = ">=3.7.0" }, { name = "numpy", specifier = ">=1.24.0,<2" }, { name = "nvidia-ml-py", marker = "extra == 'torch-gpu'", specifier = ">=12.0.0" }, + { name = "opencv-python-headless", specifier = ">=4.8.0" }, { name = "pillow", specifier = ">=10.0.0" }, { name = "python-dotenv", specifier = ">=1.0.0" }, { name = "pyyaml", specifier = ">=6.0" }, @@ -1435,10 +1437,9 @@ name = "networkx" version = "3.4.2" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version < '3.11' and extra != 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu'", - "python_full_version < '3.11' and sys_platform != 'darwin' and extra == 'extra-6-gently-torch-cpu' and extra != 'extra-6-gently-torch-gpu'", - "python_full_version < '3.11' and sys_platform == 'darwin' and extra == 'extra-6-gently-torch-cpu' and extra != 'extra-6-gently-torch-gpu'", - "python_full_version < '3.11' and extra != 'extra-6-gently-torch-cpu' and extra != 'extra-6-gently-torch-gpu'", + "python_full_version < '3.11' and sys_platform == 'darwin'", + "python_full_version < '3.11' and platform_machine == 'aarch64' and sys_platform == 'linux'", + "(python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform != 'darwin' and sys_platform != 'linux')", ] sdist = { url = "https://files.pythonhosted.org/packages/fd/1d/06475e1cd5264c0b870ea2cc6fdb3e37177c1e565c43f56ff17a10e3937f/networkx-3.4.2.tar.gz", hash = "sha256:307c3669428c5362aab27c8a1260aa8f47c4e91d3891f48be0141738d8d053e1", size = 2151368, upload-time = "2024-10-21T12:39:38.695Z" } wheels = [ @@ -1450,11 +1451,12 @@ name = "networkx" version = "3.6.1" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version >= '3.12' and extra != 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu'", - "python_full_version == '3.11.*' and extra != 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu'", - "python_full_version >= '3.11' and sys_platform != 'darwin' and extra == 'extra-6-gently-torch-cpu' and extra != 'extra-6-gently-torch-gpu'", - "python_full_version >= '3.11' and sys_platform == 'darwin' and extra == 'extra-6-gently-torch-cpu' and extra != 'extra-6-gently-torch-gpu'", - "python_full_version >= '3.11' and extra != 'extra-6-gently-torch-cpu' and extra != 'extra-6-gently-torch-gpu'", + "python_full_version >= '3.12' and sys_platform == 'darwin'", + "python_full_version >= '3.12' and platform_machine == 'aarch64' and sys_platform == 'linux'", + "(python_full_version >= '3.12' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version >= '3.12' and sys_platform != 'darwin' and sys_platform != 'linux')", + "python_full_version == '3.11.*' and sys_platform == 'darwin'", + "python_full_version == '3.11.*' and platform_machine == 'aarch64' and sys_platform == 'linux'", + "(python_full_version == '3.11.*' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.11.*' and sys_platform != 'darwin' and sys_platform != 'linux')", ] sdist = { url = "https://files.pythonhosted.org/packages/6a/51/63fe664f3908c97be9d2e4f1158eb633317598cfa6e1fc14af5383f17512/networkx-3.6.1.tar.gz", hash = "sha256:26b7c357accc0c8cde558ad486283728b65b6a95d85ee1cd66bafab4c8168509", size = 2517025, upload-time = "2025-12-08T17:02:39.908Z" } wheels = [ @@ -1551,7 +1553,7 @@ name = "nvidia-cudnn-cu11" version = "9.1.0.70" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-cublas-cu11" }, + { name = "nvidia-cublas-cu11", marker = "(platform_machine != 'aarch64' and sys_platform == 'linux') or (sys_platform != 'darwin' and sys_platform != 'linux')" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/00/3b/0b776f04e364cd99e4cf152c2a9eadb5934c67c9a91429da55169a9447fd/nvidia_cudnn_cu11-9.1.0.70-py3-none-manylinux2014_x86_64.whl", hash = "sha256:e6135ac63fe9d5b0b89cfb35c3fc1c1349f2b995becadf2e9dc21bca89d9633d", size = 663919573, upload-time = "2024-04-22T15:20:24.839Z" }, @@ -1585,7 +1587,7 @@ name = "nvidia-cusolver-cu11" version = "11.4.1.48" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-cublas-cu11" }, + { name = "nvidia-cublas-cu11", marker = "(platform_machine != 'aarch64' and sys_platform == 'linux') or (sys_platform != 'darwin' and sys_platform != 'linux')" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/55/ee/939ff0104991dd7bdabb4c9767994c612ba0e1c9a55672a1ddd42f5e5b16/nvidia_cusolver_cu11-11.4.1.48-py3-none-manylinux1_x86_64.whl", hash = "sha256:ca538f545645b7e6629140786d3127fe067b3d5a085bd794cde5bfe877c8926f", size = 128240842, upload-time = "2022-10-03T23:30:24.348Z" }, @@ -1633,6 +1635,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3f/3f/0e1dd2bc4d89f838b86c76956ffa514307d3be4d8b5ee0da4e9d12a8b54b/nvidia_nvtx_cu11-11.8.86-py3-none-win_amd64.whl", hash = "sha256:54031010ee38d774b2991004d88f90bbd7bbc1458a96bbc4b42662756508c252", size = 66297, upload-time = "2022-10-03T23:39:12.132Z" }, ] +[[package]] +name = "opencv-python-headless" +version = "4.11.0.86" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/36/2f/5b2b3ba52c864848885ba988f24b7f105052f68da9ab0e693cc7c25b0b30/opencv-python-headless-4.11.0.86.tar.gz", hash = "sha256:996eb282ca4b43ec6a3972414de0e2331f5d9cda2b41091a49739c19fb843798", size = 95177929, upload-time = "2025-01-16T13:53:40.22Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/53/2c50afa0b1e05ecdb4603818e85f7d174e683d874ef63a6abe3ac92220c8/opencv_python_headless-4.11.0.86-cp37-abi3-macosx_13_0_arm64.whl", hash = "sha256:48128188ade4a7e517237c8e1e11a9cdf5c282761473383e77beb875bb1e61ca", size = 37326460, upload-time = "2025-01-16T13:52:57.015Z" }, + { url = "https://files.pythonhosted.org/packages/3b/43/68555327df94bb9b59a1fd645f63fafb0762515344d2046698762fc19d58/opencv_python_headless-4.11.0.86-cp37-abi3-macosx_13_0_x86_64.whl", hash = "sha256:a66c1b286a9de872c343ee7c3553b084244299714ebb50fbdcd76f07ebbe6c81", size = 56723330, upload-time = "2025-01-16T13:55:45.731Z" }, + { url = "https://files.pythonhosted.org/packages/45/be/1438ce43ebe65317344a87e4b150865c5585f4c0db880a34cdae5ac46881/opencv_python_headless-4.11.0.86-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6efabcaa9df731f29e5ea9051776715b1bdd1845d7c9530065c7951d2a2899eb", size = 29487060, upload-time = "2025-01-16T13:51:59.625Z" }, + { url = "https://files.pythonhosted.org/packages/dd/5c/c139a7876099916879609372bfa513b7f1257f7f1a908b0bdc1c2328241b/opencv_python_headless-4.11.0.86-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0e0a27c19dd1f40ddff94976cfe43066fbbe9dfbb2ec1907d66c19caef42a57b", size = 49969856, upload-time = "2025-01-16T13:53:29.654Z" }, + { url = "https://files.pythonhosted.org/packages/95/dd/ed1191c9dc91abcc9f752b499b7928aacabf10567bb2c2535944d848af18/opencv_python_headless-4.11.0.86-cp37-abi3-win32.whl", hash = "sha256:f447d8acbb0b6f2808da71fddd29c1cdd448d2bc98f72d9bb78a7a898fc9621b", size = 29324425, upload-time = "2025-01-16T13:52:49.048Z" }, + { url = "https://files.pythonhosted.org/packages/86/8a/69176a64335aed183529207ba8bc3d329c2999d852b4f3818027203f50e6/opencv_python_headless-4.11.0.86-cp37-abi3-win_amd64.whl", hash = "sha256:6c304df9caa7a6a5710b91709dd4786bf20a74d57672b3c31f7033cc638174ca", size = 39402386, upload-time = "2025-01-16T13:52:56.418Z" }, +] + [[package]] name = "opentelemetry-api" version = "1.42.1" @@ -1743,10 +1762,9 @@ name = "pint" version = "0.24.4" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version < '3.11' and extra != 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu'", - "python_full_version < '3.11' and sys_platform != 'darwin' and extra == 'extra-6-gently-torch-cpu' and extra != 'extra-6-gently-torch-gpu'", - "python_full_version < '3.11' and sys_platform == 'darwin' and extra == 'extra-6-gently-torch-cpu' and extra != 'extra-6-gently-torch-gpu'", - "python_full_version < '3.11' and extra != 'extra-6-gently-torch-cpu' and extra != 'extra-6-gently-torch-gpu'", + "python_full_version < '3.11' and sys_platform == 'darwin'", + "python_full_version < '3.11' and platform_machine == 'aarch64' and sys_platform == 'linux'", + "(python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform != 'darwin' and sys_platform != 'linux')", ] dependencies = [ { name = "flexcache", marker = "python_full_version < '3.11' or (extra == 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu')" }, @@ -1764,11 +1782,12 @@ name = "pint" version = "0.25.3" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version >= '3.12' and extra != 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu'", - "python_full_version == '3.11.*' and extra != 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu'", - "python_full_version >= '3.11' and sys_platform != 'darwin' and extra == 'extra-6-gently-torch-cpu' and extra != 'extra-6-gently-torch-gpu'", - "python_full_version >= '3.11' and sys_platform == 'darwin' and extra == 'extra-6-gently-torch-cpu' and extra != 'extra-6-gently-torch-gpu'", - "python_full_version >= '3.11' and extra != 'extra-6-gently-torch-cpu' and extra != 'extra-6-gently-torch-gpu'", + "python_full_version >= '3.12' and sys_platform == 'darwin'", + "python_full_version >= '3.12' and platform_machine == 'aarch64' and sys_platform == 'linux'", + "(python_full_version >= '3.12' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version >= '3.12' and sys_platform != 'darwin' and sys_platform != 'linux')", + "python_full_version == '3.11.*' and sys_platform == 'darwin'", + "python_full_version == '3.11.*' and platform_machine == 'aarch64' and sys_platform == 'linux'", + "(python_full_version == '3.11.*' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.11.*' and sys_platform != 'darwin' and sys_platform != 'linux')", ] dependencies = [ { name = "flexcache", marker = "python_full_version >= '3.11' or (extra == 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu')" }, @@ -2004,7 +2023,7 @@ name = "pyobjc-framework-cocoa" version = "12.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core", marker = "sys_platform == 'darwin' or extra != 'extra-6-gently-torch-cpu' or (extra == 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu')" }, + { name = "pyobjc-core", marker = "sys_platform == 'darwin' or (extra == 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/6d/cc/927169225e72bab9c9b44285656768fb75052a0bc85fdbca62740e1ca43c/pyobjc_framework_cocoa-12.2.tar.gz", hash = "sha256:20b392e2b7241caad0538dfde12143343e5dfe48f72e7df660a7548e635903dc", size = 3125555, upload-time = "2026-05-30T12:35:09.273Z" } wheels = [ @@ -2018,8 +2037,8 @@ name = "pyobjc-framework-corebluetooth" version = "12.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core", marker = "sys_platform == 'darwin' or extra != 'extra-6-gently-torch-cpu' or (extra == 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu')" }, - { name = "pyobjc-framework-cocoa", marker = "sys_platform == 'darwin' or extra != 'extra-6-gently-torch-cpu' or (extra == 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu')" }, + { name = "pyobjc-core", marker = "sys_platform == 'darwin' or (extra == 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu')" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform == 'darwin' or (extra == 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/52/df/bee7ba216f9fb513710aac1701b78c97b087b37fca8ec1806f8572e0bbb3/pyobjc_framework_corebluetooth-12.2.tar.gz", hash = "sha256:8b4e5ca99953c360c391a695b0782a5328fcecafd56fdf790ad709e932feb306", size = 37552, upload-time = "2026-05-30T12:35:33.863Z" } wheels = [ @@ -2033,8 +2052,8 @@ name = "pyobjc-framework-libdispatch" version = "12.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core", marker = "sys_platform == 'darwin' or extra != 'extra-6-gently-torch-cpu' or (extra == 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu')" }, - { name = "pyobjc-framework-cocoa", marker = "sys_platform == 'darwin' or extra != 'extra-6-gently-torch-cpu' or (extra == 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu')" }, + { name = "pyobjc-core", marker = "sys_platform == 'darwin' or (extra == 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu')" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform == 'darwin' or (extra == 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/1b/fe/e23be301e46c30450955cdb096f16f6a86e7609787a4b8225ec24d6fdc9d/pyobjc_framework_libdispatch-12.2.tar.gz", hash = "sha256:4a41879ef7716b73d70f2e40ff39353d686cbc59d48c93217ed362d2b2baf1ba", size = 40345, upload-time = "2026-05-30T12:39:09.474Z" } wheels = [ @@ -2183,10 +2202,9 @@ name = "rpds-py" version = "0.30.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version < '3.11' and extra != 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu'", - "python_full_version < '3.11' and sys_platform != 'darwin' and extra == 'extra-6-gently-torch-cpu' and extra != 'extra-6-gently-torch-gpu'", - "python_full_version < '3.11' and sys_platform == 'darwin' and extra == 'extra-6-gently-torch-cpu' and extra != 'extra-6-gently-torch-gpu'", - "python_full_version < '3.11' and extra != 'extra-6-gently-torch-cpu' and extra != 'extra-6-gently-torch-gpu'", + "python_full_version < '3.11' and sys_platform == 'darwin'", + "python_full_version < '3.11' and platform_machine == 'aarch64' and sys_platform == 'linux'", + "(python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform != 'darwin' and sys_platform != 'linux')", ] sdist = { url = "https://files.pythonhosted.org/packages/20/af/3f2f423103f1113b36230496629986e0ef7e199d2aa8392452b484b38ced/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84", size = 69469, upload-time = "2025-11-30T20:24:38.837Z" } wheels = [ @@ -2253,11 +2271,12 @@ name = "rpds-py" version = "2026.5.1" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version >= '3.12' and extra != 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu'", - "python_full_version == '3.11.*' and extra != 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu'", - "python_full_version >= '3.11' and sys_platform != 'darwin' and extra == 'extra-6-gently-torch-cpu' and extra != 'extra-6-gently-torch-gpu'", - "python_full_version >= '3.11' and sys_platform == 'darwin' and extra == 'extra-6-gently-torch-cpu' and extra != 'extra-6-gently-torch-gpu'", - "python_full_version >= '3.11' and extra != 'extra-6-gently-torch-cpu' and extra != 'extra-6-gently-torch-gpu'", + "python_full_version >= '3.12' and sys_platform == 'darwin'", + "python_full_version >= '3.12' and platform_machine == 'aarch64' and sys_platform == 'linux'", + "(python_full_version >= '3.12' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version >= '3.12' and sys_platform != 'darwin' and sys_platform != 'linux')", + "python_full_version == '3.11.*' and sys_platform == 'darwin'", + "python_full_version == '3.11.*' and platform_machine == 'aarch64' and sys_platform == 'linux'", + "(python_full_version == '3.11.*' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.11.*' and sys_platform != 'darwin' and sys_platform != 'linux')", ] sdist = { url = "https://files.pythonhosted.org/packages/2e/43/25a8dcd3feedd735039a8f0b5b7e3b118232b5eae288c4fd9ab200d41094/rpds_py-2026.5.1.tar.gz", hash = "sha256:07b24fea40541e28570e5b795a4a38fbdcd12550c06bd0748005ecc8116ca256", size = 64459, upload-time = "2026-05-28T12:02:13.232Z" } wheels = [ @@ -2335,10 +2354,9 @@ name = "scikit-image" version = "0.25.2" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version < '3.11' and extra != 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu'", - "python_full_version < '3.11' and sys_platform != 'darwin' and extra == 'extra-6-gently-torch-cpu' and extra != 'extra-6-gently-torch-gpu'", - "python_full_version < '3.11' and sys_platform == 'darwin' and extra == 'extra-6-gently-torch-cpu' and extra != 'extra-6-gently-torch-gpu'", - "python_full_version < '3.11' and extra != 'extra-6-gently-torch-cpu' and extra != 'extra-6-gently-torch-gpu'", + "python_full_version < '3.11' and sys_platform == 'darwin'", + "python_full_version < '3.11' and platform_machine == 'aarch64' and sys_platform == 'linux'", + "(python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform != 'darwin' and sys_platform != 'linux')", ] dependencies = [ { name = "imageio", marker = "python_full_version < '3.11' or (extra == 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu')" }, @@ -2374,11 +2392,12 @@ name = "scikit-image" version = "0.26.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version >= '3.12' and extra != 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu'", - "python_full_version == '3.11.*' and extra != 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu'", - "python_full_version >= '3.11' and sys_platform != 'darwin' and extra == 'extra-6-gently-torch-cpu' and extra != 'extra-6-gently-torch-gpu'", - "python_full_version >= '3.11' and sys_platform == 'darwin' and extra == 'extra-6-gently-torch-cpu' and extra != 'extra-6-gently-torch-gpu'", - "python_full_version >= '3.11' and extra != 'extra-6-gently-torch-cpu' and extra != 'extra-6-gently-torch-gpu'", + "python_full_version >= '3.12' and sys_platform == 'darwin'", + "python_full_version >= '3.12' and platform_machine == 'aarch64' and sys_platform == 'linux'", + "(python_full_version >= '3.12' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version >= '3.12' and sys_platform != 'darwin' and sys_platform != 'linux')", + "python_full_version == '3.11.*' and sys_platform == 'darwin'", + "python_full_version == '3.11.*' and platform_machine == 'aarch64' and sys_platform == 'linux'", + "(python_full_version == '3.11.*' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.11.*' and sys_platform != 'darwin' and sys_platform != 'linux')", ] dependencies = [ { name = "imageio", marker = "python_full_version >= '3.11' or (extra == 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu')" }, @@ -2415,10 +2434,9 @@ name = "scipy" version = "1.15.3" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version < '3.11' and extra != 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu'", - "python_full_version < '3.11' and sys_platform != 'darwin' and extra == 'extra-6-gently-torch-cpu' and extra != 'extra-6-gently-torch-gpu'", - "python_full_version < '3.11' and sys_platform == 'darwin' and extra == 'extra-6-gently-torch-cpu' and extra != 'extra-6-gently-torch-gpu'", - "python_full_version < '3.11' and extra != 'extra-6-gently-torch-cpu' and extra != 'extra-6-gently-torch-gpu'", + "python_full_version < '3.11' and sys_platform == 'darwin'", + "python_full_version < '3.11' and platform_machine == 'aarch64' and sys_platform == 'linux'", + "(python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform != 'darwin' and sys_platform != 'linux')", ] dependencies = [ { name = "numpy", marker = "python_full_version < '3.11' or (extra == 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu')" }, @@ -2459,11 +2477,12 @@ name = "scipy" version = "1.17.1" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version >= '3.12' and extra != 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu'", - "python_full_version == '3.11.*' and extra != 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu'", - "python_full_version >= '3.11' and sys_platform != 'darwin' and extra == 'extra-6-gently-torch-cpu' and extra != 'extra-6-gently-torch-gpu'", - "python_full_version >= '3.11' and sys_platform == 'darwin' and extra == 'extra-6-gently-torch-cpu' and extra != 'extra-6-gently-torch-gpu'", - "python_full_version >= '3.11' and extra != 'extra-6-gently-torch-cpu' and extra != 'extra-6-gently-torch-gpu'", + "python_full_version >= '3.12' and sys_platform == 'darwin'", + "python_full_version >= '3.12' and platform_machine == 'aarch64' and sys_platform == 'linux'", + "(python_full_version >= '3.12' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version >= '3.12' and sys_platform != 'darwin' and sys_platform != 'linux')", + "python_full_version == '3.11.*' and sys_platform == 'darwin'", + "python_full_version == '3.11.*' and platform_machine == 'aarch64' and sys_platform == 'linux'", + "(python_full_version == '3.11.*' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.11.*' and sys_platform != 'darwin' and sys_platform != 'linux')", ] dependencies = [ { name = "numpy", marker = "python_full_version >= '3.11' or (extra == 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu')" }, @@ -2558,10 +2577,9 @@ name = "tifffile" version = "2025.5.10" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version < '3.11' and extra != 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu'", - "python_full_version < '3.11' and sys_platform != 'darwin' and extra == 'extra-6-gently-torch-cpu' and extra != 'extra-6-gently-torch-gpu'", - "python_full_version < '3.11' and sys_platform == 'darwin' and extra == 'extra-6-gently-torch-cpu' and extra != 'extra-6-gently-torch-gpu'", - "python_full_version < '3.11' and extra != 'extra-6-gently-torch-cpu' and extra != 'extra-6-gently-torch-gpu'", + "python_full_version < '3.11' and sys_platform == 'darwin'", + "python_full_version < '3.11' and platform_machine == 'aarch64' and sys_platform == 'linux'", + "(python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform != 'darwin' and sys_platform != 'linux')", ] dependencies = [ { name = "numpy", marker = "python_full_version < '3.11' or (extra == 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu')" }, @@ -2576,11 +2594,12 @@ name = "tifffile" version = "2026.3.3" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version >= '3.12' and extra != 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu'", - "python_full_version == '3.11.*' and extra != 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu'", - "python_full_version >= '3.11' and sys_platform != 'darwin' and extra == 'extra-6-gently-torch-cpu' and extra != 'extra-6-gently-torch-gpu'", - "python_full_version >= '3.11' and sys_platform == 'darwin' and extra == 'extra-6-gently-torch-cpu' and extra != 'extra-6-gently-torch-gpu'", - "python_full_version >= '3.11' and extra != 'extra-6-gently-torch-cpu' and extra != 'extra-6-gently-torch-gpu'", + "python_full_version >= '3.12' and sys_platform == 'darwin'", + "python_full_version >= '3.12' and platform_machine == 'aarch64' and sys_platform == 'linux'", + "(python_full_version >= '3.12' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version >= '3.12' and sys_platform != 'darwin' and sys_platform != 'linux')", + "python_full_version == '3.11.*' and sys_platform == 'darwin'", + "python_full_version == '3.11.*' and platform_machine == 'aarch64' and sys_platform == 'linux'", + "(python_full_version == '3.11.*' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.11.*' and sys_platform != 'darwin' and sys_platform != 'linux')", ] dependencies = [ { name = "numpy", marker = "python_full_version >= '3.11' or (extra == 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu')" }, @@ -2631,9 +2650,15 @@ name = "torch" version = "2.7.1+cu118" source = { registry = "https://download.pytorch.org/whl/cu118" } resolution-markers = [ - "python_full_version >= '3.12'", - "python_full_version == '3.11.*'", - "python_full_version < '3.11'", + "python_full_version >= '3.12' and sys_platform == 'darwin'", + "python_full_version >= '3.12' and platform_machine == 'aarch64' and sys_platform == 'linux'", + "(python_full_version >= '3.12' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version >= '3.12' and sys_platform != 'darwin' and sys_platform != 'linux')", + "python_full_version == '3.11.*' and sys_platform == 'darwin'", + "python_full_version == '3.11.*' and platform_machine == 'aarch64' and sys_platform == 'linux'", + "(python_full_version == '3.11.*' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.11.*' and sys_platform != 'darwin' and sys_platform != 'linux')", + "python_full_version < '3.11' and sys_platform == 'darwin'", + "python_full_version < '3.11' and platform_machine == 'aarch64' and sys_platform == 'linux'", + "(python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform != 'darwin' and sys_platform != 'linux')", ] dependencies = [ { name = "filelock" }, @@ -2671,7 +2696,8 @@ name = "torch" version = "2.12.0" source = { registry = "https://download.pytorch.org/whl/cpu" } resolution-markers = [ - "python_full_version >= '3.11' and sys_platform == 'darwin'", + "python_full_version >= '3.12' and sys_platform == 'darwin'", + "python_full_version == '3.11.*' and sys_platform == 'darwin'", "python_full_version < '3.11' and sys_platform == 'darwin'", ] dependencies = [ @@ -2695,8 +2721,12 @@ name = "torch" version = "2.12.0+cpu" source = { registry = "https://download.pytorch.org/whl/cpu" } resolution-markers = [ - "python_full_version >= '3.11' and sys_platform != 'darwin'", - "python_full_version < '3.11' and sys_platform != 'darwin'", + "python_full_version >= '3.12' and platform_machine == 'aarch64' and sys_platform == 'linux'", + "(python_full_version >= '3.12' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version >= '3.12' and sys_platform != 'darwin' and sys_platform != 'linux')", + "python_full_version == '3.11.*' and platform_machine == 'aarch64' and sys_platform == 'linux'", + "(python_full_version == '3.11.*' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.11.*' and sys_platform != 'darwin' and sys_platform != 'linux')", + "python_full_version < '3.11' and platform_machine == 'aarch64' and sys_platform == 'linux'", + "(python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform != 'darwin' and sys_platform != 'linux')", ] dependencies = [ { name = "filelock", marker = "sys_platform != 'darwin'" }, @@ -2730,9 +2760,15 @@ name = "torchvision" version = "0.22.1+cu118" source = { registry = "https://download.pytorch.org/whl/cu118" } resolution-markers = [ - "python_full_version >= '3.12'", - "python_full_version == '3.11.*'", - "python_full_version < '3.11'", + "python_full_version >= '3.12' and sys_platform == 'darwin'", + "python_full_version >= '3.12' and platform_machine == 'aarch64' and sys_platform == 'linux'", + "(python_full_version >= '3.12' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version >= '3.12' and sys_platform != 'darwin' and sys_platform != 'linux')", + "python_full_version == '3.11.*' and sys_platform == 'darwin'", + "python_full_version == '3.11.*' and platform_machine == 'aarch64' and sys_platform == 'linux'", + "(python_full_version == '3.11.*' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.11.*' and sys_platform != 'darwin' and sys_platform != 'linux')", + "python_full_version < '3.11' and sys_platform == 'darwin'", + "python_full_version < '3.11' and platform_machine == 'aarch64' and sys_platform == 'linux'", + "(python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform != 'darwin' and sys_platform != 'linux')", ] dependencies = [ { name = "numpy" }, @@ -2753,7 +2789,8 @@ name = "torchvision" version = "0.27.0" source = { registry = "https://download.pytorch.org/whl/cpu" } resolution-markers = [ - "python_full_version >= '3.11' and sys_platform == 'darwin'", + "python_full_version >= '3.12' and sys_platform == 'darwin'", + "python_full_version == '3.11.*' and sys_platform == 'darwin'", "python_full_version < '3.11' and sys_platform == 'darwin'", ] dependencies = [ @@ -2772,8 +2809,12 @@ name = "torchvision" version = "0.27.0+cpu" source = { registry = "https://download.pytorch.org/whl/cpu" } resolution-markers = [ - "python_full_version >= '3.11' and sys_platform != 'darwin'", - "python_full_version < '3.11' and sys_platform != 'darwin'", + "python_full_version >= '3.12' and platform_machine == 'aarch64' and sys_platform == 'linux'", + "(python_full_version >= '3.12' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version >= '3.12' and sys_platform != 'darwin' and sys_platform != 'linux')", + "python_full_version == '3.11.*' and platform_machine == 'aarch64' and sys_platform == 'linux'", + "(python_full_version == '3.11.*' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.11.*' and sys_platform != 'darwin' and sys_platform != 'linux')", + "python_full_version < '3.11' and platform_machine == 'aarch64' and sys_platform == 'linux'", + "(python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform != 'darwin' and sys_platform != 'linux')", ] dependencies = [ { name = "numpy", marker = "sys_platform != 'darwin'" }, @@ -2809,7 +2850,7 @@ name = "triton" version = "3.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "setuptools" }, + { name = "setuptools", marker = "(platform_machine != 'aarch64' and sys_platform == 'linux') or (sys_platform != 'darwin' and sys_platform != 'linux')" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/8d/a9/549e51e9b1b2c9b854fd761a1d23df0ba2fbc60bd0c13b489ffa518cfcb7/triton-3.3.1-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b74db445b1c562844d3cfad6e9679c72e93fdfb1a90a24052b03bb5c49d1242e", size = 155600257, upload-time = "2025-05-29T23:39:36.085Z" }, @@ -3007,7 +3048,7 @@ name = "winrt-runtime" version = "3.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "sys_platform != 'darwin' or extra != 'extra-6-gently-torch-cpu' or (extra == 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu')" }, + { name = "typing-extensions", marker = "(platform_machine != 'aarch64' and sys_platform == 'linux') or (sys_platform != 'darwin' and sys_platform != 'linux') or (sys_platform == 'darwin' and extra == 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu') or (sys_platform == 'linux' and extra == 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/16/dd/acdd527c1d890c8f852cc2af644aa6c160974e66631289420aa871b05e65/winrt_runtime-3.2.1.tar.gz", hash = "sha256:c8dca19e12b234ae6c3dadf1a4d0761b51e708457492c13beb666556958801ea", size = 21721, upload-time = "2025-06-06T14:40:27.593Z" } wheels = [ @@ -3027,7 +3068,7 @@ name = "winrt-windows-devices-bluetooth" version = "3.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "winrt-runtime", marker = "sys_platform != 'darwin' or extra != 'extra-6-gently-torch-cpu' or (extra == 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu')" }, + { name = "winrt-runtime", marker = "(platform_machine != 'aarch64' and sys_platform == 'linux') or (sys_platform != 'darwin' and sys_platform != 'linux') or (sys_platform == 'darwin' and extra == 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu') or (sys_platform == 'linux' and extra == 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b2/a0/1c8a0c469abba7112265c6cb52f0090d08a67c103639aee71fc690e614b8/winrt_windows_devices_bluetooth-3.2.1.tar.gz", hash = "sha256:db496d2d92742006d5a052468fc355bf7bb49e795341d695c374746113d74505", size = 23732, upload-time = "2025-06-06T14:41:20.489Z" } wheels = [ @@ -3047,7 +3088,7 @@ name = "winrt-windows-devices-bluetooth-advertisement" version = "3.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "winrt-runtime", marker = "sys_platform != 'darwin' or extra != 'extra-6-gently-torch-cpu' or (extra == 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu')" }, + { name = "winrt-runtime", marker = "(platform_machine != 'aarch64' and sys_platform == 'linux') or (sys_platform != 'darwin' and sys_platform != 'linux') or (sys_platform == 'darwin' and extra == 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu') or (sys_platform == 'linux' and extra == 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/06/fc/7ffe66ca4109b9e994b27c00f3d2d506e6e549e268791f755287ad9106d8/winrt_windows_devices_bluetooth_advertisement-3.2.1.tar.gz", hash = "sha256:0223852a7b7fa5c8dea3c6a93473bd783df4439b1ed938d9871f947933e574cc", size = 16906, upload-time = "2025-06-06T14:41:21.448Z" } wheels = [ @@ -3067,7 +3108,7 @@ name = "winrt-windows-devices-bluetooth-genericattributeprofile" version = "3.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "winrt-runtime", marker = "sys_platform != 'darwin' or extra != 'extra-6-gently-torch-cpu' or (extra == 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu')" }, + { name = "winrt-runtime", marker = "(platform_machine != 'aarch64' and sys_platform == 'linux') or (sys_platform != 'darwin' and sys_platform != 'linux') or (sys_platform == 'darwin' and extra == 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu') or (sys_platform == 'linux' and extra == 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/44/21/aeeddc0eccdfbd25e543360b5cc093233e2eab3cdfb53ad3cabae1b5d04d/winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1.tar.gz", hash = "sha256:cdf6ddc375e9150d040aca67f5a17c41ceaf13a63f3668f96608bc1d045dde71", size = 38896, upload-time = "2025-06-06T14:41:22.687Z" } wheels = [ @@ -3087,7 +3128,7 @@ name = "winrt-windows-devices-enumeration" version = "3.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "winrt-runtime", marker = "sys_platform != 'darwin' or extra != 'extra-6-gently-torch-cpu' or (extra == 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu')" }, + { name = "winrt-runtime", marker = "(platform_machine != 'aarch64' and sys_platform == 'linux') or (sys_platform != 'darwin' and sys_platform != 'linux') or (sys_platform == 'darwin' and extra == 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu') or (sys_platform == 'linux' and extra == 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/9e/dd/75835bfbd063dffa152109727dedbd80f6e92ea284855f7855d48cdf31c9/winrt_windows_devices_enumeration-3.2.1.tar.gz", hash = "sha256:df316899e39bfc0ffc1f3cb0f5ee54d04e1d167fbbcc1484d2d5121449a935cf", size = 23538, upload-time = "2025-06-06T14:41:26.787Z" } wheels = [ @@ -3107,7 +3148,7 @@ name = "winrt-windows-devices-radios" version = "3.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "winrt-runtime", marker = "sys_platform != 'darwin' or extra != 'extra-6-gently-torch-cpu' or (extra == 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu')" }, + { name = "winrt-runtime", marker = "(platform_machine != 'aarch64' and sys_platform == 'linux') or (sys_platform != 'darwin' and sys_platform != 'linux') or (sys_platform == 'darwin' and extra == 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu') or (sys_platform == 'linux' and extra == 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/5e/02/9704ea359ad8b0d6faa1011f98fb477e8fb6eac5201f39d19e73c2407e7b/winrt_windows_devices_radios-3.2.1.tar.gz", hash = "sha256:4dc9b9d1501846049eb79428d64ec698d6476c27a357999b78a8331072e18a0b", size = 5908, upload-time = "2025-06-06T14:41:44.868Z" } wheels = [ @@ -3127,7 +3168,7 @@ name = "winrt-windows-foundation" version = "3.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "winrt-runtime", marker = "sys_platform != 'darwin' or extra != 'extra-6-gently-torch-cpu' or (extra == 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu')" }, + { name = "winrt-runtime", marker = "(platform_machine != 'aarch64' and sys_platform == 'linux') or (sys_platform != 'darwin' and sys_platform != 'linux') or (sys_platform == 'darwin' and extra == 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu') or (sys_platform == 'linux' and extra == 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/0c/55/098ce7ea0679efcc1298b269c48768f010b6c68f90c588f654ec874c8a74/winrt_windows_foundation-3.2.1.tar.gz", hash = "sha256:ad2f1fcaa6c34672df45527d7c533731fdf65b67c4638c2b4aca949f6eec0656", size = 30485, upload-time = "2025-06-06T14:41:53.344Z" } wheels = [ @@ -3147,7 +3188,7 @@ name = "winrt-windows-foundation-collections" version = "3.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "winrt-runtime", marker = "sys_platform != 'darwin' or extra != 'extra-6-gently-torch-cpu' or (extra == 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu')" }, + { name = "winrt-runtime", marker = "(platform_machine != 'aarch64' and sys_platform == 'linux') or (sys_platform != 'darwin' and sys_platform != 'linux') or (sys_platform == 'darwin' and extra == 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu') or (sys_platform == 'linux' and extra == 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ef/62/d21e3f1eeb8d47077887bbf0c3882c49277a84d8f98f7c12bda64d498a07/winrt_windows_foundation_collections-3.2.1.tar.gz", hash = "sha256:0eff1ad0d8d763ad17e9e7bbd0c26a62b27215016393c05b09b046d6503ae6d5", size = 16043, upload-time = "2025-06-06T14:41:53.983Z" } wheels = [ @@ -3167,7 +3208,7 @@ name = "winrt-windows-storage-streams" version = "3.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "winrt-runtime", marker = "sys_platform != 'darwin' or extra != 'extra-6-gently-torch-cpu' or (extra == 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu')" }, + { name = "winrt-runtime", marker = "(platform_machine != 'aarch64' and sys_platform == 'linux') or (sys_platform != 'darwin' and sys_platform != 'linux') or (sys_platform == 'darwin' and extra == 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu') or (sys_platform == 'linux' and extra == 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/00/50/f4488b07281566e3850fcae1021f0285c9653992f60a915e15567047db63/winrt_windows_storage_streams-3.2.1.tar.gz", hash = "sha256:476f522722751eb0b571bc7802d85a82a3cae8b1cce66061e6e758f525e7b80f", size = 34335, upload-time = "2025-06-06T14:43:23.905Z" } wheels = [ diff --git a/ux-prototype/MIGRATION-PLAN.md b/ux-prototype/MIGRATION-PLAN.md new file mode 100644 index 00000000..8e906515 --- /dev/null +++ b/ux-prototype/MIGRATION-PLAN.md @@ -0,0 +1,102 @@ +# Gently web UI → agent-first paradigm: migration plan + +Strangler-fig migration of the **existing** stack (FastAPI + Jinja2 + vanilla-JS, `gently/ui/web/`). **No SPA rewrite.** Everything new is gated behind a `GENTLY_UX_V2` flag and layered onto the seams that already exist. Target paradigm is the prototype in `ux-prototype/landing.html`. + +## Why this is cheap (the load-bearing discoveries) + +- **The structured-ask protocol already exists as data.** The agent emits `{type:'choice_request', request_id, choice_data:{question, options[], _type, allow_multiple}}` over `/ws/agent` (`conversation.py:617`, `bridge.stream_response:648`); the client replies `{type:'choice_response', request_id, selected}` (`agent_ws.py:769`). "One payload, two renderers" = add a `_kind` discriminator + factor `agent-chat.js renderChoice` (L356-390) into a pure `buildAskCard()` + a second mount. **Not a protocol rewrite.** +- **There's already an inference-first precedent**: `bridge.bootstrap_resolution_picker` builds inferred pickers in-memory from candidates without persisting. Plan-mode's draft-first flow models on it. +- **One init chokepoint**: `switchTab(name)` (`app.js:60-103`) is the *only* caller of every tab's manager init. The new shell **calls** it for each region reveal — never reimplements activation. +- **Two separate sockets**: `/ws` (telemetry + server `EventBus` fan-out) and `/ws/agent` (chat + asks). They have different lifecycles — the context surface (Phase 4) rides `/ws`, the ask-stage rides `/ws/agent`. + +## Coexistence (how old + new run side by side) + +A Jinja2 flag: `pages.py GET /` passes `ux_v2` (new `GENTLY_UX_V2` setting, default off) into `index.html`. The template keeps **both** the current 8-tab markup and the new grouped-rail markup, mutually exclusive via `{% if ux_v2 %}` + a `body.ux-v2` class. New JS modules (`status-store.js`, `ask-stage.js`, `shell.js`, `context-surface.js`) load on every page but **no-op without `body.ux-v2`**, so they can't regress v1. Same URL, same shell, same uvicorn process. Flip default-on after a soak (Phase 6), then delete v1 markup. Both UIs read the same state objects and sockets, so v1/v2 can be compared side-by-side on identical live data. + +> CSS hazard: `main.css` has **duplicate** `.tab`/`.tab-content`/`.status-dot` rulesets (~L547 and ~L2892). Consolidate or strictly scope v2 under `body.ux-v2` **before** Phase 2 touches nav CSS. + +## Phase sequence + +| # | Phase | Ships | Flag | Depends | +|---|-------|-------|------|---------| +| 0 | Bug-fix beachhead: sticky status store + idle-telemetry | Status unification + quiet idle channel, **to prod now** | none | — | +| 1 | Dual-render the ask protocol + correct clear-signal | The paradigm enabler | ux_v2 | 0 | +| 2 | Shell unfold + grouped nav + session-context strip | The calm welcome→workspace | ux_v2 | 1 | +| 3a | Inference-first plan mode **backend** (headless) | Draft-from-strain + per-field provenance | — | 2 | +| 3b | Inference-first plan mode **UI** (`plan_confirm` renderer) | Draft renders with provenance | ux_v2 | 3a | +| 4 | Co-editable FileContextStore surface + proactive cards | Shared visibility (beliefs/attention/uncertainty) | ux_v2 | 3b | +| 5 | Carve per-embryo tactical Experiment view out of `embryos.js` | The tactical view mount (contents TBD) | ux_v2 | 4 | +| 6 | Default-on flip + v1 deletion | Irreversible cutover, isolated/soaked | flip | 5 | + +## Phase detail + +### Phase 0 — Bug-fix beachhead (no flag, pure value) +- **Single sticky/replaying `ConnectionStatus` store** (`status-store.js`) holding `{gentlyConnected, microscopeConnected, agentConnected}` and emitting `CONNECTION_STATUS`. Must **replay last state to late subscribers** or bug #1 just moves. +- **Bug #1**: header pill (`updateTopLevelDot` `app.js:562`), home line (`home.js updateStatus:136` — today reads `state.connected` once, the literal "Offline while connected" bug), and dock dot all **subscribe** and re-render on every event. +- **Bug #3 (measure first)**: code shows **15s** polls (not 1-2s); the real idle cost is likely the ~5Hz `DEVICE_STATE_UPDATE` WS stream. **Ship unconditionally:** decouple `#events-count` from `DEVICE_STATE_UPDATE`/`BOTTOM_CAMERA_FRAME`. **Gate polls only if** measurement implicates them; if it's the SSE stream, coalesce/backoff in `device_state_monitor.py`. Gate on a stable `DevicesManager.active` flag, **not** switchTab internals (Phase 2 rewrites those). +- **Bug #2**: capture the prototype's correct multi-select contract (Continue mounts with disabled state *derived from current selection*) for `buildAskCard`. +- Files: `status-store.js` (new), `websocket.js`, `app.js`, `home.js`, `agent-chat.js`, `devices.js`, `events.js`, `index.html`. +- Verify: cold + reload + kill each socket independently — all three indicators agree within one handshake; kill device layer → microscope badge flips in 15s, gently stays Online. + +### Phase 1 — Dual-render the ask protocol (the enabler) +- `_kind` always-present discriminator + additive `_surface` ∈ {transcript,stage,both} (default transcript so the Ink TUI/older clients are unaffected). Set on each payload the bridge builds. +- Factor `renderChoice` → pure `buildAskCard()` + a module-level `answered` Set keyed by **opaque** `request_id` (never parse a prefix — ids mix `req_N` and `resolve_*_`). New `ask-stage.js` renders the same card into `#ask-stage`. +- **BLOCKER fixed — clear signal**: fire `ASK_CLEARED` the instant a `choice_response` is sent (plus on cancel/error/control-loss/socket-close), **not** on `stream_end` — an in-turn ask suspends on `asend` (`bridge.py:657`) and `stream_end` only arrives *after* the answer; a cancelled turn emits none. +- **BLOCKER fixed — dismiss vs control**: `agent_ws.py:772` silently drops non-holder responses. Stage renders read-only when `!hasControl`; only the holder answers/dismisses; a holder's escape posts a real empty `choice_response` so the turn-lock releases. +- **BLOCKER fixed — leak cleanup**: pop orphaned `_choice_futures` on holder-change and answerer-disconnect, not only last-client (`agent_ws.py:889`). +- Add the **free-text "Something else"** affordance web cards lack today (`bridge._dispatch_resolution_pick:462` already routes unknown selections to LLM resolution). +- Verify: trigger the session-open bootstrap picker — appears in chat **and** `#ask-stage`; answering either clears both; cancel a turn mid-ask → stage clears and next turn works; lose control mid-ask → stage goes read-only. + +### Phase 2 — Shell unfold + grouped nav + session strip +- `shell.js` screen-state machine sets `body[data-screen]` (welcome|plan|standalone|shell) and **calls `switchTab()`** for every reveal (+ a dev assertion if a region shows without its init). +- Grouped left rail (Now / Library / System) → each item maps to an existing `data-tab` id via `switchTab`. Session-context strip reuses `ExperimentStrip` (fix stale `switchTab('tasks')` at `experiment-strip.js:176`), fed by `/api/experiments/current/strategy`, reading live status from the Phase 0 store. +- **Real routing**: replace the consume-once hash (`app.js:633` `replaceState` to `/`) with deliberate URL/state sync so refresh/back-button + the `/review`→`/#sessions` redirects resolve correctly. +- Decouple welcome→plan from the brittle `togglePanel(true)+setTimeout(250ms,'/wizard')` (`home.js:159`): the picker renders in `#ask-stage` on connect, driven by the bootstrap `choice_request`. +- **Resume**: replace the `window.location.href='/'` hard reload on `session_changed` (`websocket.js:147`, `review.js:108`) with in-place re-hydration (jarring otherwise). +- Verify: cold load → calm welcome; choosing a plan unfolds (no hard cut); each rail item fires its manager's init side-effect (verify the side-effect, not just visibility). + +### Phase 3a — Inference-first plan backend (headless-testable) +- Flip the plan-mode prompt from ask-first to **infer-first** (`plan_mode_system_prompt.tex`, `harness/plan_mode/prompt.py`): arrive with a draft, ask only for genuine gaps / low-confidence / consequential confirmations. +- Deterministic **strain→channel** inference in `research.py`: parse genotype from `search_strains` (TagRFP→561, GFP→488), attach source ref. **Must degrade to "confirm" — never fabricate a wavelength.** Network-dependent (WormBase REST + CGC scraping), so the degrade path is load-bearing. +- **Per-field provenance** (`model.py:186`): `ImagingSpec` fields are flat scalars with no per-field source (`references[]` is on `PlanItem`, not the spec). Add a parallel `{field → {source, confidence, citation}}` map; reuse the existing `Confidence` enum. +- **Drafts stay in-memory** (like `bootstrap_resolution_picker`), materialized via `create_campaign`/`create_plan_item` only on explicit confirm — avoids a `PlanItemStatus` enum change *and* orphan-folder cleanup. +- Use `gap_assessment.assess_gaps()` to *select* which gaps to ask (don't re-enable the deliberately-disabled multi-question wizard; note `conversation_weight` short-circuits after onboarding). +- Verify (no UI): known strain → draft with channels pre-filled + per-field source/confidence; unknown/offline → "confirm channel", never fabricated; reject → no folder written; confirm → materializes and `GET /api/campaigns/{id}/document` returns the tree. + +### Phase 3b — Inference-first plan UI +- `_kind:'plan_confirm'` ask: bridge emits the in-memory draft as `choice_data`; `ask-stage.js` renders cards with per-field source tags + confidence + edit affordances; chat shows a **compact reference line** (not a duplicate) so the surfaces can't drift. +- Extend `renderSpec` (`agent-chat.js:403`, today a flat key→value table) with a **source column**. +- Confirm posts the same `choice_response`; bridge materializes; stage clears via `ASK_CLEARED`. + +### Phase 4 — Co-editable context surface + proactive cards +- **BLOCKER fixed — real store change**: `FileContextStore` has no event bus. (1) add `CONTEXT_UPDATED` to the closed `EventType` enum (`core/event_bus.py`); (2) inject a bus/callback into the store; (3) emit a **single coalesced** event from each mutator (`add_expectation:1880`, `add_watchpoint:1933`, `add_question:1980`, `update_embryo_understanding:2049`, …); (4) wire in `launch_gently.py:497`. +- New `routes/context.py`: **read** side models on `campaigns.py` (`_serialize`); **write** side uses `Depends(require_control)` from `data.py`/`sessions.py` (NOT the `campaigns.py` mesh/account auth) so a viewer can't mutate the agent's mind. +- Live updates over `/ws` (telemetry socket) → `websocket.js:117` → `context-surface.js`. **Push on change, never poll** (`load_active` scans ~50 observations + YAML). +- `context-surface.js` renders beliefs/attention/uncertainty as a calm panel in the Now region with inline edit/resolve (disabled when `!hasControl`). +- **Proactive cards**: wire watchpoint/question creation + the existing wake-router `origin:'wake'` approvals (`agent.py:1079`) to surface prominent `#ask-stage` cards — real backing for the prototype's attention card, no new mechanism. + +### Phase 5 — Carve the tactical Experiment view (behind the flag, before the flip) +- Make Experiment a distinct renderer over `EmbryosManager.state` + the strategy snapshot rather than overloading the 4556-line `embryos.js`. **Preserve `reconcileWithServerState`/`clearAllState` as the contract.** Don't over-specify contents yet — it's a mount point. +- **Remove the `STUB_STRATEGY` fallback** (`experiment-overview.js:14` + the "mockup · stubbed data" badge) → real loading/empty state. Production must never render stubs. +- Stays behind the flag through its own soak so a reconciliation regression is caught before the irreversible flip. + +### Phase 6 — Default-on flip + v1 cleanup (irreversible, isolated) +- Flip `GENTLY_UX_V2` default-on after soak; delete v1 `{% else %}` nav markup, superseded v1 status writers, and the dead/duplicate `.tab` CSS. Isolated from Phase 5 so the high-regression carve-out never coincides with deletion. + +## Blockers the adversarial pass caught (now folded in) +1. **Clear signal must follow the choice lifecycle, not the stream lifecycle** (asend suspension; cancelled turns emit no `stream_end`). +2. **Dismiss vs control gate** — non-holder responses are silently dropped; only the holder dismisses; server cleans orphaned futures on holder-change/disconnect. +3. **Phase 4 store change is real, not free** — new `EventType`, bus injection, coalesced emit from every mutator, launch wiring. +4. Two answer paths (`_choice_futures` vs bridge-owned `_pending_import`) → client-authoritative `answered` Set + bridge idempotency guard. +5. `switchTab` is the sole init chokepoint → shell calls it, never reimplements. +6. Per-field provenance doesn't exist today → added in 3a. +7. Phase 3 split into headless backend (3a) + UI (3b); embryos.js carve-out isolated in Phase 5. + +## Open decisions (yours) +- **Measure bug #3 first**: is idle chatter the ~5Hz `DEVICE_STATE_UPDATE` stream or the 15s polls? Determines the lever (coalesce in `device_state_monitor.py` vs gate polls). +- **Status**: client-computed sticky store (chosen for Phase 0) vs a single server-emitted status object over `/ws`. +- **Routing**: History API vs hash-fragment for region state (keeping the `/review`→`/#sessions` redirects working without reload). +- **Co-edit concurrency**: optimistic last-write-wins vs per-item version/lock, given the agent mutates the same YAML. +- **Slash-command demotion**: re-render `/status`,`/embryos` rich content as affordances vs button-per-command. +- **Per-field provenance schema**: parallel map on `ImagingSpec` vs sibling dataclass vs extending `PlanItem.references[]`. +- **Experiment tactical view contents** — deferred to Phase 5 design. diff --git a/ux-prototype/landing.html b/ux-prototype/landing.html new file mode 100644 index 00000000..59e7bd94 --- /dev/null +++ b/ux-prototype/landing.html @@ -0,0 +1,674 @@ + + + + + +Gently — entry paradigm sketch + + + +
    +
    +
    Gently
    +
    + + Scope ready · 36.9 °C · stage idle +
    +
    + +
    + +
    +
    +
    +
    Good evening.
    What are we doing today?
    +
    + +
    + + + +
    + +
    + +
    + + +
    +
    +
    + + +
    +
    +
    +
    +
    + Gently + +
    +
    +
    +
    +
    + + +
    +
    +
    +
    +
    + +
    +

    The plan

    +

    assembling from your choices

    +
    +
    Nothing yet — pick above and watch it fill in.
    +
    +
    +
    +
    +
    + + +
    +
    +
    +

    Quick look

    +

    I'll take one careful volume right where the stage is now — nothing scheduled, nothing committed.
    (We'll design this surface next — it's a stub for now.)

    +
    +
    +
    + + +
    +
    +
    + LIVE + run + + 0h 12m elapsed · next 1:43 +
    + + + +
    +
    +
    ⚠ Needs you
    +
    Embryo 3 has been quiet for 40 min — past its expected division window. Keep waiting, or flag it for you?
    +
    + + + +
    +
    + +

    Embryos · 3 tracked

    representative — the embryo-wise tactical view is yours to define
    +
    +
    Embryo 1on track
    4-cell · imaging normally
    +
    Embryo 2dividing
    sped up to every 30 s
    +
    Embryo 3stalled
    40 min quiet · flagged above
    +
    +
    +
    +
    + +
    +
    + + + +