diff --git a/.gitignore b/.gitignore index f9a50443..34a7c3b7 100644 --- a/.gitignore +++ b/.gitignore @@ -149,3 +149,4 @@ gently/ui/tui/dist/ # Stray local storage: GENTLY_STORAGE_PATH default (D:\Gently3) resolves # literally to ./D:/ under the repo on Linux. Not data we track. /D:/ +.superpowers/ 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/specs/2026-06-28-operations-tab-design.md b/docs/superpowers/specs/2026-06-28-operations-tab-design.md new file mode 100644 index 00000000..d301e312 --- /dev/null +++ b/docs/superpowers/specs/2026-06-28-operations-tab-design.md @@ -0,0 +1,126 @@ +# Design: Operations — the agent-authored Operation Plan (sub-project D, v3) + +Status: **DRAFT for review.** Redesigned 2026-06-28 after two from-scratch iterations (swimlane → +operation-spine) and a foundational rethink the user prompted: tactics are richer than a linear plan, +and the tactical picture should be a **typed output the agent authors per experiment**, not a backend +reconstruction. Grounded by an architecture recon (every piece needed already exists). +Base branch: `feature/operations-tab` (off C). Keep: Experiment→Operations rename + the burst-`phase` +event. Discarded: the swimlane band (reverted) and the single-linear-spine assumption. + +## 0. The two decisions this encodes (pending user confirm) +1. **Source of truth = the agent.** The agent emits a typed **Operation Plan** (the tactics it has + planned / is running / has run, each typed) via forced `tool_choice` — gently's existing house + pattern (Ask-the-notebook, hatching detector, verifier, classifier all do this; `@tool` + auto-generates schemas). The UI renders the agent's plan **⊕ live telemetry binding**. We do NOT + keep extending `strategy_snapshot`'s timeline-replay *reconstruction* to guess tactics — the agent + owns tactic identity; the backend supplies live values. +2. **Open, grounded ontology.** A tactic is far broader than a phased protocol; the schema covers the + real operational modes (below). No round-robin primitive (the scheduler is priority-queue + "most-overdue"; "round" is legacy naming). + +## 1. The Operation Plan (typed agent output) + +``` +operation_plan = { + session_id, title, goal, # the agent's framing of the run + tactics: [ tactic ], + updated_at, updated_reason # patched on each tactic transition +} +tactic = { + id, name, kind, state, scope, + rationale, # the agent's WHY (no self-rated confidence) + structure, live_bind, relations +} +kind : 'standing_timelapse' | 'reactive_monitor' | 'scripted_protocol' | 'exclusive_burst' | 'oneshot' | 'custom' +state : 'planned' | 'active' | 'done' +scope : { mode:'global'|'embryos', embryo_ids?:[...] } +structure : # by kind + standing_timelapse → { cadence_s, per_embryo:[{embryo_id, cadence_phase:'normal'|'fast'|'burst'|'paused', interval_s}] } + reactive_monitor → { watch, reaction, status:'armed'|'watching'|'fired' } # onset, saturation, burst-on-structure, hatching-detect + scripted_protocol → { phases:[{name, state:'done'|'active'|'todo', count}] } # before/during/after + exclusive_burst → { frames, mode, phase? } + oneshot|custom → { note } +live_bind : [ 'temperature' | 'current_burst' | 'cadence' | 'signal' | ... ] # which telemetry fills readouts/progress +relations : { after?:[tactic_id], layered_on?:[tactic_id], triggers?:[tactic_id] } # concurrency + composition +live : { # agent-authored display hints + updater-merged telemetry + readouts : [{label, value, sub?, bar?, bind?}] # instrument strip entries (e.g. {label:"cadence", value:"120 s"}) + phases : [{name, state:'done'|'active'|'todo', count, pips}] # phase stepper for scripted_protocol / exclusive_burst + # flat bound keys merged in by the updater as telemetry arrives: + # request_id, sustained_hz, setpoint, locked, last_fired, new_phase, ... +} +``` + +The `live` object has two agent-authored sub-keys: `readouts` (a list of instrument-strip rows the agent populates at declaration time) and `phases` (a phase stepper list for scripted or burst tactics). The operation-plan updater additionally merges flat keys (`request_id`, `sustained_hz`, `setpoint`, `locked`, `last_fired`, `new_phase`, and any other telemetry bound via `live_bind`) directly onto `live` as events arrive — the agent seeds these at declaration if the value is already known, or leaves them absent for the updater to fill in. + +This single typed object expresses a regular timelapse (standing), an async per-embryo run (standing +with per-embryo cadence — *real* in the orchestrator), a reactive monitor incl. **hatching detection** +(reactive), a temp-change protocol (scripted, phased), an exclusive burst, and arbitrary `custom` +tactics — because the **agent declares each one's kind/structure**. + +## 2. Architecture (producer → model → consumer, all reusing existing paths) + +### 2.1 Producer — the `operation_plan` typed tool (agent) +A new agent tool `declare_operation_plan(...)` (or `update_operation_plan`) in `gently/app/tools/`, +schema auto-generated by the `@tool` decorator (or a forced literal schema like `notebook_ask.ASK_TOOL`). +The agent calls it at experiment planning and **patches it on each tactic transition** (begin / end / +phase-change). Prompt guidance (in `harness/prompts/templates.py`, beside the monitoring-mode decision +table) tells the agent to keep its Operation Plan current as it operates. + +### 2.2 Model — store the plan in `FileContextStore` +A new domain beside `session_intents`/`active/`: `agent/operation_plans/{session_id}.yaml`. Add +`set_operation_plan` / `get_operation_plan` to `FileContextStore` and fire the existing +`CONTEXT_UPDATED` (the store's `_notify_*` already broadcasts to `/ws`). The plan is an agent-authored +typed artifact exactly like campaigns/plan-items already are. + +### 2.3 Consumer — Operations renders plan ⊕ live binding +- Route `GET /api/operation_plan/{session_id}` (mirror `routes/context.py`) returns the plan. +- The renderer (the **operation spine**, generalized): one node per tactic in used/active/queued state. + The active node blooms open and **binds live telemetry** by `live_bind` keys — temperature gauge, + current burst, phase progress, cadence — sourced from the orchestrator `get_status` / + `strategy_snapshot` / the tactic events C emits. Per-embryo cadence (standing/async) shows as a + compact strip in the standing-tactic node. Reactive monitors render watch+reaction+status; scripted + protocols render the before/during/after stepper. +- Live refresh: subscribe to `CONTEXT_UPDATED` (plan changed) + the tactic telemetry events + (`TEMP_PROTOCOL_*`, `BURST_*`, `EMBRYO_CADENCE_CHANGED`, `POWER_RAMP_STEP`, `TEMPERATURE_SETPOINT_CHANGED`) + → debounced refetch+render. (Mirrors `experiment-strip.js` / `context-surface.js`.) +- **Audit fixes** (from the design audit) baked into the renderer: queued reads as a cocked instrument + (blue thread + "next" marker), the active phase/status is the headline, flatten the card (no + card-in-card), the active row's whole left column is amber + colored left edge per state, copy = + "queued", keep mono instrument values + domain copy. + +### 2.4 Scenario test mode (your "test it for real experiments" + "different tactic cases") +A repo scenario library of **Operation Plan fixtures** (the prototyped six cases, now as plan objects): +temp-strain, expression-onset, pre-hatching/hatching-detect, transmission survey, decided-plan, idle — +plus async-multi-embryo. `?scenario=` (dev-gated) renders a fixture with no agent/fetch, so +Operations is developable/auditable offline against any tactic case. The fixtures double as the test +corpus + a Chrome-MCP audit target. + +## 3. Data flow +agent → `declare_operation_plan` (forced typed) → `FileContextStore` → `CONTEXT_UPDATED` → `/ws` → +Operations refetch → `/api/operation_plan` (the plan) **⊕** live telemetry (`get_status` / +`strategy_snapshot` / events) bound by `live_bind` → spine renderer. Scenario mode: `?scenario=` → +fixture plan → same renderer. + +## 4. Why agent-authored over backend reconstruction +`strategy_snapshot.py` already reconstructs the picture by replaying `timeline.jsonl` and re-instantiating +modes — but every new tactic kind needs new reconstruction heuristics, and it can only ever *infer* +intent. The agent *is* the composer; it should *declare* its tactics (authoritative, open-ontology, +self-describing). The backend keeps providing **live values** (what it's good at), not tactic identity. + +## 5. Convergence with G (tactics library) +Once tactics are typed, agent-authored objects, **G is "save/reuse these typed tactics"** and D is +"render the active plan of them." This spec is the shared substrate for both — settling it unblocks +both sub-projects. + +## 6. Testing +- Store: `set/get_operation_plan` round-trip + `CONTEXT_UPDATED` fired. +- Tool: the forced `declare_operation_plan` returns a schema-valid plan (mirror the notebook_ask test). +- Route: `/api/operation_plan/{session}` returns the stored plan; 404/empty handled. +- Renderer: `node --check` + Chrome-MCP audit across the scenario fixtures (all tactic kinds render). +- Live binding: a unit test that telemetry maps onto a tactic's `live_bind` keys. +- Rig-deferred: the real agent emitting a live plan during an experiment. + +## 7. Out of scope (v3) +- Full multi-embryo concurrent lanes (v1 = one standing mode + layered tactics + a per-embryo strip). +- G's authoring/save UI (this spec defines the tactic object G will reuse). +- Migrating away from `strategy_snapshot` for the legacy swimlane consumers (Operations is the new view). diff --git a/gently/app/agent.py b/gently/app/agent.py index 02ec700a..3aa4989d 100644 --- a/gently/app/agent.py +++ b/gently/app/agent.py @@ -212,6 +212,8 @@ def __init__( self.device_state_monitor = None # Session-scoped temperature sampler — polls device layer, persists readings. self.temperature_sampler = None + # Bus-subscriber that transitions plan tactics when execution events fire. + self.operation_plan_updater = 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 = None @@ -465,6 +467,17 @@ 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, + ) + + 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]) @@ -638,9 +651,7 @@ def _init_timelapse_orchestrator(self): store=self.store, claude_client=self.claude, temperature_provider=lambda: ( - self.temperature_sampler.latest - if self.temperature_sampler - else None + self.temperature_sampler.latest if self.temperature_sampler else None ), ) except Exception as e: @@ -848,6 +859,19 @@ async def start_viz_server( 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. @@ -897,6 +921,12 @@ async def stop_viz_server(self): 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 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 b741e57d..7dc3583f 100644 --- a/gently/app/orchestration/exclusive.py +++ b/gently/app/orchestration/exclusive.py @@ -102,6 +102,7 @@ def __init__( 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 @@ -109,6 +110,7 @@ def __init__( 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 @@ -131,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, }, ) @@ -233,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, }, ) diff --git a/gently/app/orchestration/temperature_protocol.py b/gently/app/orchestration/temperature_protocol.py index 60058133..e25ee148 100644 --- a/gently/app/orchestration/temperature_protocol.py +++ b/gently/app/orchestration/temperature_protocol.py @@ -1,8 +1,12 @@ -import asyncio, logging +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() @@ -11,7 +15,8 @@ async def wait_for_temperature_lock(client, timeout_s: float, poll_s: float = 2. try: resp = await client.get_temperature() except Exception as exc: - logger.warning("wait_for_temperature_lock poll failed: %s", exc); resp = {} + 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: @@ -19,34 +24,66 @@ async def wait_for_temperature_lock(client, timeout_s: float, poll_s: float = 2. 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): +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 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 = 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 + 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}) + 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() + 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: @@ -54,16 +91,27 @@ async def one_burst(phase): except Exception: st = "" if "LOCKED" in st: - locked = True; break + 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 + cancelled = True + raise except Exception as exc: - error = str(exc); logger.exception("temp-change burst protocol failed") + 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}) + 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 c640f926..0645d77f 100644 --- a/gently/app/orchestration/timelapse.py +++ b/gently/app/orchestration/timelapse.py @@ -2062,6 +2062,7 @@ def queue_burst( 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``. @@ -2102,6 +2103,7 @@ def queue_burst( 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 index e762534c..0995d1f2 100644 --- a/gently/app/temperature_sampler.py +++ b/gently/app/temperature_sampler.py @@ -6,14 +6,15 @@ 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 -from gently.core.event_bus import get_event_bus, EventType logger = logging.getLogger(__name__) diff --git a/gently/app/tools/__init__.py b/gently/app/tools/__init__.py index 16d7aebb..b3b31d1b 100644 --- a/gently/app/tools/__init__.py +++ b/gently/app/tools/__init__.py @@ -38,6 +38,7 @@ led_tools, light_source_tools, memory_tools, + operation_plan_tools, plan_execution_tools, resolution_tools, session_tools, @@ -85,6 +86,7 @@ def register_all_tools(): "led_tools", "light_source_tools", "memory_tools", + "operation_plan_tools", "plan_execution_tools", "resolution_tools", "session_tools", diff --git a/gently/app/tools/acquisition_tools.py b/gently/app/tools/acquisition_tools.py index 6e029ad4..bfc892ea 100644 --- a/gently/app/tools/acquisition_tools.py +++ b/gently/app/tools/acquisition_tools.py @@ -213,10 +213,9 @@ async def acquire_volume( }, } from gently.app.temperature_sampler import temperature_stamp as _ts + _temp_stamp = _ts( - getattr( - getattr(agent, "temperature_sampler", None), "latest", None - ) + getattr(getattr(agent, "temperature_sampler", None), "latest", None) ) if _temp_stamp is not None: acq_metadata["temperature"] = _temp_stamp 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/temperature_protocol_tools.py b/gently/app/tools/temperature_protocol_tools.py index 678b08b8..80d6166a 100644 --- a/gently/app/tools/temperature_protocol_tools.py +++ b/gently/app/tools/temperature_protocol_tools.py @@ -8,7 +8,11 @@ import asyncio -from gently.harness.tools.helpers import ctx_get, require_agent, require_microscope, require_timelapse_orchestrator +from gently.harness.tools.helpers import ( + require_agent, + require_microscope, + require_timelapse_orchestrator, +) from gently.harness.tools.registry import ToolCategory, ToolExample, tool @@ -33,7 +37,12 @@ ), 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}, + { + "embryo_id": "embryo_2", + "target_setpoint_c": 15.0, + "bursts_before": 2, + "bursts_after": 2, + }, ), ], ) @@ -43,6 +52,7 @@ async def run_temp_change_burst_protocol_tool( 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. @@ -83,6 +93,14 @@ async def run_temp_change_burst_protocol_tool( "(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, @@ -91,6 +109,7 @@ async def run_temp_change_burst_protocol_tool( frames=frames, bursts_before=bursts_before, bursts_after=bursts_after, + tactic_id=tactic_id, ) ) diff --git a/gently/app/tools/timelapse_tools.py b/gently/app/tools/timelapse_tools.py index d5ec3d76..0c0c45c2 100644 --- a/gently/app/tools/timelapse_tools.py +++ b/gently/app/tools/timelapse_tools.py @@ -267,7 +267,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 +283,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 +300,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 +315,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 +1043,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 +1056,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 +1180,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 +1193,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 0b5db1cd..4df91bb4 100644 --- a/gently/core/event_bus.py +++ b/gently/core/event_bus.py @@ -129,7 +129,9 @@ class EventType(Enum): # 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_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) diff --git a/gently/core/file_store.py b/gently/core/file_store.py index e7fb2174..46c781b2 100644 --- a/gently/core/file_store.py +++ b/gently/core/file_store.py @@ -450,7 +450,8 @@ def append_temperature_sample(self, session_id: str, sample: dict) -> None: _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). + """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. @@ -771,9 +772,7 @@ def get_volume_meta(self, session_id: str, embryo_id: str, timepoint: int) -> di sd = self._session_dir(session_id) if sd is None: return None - meta_path = ( - sd / "embryos" / embryo_id / "volumes" / self._volume_meta_filename(timepoint) - ) + 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]: diff --git a/gently/hardware/dispim/client.py b/gently/hardware/dispim/client.py index 1437a8a8..58746750 100644 --- a/gently/hardware/dispim/client.py +++ b/gently/hardware/dispim/client.py @@ -1014,9 +1014,7 @@ async def stream_lightsheet(self, timeout: float | None = None): 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: + 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: diff --git a/gently/hardware/dispim/device_layer.py b/gently/hardware/dispim/device_layer.py index 0e44ebfa..6173236f 100644 --- a/gently/hardware/dispim/device_layer.py +++ b/gently/hardware/dispim/device_layer.py @@ -171,14 +171,14 @@ def __init__( # 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_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 - 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 + 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. @@ -1022,10 +1022,7 @@ def _ensure_lightsheet_sequence_sync(self) -> None: 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"] - ) + need_restart = not self._ls_seq_started or self._ls_applied.get("exposure") != p["exposure"] if need_restart: if core.isSequenceRunning(): core.stopSequenceAcquisition() @@ -1039,8 +1036,8 @@ def _ensure_lightsheet_sequence_sync(self) -> None: 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 + self._park_lightsheet_sync() # galvo/piezo applied live + self._ensure_lightsheet_sequence_sync() # start / restart on exposure try: from gently.hardware.dispim.devices.acquisition import _safe_obtain except (ImportError, AttributeError): @@ -1083,7 +1080,9 @@ async def _lightsheet_streamer(self): 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) + self._encode_frame_for_stream( + img, self._ls_target_max_dim, self._ls_jpeg_quality + ) if img is not None else None ) diff --git a/gently/harness/memory/file_store.py b/gently/harness/memory/file_store.py index ececeba2..7a9eba4e 100644 --- a/gently/harness/memory/file_store.py +++ b/gently/harness/memory/file_store.py @@ -811,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) # ================================================================== diff --git a/gently/harness/memory/model.py b/gently/harness/memory/model.py index e9e6532d..55bff965 100644 --- a/gently/harness/memory/model.py +++ b/gently/harness/memory/model.py @@ -245,6 +245,14 @@ class ImagingSpec: # 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: diff --git a/gently/harness/prompts/templates.py b/gently/harness/prompts/templates.py index f75fc7a5..957ec0ad 100644 --- a/gently/harness/prompts/templates.py +++ b/gently/harness/prompts/templates.py @@ -222,6 +222,73 @@ """ +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": []} + ] + } +} +``` + +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 +550,8 @@ def build_system_prompt( {REACTIVE_MONITORING_MODES} +{OPERATION_PLAN_GUIDANCE} + {AUTONOMY_AND_ADAPTATION} {USER_INTERACTION_GUIDELINES} diff --git a/gently/ui/web/routes/__init__.py b/gently/ui/web/routes/__init__.py index 66e8842c..e3da4c92 100644 --- a/gently/ui/web/routes/__init__.py +++ b/gently/ui/web/routes/__init__.py @@ -13,11 +13,12 @@ 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 .temperature import create_router as create_temperature_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 .sessions import create_router as create_sessions_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 @@ -39,6 +40,7 @@ def register_all_routes(server): create_context_router, create_notebook_router, create_temperature_router, + create_operation_plan_router, ): router = factory(server) server.app.include_router(router) diff --git a/gently/ui/web/routes/data.py b/gently/ui/web/routes/data.py index cfbeb11c..3844b131 100644 --- a/gently/ui/web/routes/data.py +++ b/gently/ui/web/routes/data.py @@ -482,8 +482,10 @@ async def lightsheet_live_params(payload: dict = Body(...)): # noqa: B008 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")) + 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 @@ -564,7 +566,7 @@ async def stage_move(payload: dict = Body(...)): # noqa: B008 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") + 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 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/temperature.py b/gently/ui/web/routes/temperature.py index 1505714b..90916181 100644 --- a/gently/ui/web/routes/temperature.py +++ b/gently/ui/web/routes/temperature.py @@ -3,6 +3,7 @@ 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 diff --git a/gently/ui/web/static/css/experiment.css b/gently/ui/web/static/css/experiment.css index 9b343378..2033f354 100644 --- a/gently/ui/web/static/css/experiment.css +++ b/gently/ui/web/static/css/experiment.css @@ -634,3 +634,559 @@ 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; +} diff --git a/gently/ui/web/static/js/experiment-overview.js b/gently/ui/web/static/js/experiment-overview.js index 9e7fc221..b7a82f50 100644 --- a/gently/ui/web/static/js/experiment-overview.js +++ b/gently/ui/web/static/js/experiment-overview.js @@ -12,15 +12,69 @@ 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 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. + const [plan, strategy] = await Promise.all([ + this.loadPlan(), + this.loadStrategy() + ]); + this.activePlan = plan; this.activeStrategy = strategy; + 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() { @@ -32,19 +86,85 @@ const ExperimentOverview = { // No active experiment / not ready yet — show the empty state, // never stubbed data. console.warn('[ExperimentOverview] strategy fetch returned', resp.status); - this.isLive = false; return null; } const data = await resp.json(); - this.isLive = true; return data; } catch (e) { console.warn('[ExperimentOverview] strategy fetch error:', e); - this.isLive = false; return null; } }, + // Fetch the agent-authored Operation Plan for the current session. + // Returns the plan object (plan.tactics etc.) or null when unavailable. + 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(); + 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. + _debouncedRefresh() { + if (this._planRefreshTimer) clearTimeout(this._planRefreshTimer); + this._planRefreshTimer = setTimeout(async () => { + this._planRefreshTimer = null; + const plan = await this.loadPlan(); + this.activePlan = plan; + 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; @@ -65,10 +185,12 @@ const ExperimentOverview = { } // Tear down any prior ticker before we blow away the SVG it pointed at. this._stopNowTicker(); - if (!s) { - // No active experiment — a calm empty state, never stubbed data. + // 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 — the imaging tactics (cadence, reactive rules) will appear here once a run is live.
'; + 'No active experiment — rules and monitoring modes will appear here once a run is live.'; return; } try { @@ -76,8 +198,9 @@ const ExperimentOverview = { 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); } console.log('[ExperimentOverview] rendered OK, view=', this.activeView); } catch (err) { @@ -89,20 +212,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) { @@ -111,56 +220,7 @@ 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}`; - } - 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); - } else { - ctx.chipBg.setAttribute('x', 4); - ctx.chipBg.setAttribute('width', textLen); - ctx.chipText.setAttribute('x', 10); - } - }, - _renderOverviewView(root, s) { - root.appendChild(this._renderHeader(s)); - root.appendChild(this._renderModes(s)); - root.appendChild(this._renderModeExpanded(s)); - root.appendChild(this._renderSwimlanes(s)); - }, _renderRulesView(root, s) { // Compact header echoing the session identity @@ -179,48 +239,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)); - } - metaRow.appendChild(elText('span', 'expov-live-badge', 'live')); - 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 @@ -286,803 +304,244 @@ 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' - }); - // 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}`)); - } - 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); - }); + // ================================================================= + // Operation Spine — data-driven plan renderer (replaces swimlanes) + // ================================================================= - wrap.appendChild(svg); - return wrap; + // 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])); }, - _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' - })); - } - - // ---- 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`)); + // 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); } - // 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 - })); - } - }); - - // ---- 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); - } - }); - - 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%' + 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)); }); - 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' - })); - } + return; } - // ---- 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 - } - 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); + 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'); + + const spineNodes = tactics + .map((t, idx) => this._renderOpsTactic(t, idx, firstPlannedIdx, ESC)) + .join(''); + + root.innerHTML = ` +
+
Operations · ${hasActive ? 'live' : 'idle'}
+

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

+
${ESC(plan.session_id || '')}${plan.goal ? ' · ' + ESC(plan.goal) : ''}
+
+ done + in use + queued +
+
${spineNodes}
+
`; + }, + + // Render a single tactic node. + _renderOpsTactic(t, idx, firstPlannedIdx, ESC) { + 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 || ''; + + // Header row: name · target · summary + let inner = ` +
+ ${ESC(t.name)} + ${target ? `${ESC(target)}` : ''} + ${summary ? `${ESC(summary)}` : ''} +
+ ${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('')} +
`; } - // ---- 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 }); + // 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); } - 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]); + + // 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}
`; } } - 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); + + return ` +
+
${seq} · ${stateLabel}${nextBadge ? ' ' + nextBadge : ''}
+
${inner}
+
`; + }, + + // 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)}
` : ''} +
`; + }, + + // 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('')} +
`; } - // 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' - })); - } + // 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}
`; } - // 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)); + + // reactive_monitor → armed/watching/fired status badge. + if (t.kind === 'reactive_monitor') { + const st = (t.structure && t.structure.status) || 'armed'; + return `
${ESC(st)}
`; } - // 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' - }); - 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)}`)); - }); + // exclusive_burst / oneshot / custom — readouts only (already rendered above). + return ''; + }, - // 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 - })); - } - // 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' - }, '■')); + // 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('')} +
`; } - // ---- 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 }); - } - } - // 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]); - }); + if (t.kind === 'standing_timelapse' && t.structure && t.structure.cadence_s) { + return `
cadence · ${ESC(t.structure.cadence_s)}s
`; + } - // 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' - })); - } - } - // 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}%`)); - }); + if (t.kind === 'reactive_monitor' && t.structure && t.structure.watch) { + return `
watch · ${ESC(t.structure.watch)}
`; + } - // 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}`); - } - } - 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' - })); + if ((t.kind === 'oneshot' || t.kind === 'custom') && t.structure && t.structure.note) { + return `
${ESC(t.structure.note)}
`; } - // 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; + + return ''; }, // ----------------------------------------------------------------- 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/strategy_snapshot.py b/gently/ui/web/strategy_snapshot.py index 4948ae9b..9eeb57e1 100644 --- a/gently/ui/web/strategy_snapshot.py +++ b/gently/ui/web/strategy_snapshot.py @@ -774,6 +774,7 @@ def _replay_timeline( "end": None, "frames": int(data.get("frames") or 0), "hz": hz, + "phase": data.get("phase"), } ) diff --git a/gently/ui/web/templates/index.html b/gently/ui/web/templates/index.html index 1b1dba8e..1f09ba06 100644 --- a/gently/ui/web/templates/index.html +++ b/gently/ui/web/templates/index.html @@ -116,7 +116,7 @@

Take a quick look

Now
- +
@@ -390,7 +390,7 @@

Embryo Monitoring

-

Experiment

+

Operations

@@ -931,6 +931,7 @@

Properties

+ diff --git a/tests/test_burst_laser_config.py b/tests/test_burst_laser_config.py index 9e973715..079651a6 100644 --- a/tests/test_burst_laser_config.py +++ b/tests/test_burst_laser_config.py @@ -1,6 +1,5 @@ """Task 2: BurstAcquisition laser_config threading tests.""" -import asyncio -import pytest + from gently.app.orchestration.exclusive import BurstAcquisition 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_lightsheet_client.py b/tests/test_lightsheet_client.py index cf4a9538..c02f2129 100644 --- a/tests/test_lightsheet_client.py +++ b/tests/test_lightsheet_client.py @@ -1,4 +1,3 @@ -import pytest from gently.hardware.dispim.client import DiSPIMMicroscope @@ -29,6 +28,7 @@ def test_stream_lightsheet_is_async_generator(): # 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) diff --git a/tests/test_lightsheet_event.py b/tests/test_lightsheet_event.py index 20271403..277b14f1 100644 --- a/tests/test_lightsheet_event.py +++ b/tests/test_lightsheet_event.py @@ -1,6 +1,6 @@ """Tests for LIGHTSHEET_FRAME event type""" -from gently.core.event_bus import EventType, EventBus, _NO_HISTORY_TYPES +from gently.core.event_bus import _NO_HISTORY_TYPES, EventBus, EventType def test_lightsheet_frame_event_exists(): diff --git a/tests/test_lightsheet_monitor.py b/tests/test_lightsheet_monitor.py index f5cdf797..5f3c6bf9 100644 --- a/tests/test_lightsheet_monitor.py +++ b/tests/test_lightsheet_monitor.py @@ -6,10 +6,11 @@ """ import asyncio + import pytest -from gently.core.event_bus import EventType, get_event_bus from gently.app.lightsheet_monitor import LightSheetStreamMonitor +from gently.core.event_bus import EventType, get_event_bus class FakeScope: diff --git a/tests/test_lightsheet_routes.py b/tests/test_lightsheet_routes.py index 0f7f172b..5cb9debe 100644 --- a/tests/test_lightsheet_routes.py +++ b/tests/test_lightsheet_routes.py @@ -7,13 +7,14 @@ - acquire/burst, acquire/volume - require_control gate (403 without override) """ -from unittest.mock import MagicMock, AsyncMock + +from unittest.mock import AsyncMock, MagicMock 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 +from gently.ui.web.routes.data import create_router def _app(client=None, monitor=None): @@ -36,6 +37,7 @@ def _app(client=None, monitor=None): # 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") @@ -88,6 +90,7 @@ def test_live_stop_calls_monitor_stop(): # live/params # --------------------------------------------------------------------------- + def test_live_params_forwards(): client = MagicMock() client.set_lightsheet_live_params = AsyncMock(return_value={"params": {}}) @@ -96,9 +99,7 @@ def test_live_params_forwards(): 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 - ) + 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(): @@ -118,6 +119,7 @@ def test_live_params_no_client_503(): # led/set # --------------------------------------------------------------------------- + def test_led_set_forwards(): client = MagicMock() client.set_led = AsyncMock(return_value={"success": True}) @@ -130,6 +132,7 @@ def test_led_set_forwards(): # 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() @@ -143,6 +146,7 @@ def test_laser_off_calls_set_laser_config_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() @@ -158,6 +162,7 @@ def test_laser_configs_forwards_to_client(): 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() @@ -178,6 +183,7 @@ def test_laser_configs_no_require_control(): # camera/led_mode # --------------------------------------------------------------------------- + def test_camera_led_mode_forwards(): client = MagicMock() client.set_camera_led_mode = AsyncMock(return_value={"success": True}) @@ -190,6 +196,7 @@ def test_camera_led_mode_forwards(): # stage/move # --------------------------------------------------------------------------- + def test_stage_move_forwards(): client = MagicMock() client.move_to_position = AsyncMock(return_value={"success": True}) @@ -209,6 +216,7 @@ def test_stage_move_missing_xy_400(): # acquire/burst # --------------------------------------------------------------------------- + def test_acquire_burst_forwards(): """Basic burst forwards without optional params — no laser_config/piezo/galvo in body.""" client = MagicMock() @@ -229,14 +237,25 @@ def test_acquire_burst_forwards_laser_config_and_focal_plane(): 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}, + 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, + frames=10, + mode="brightfield", + num_slices=50, + exposure_ms=20.0, + laser_config="ALL OFF", + piezo_center=55.0, + galvo_center=1.5, ) @@ -244,6 +263,7 @@ def test_acquire_burst_forwards_laser_config_and_focal_plane(): # acquire/volume # --------------------------------------------------------------------------- + def test_acquire_volume_forwards(): """Basic volume forwards without optional params.""" client = MagicMock() @@ -262,13 +282,21 @@ def test_acquire_volume_forwards_laser_config_and_focal_plane(): 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}, + 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, + num_slices=50, + exposure_ms=20.0, + laser_config="ALL OFF", + piezo_center=55.0, + galvo_center=1.5, ) @@ -276,6 +304,7 @@ def test_acquire_volume_forwards_laser_config_and_focal_plane(): # require_control gate — 403 WITHOUT override # --------------------------------------------------------------------------- + def test_require_control_gate_403(): """Without the dependency override, TestClient host is not loopback → 403.""" server = MagicMock() diff --git a/tests/test_lightsheet_streamer.py b/tests/test_lightsheet_streamer.py index c36377a1..14b320f9 100644 --- a/tests/test_lightsheet_streamer.py +++ b/tests/test_lightsheet_streamer.py @@ -3,46 +3,89 @@ 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"): +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 +import bluesky as _bs # noqa: E402 + _bs.RunEngine = MagicMock(name="RunEngine") -import asyncio, numpy as np, pytest -from gently.hardware.dispim.device_layer import DeviceLayerServer +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.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 + 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 + 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 + 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 __init__(self): + super().__init__() + self.name = "Piezo" + self.state = None + + def set_spim_state(self, s): + self.state = s def _make_dl(): @@ -63,14 +106,16 @@ def _make_dl(): 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 + 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 = {} + 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.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 @@ -78,13 +123,15 @@ async def test_grab_parks_and_peeks(monkeypatch): 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 + 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 = {} + 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 + 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_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_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_temp_protocol_driver.py b/tests/test_temp_protocol_driver.py index aa112f03..cd2b5f30 100644 --- a/tests/test_temp_protocol_driver.py +++ b/tests/test_temp_protocol_driver.py @@ -1,37 +1,68 @@ 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 + 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=[] + 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)) + 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) + client = FakeClient() + orch = FakeOrch(client) bursts = [] - async def runner(b): bursts.append({"phase": getattr(b, "_phase", None), "laser": b._laser_config}) + + 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) + 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 + 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 @@ -47,5 +78,9 @@ async def runner(b): bursts.append({"phase": getattr(b, "_phase", None), "laser" 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" + 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 index 7344a985..62883fae 100644 --- a/tests/test_temp_protocol_events.py +++ b/tests/test_temp_protocol_events.py @@ -12,6 +12,7 @@ def test_new_event_types_exist(): 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 index 574940fe..2210c1f5 100644 --- a/tests/test_temp_protocol_snapshot.py +++ b/tests/test_temp_protocol_snapshot.py @@ -45,9 +45,7 @@ def _write_session(session_dir: Path) -> None: EMBRYO_ID: {"interval_seconds": 120}, }, } - (session_dir / "timelapse.yaml").write_text( - yaml.dump(timelapse), encoding="utf-8" - ) + (session_dir / "timelapse.yaml").write_text(yaml.dump(timelapse), encoding="utf-8") # timeline.jsonl — the sequence under test events = [ diff --git a/tests/test_temp_protocol_tool.py b/tests/test_temp_protocol_tool.py index 519c4375..d9fe748e 100644 --- a/tests/test_temp_protocol_tool.py +++ b/tests/test_temp_protocol_tool.py @@ -4,12 +4,10 @@ TDD: write failing tests first, then implement the tool. """ -import asyncio -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import MagicMock, patch import pytest - # --------------------------------------------------------------------------- # Fakes # --------------------------------------------------------------------------- @@ -17,6 +15,7 @@ class FakeClient: """Minimal fake microscope client.""" + async def set_temperature(self, t): return {"success": True, "temperature_c": t, "state": "[ HEATING ]"} @@ -26,6 +25,7 @@ async def get_temperature(self): class FakeOrchestrator: """Minimal fake timelapse orchestrator with a client attribute.""" + def __init__(self, client): self.client = client @@ -35,6 +35,7 @@ def _emit_event(self, *args, **kwargs): class FakeAgent: """Minimal fake agent — carries timelapse_orchestrator.""" + def __init__(self, orchestrator): self.timelapse_orchestrator = orchestrator @@ -177,8 +178,8 @@ def test_tool_is_registered(): @pytest.mark.asyncio async def test_tool_refuses_during_active_timelapse(): """If orchestrator._status == RUNNING, refuse without creating a task.""" - from gently.app.tools.temperature_protocol_tools import run_temp_change_burst_protocol_tool 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 @@ -201,4 +202,6 @@ def fake_create_task(coro, **kwargs): 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}" + 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 index 388976aa..80e6601f 100644 --- a/tests/test_temperature_event.py +++ b/tests/test_temperature_event.py @@ -2,12 +2,12 @@ Test suite for TEMPERATURE_UPDATE event type """ -from gently.core.event_bus import EventType, EventBus +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') + assert hasattr(EventType, "TEMPERATURE_UPDATE") event_type = EventType.TEMPERATURE_UPDATE assert event_type.name == "TEMPERATURE_UPDATE" diff --git a/tests/test_temperature_route.py b/tests/test_temperature_route.py index 4cd02cf3..5e71a9ea 100644 --- a/tests/test_temperature_route.py +++ b/tests/test_temperature_route.py @@ -1,26 +1,41 @@ -from unittest.mock import MagicMock 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._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 + srv = MagicMock() + srv.gently_store = store return srv, store def _client(server): - app = FastAPI(); app.include_router(create_router(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"}]) + 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() diff --git a/tests/test_temperature_sampler.py b/tests/test_temperature_sampler.py index 4c79bf76..8a221aef 100644 --- a/tests/test_temperature_sampler.py +++ b/tests/test_temperature_sampler.py @@ -5,11 +5,11 @@ `file_store.create_session(name="s")` is adapted to `file_store.create_session(str(uuid.uuid4()), name="s")` to match the real API. """ -import asyncio + import uuid -from gently.core.event_bus import EventBus, EventType from gently.app.temperature_sampler import TemperatureSampler, temperature_stamp +from gently.core.event_bus import EventBus, EventType class FakeScope: @@ -32,7 +32,9 @@ def _capture(bus): 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"}) + 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) @@ -68,15 +70,22 @@ async def test_tick_poll_failure_is_a_gap_not_a_crash(file_store): 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", + 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"}) + scope = FakeScope( + {"success": True, "temperature_c": 28.4, "setpoint_c": 32.0, "state": "heating"} + ) bus = EventBus() # Successful first tick. @@ -96,7 +105,9 @@ async def test_stale_latest_cleared_on_poll_failure(file_store): bus = EventBus() # Successful first tick. - good_scope = FakeScope({"success": True, "temperature_c": 28.4, "setpoint_c": 32.0, "state": "heating"}) + 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 diff --git a/tests/test_temperature_sampler_wiring.py b/tests/test_temperature_sampler_wiring.py index 9564d775..3eec8148 100644 --- a/tests/test_temperature_sampler_wiring.py +++ b/tests/test_temperature_sampler_wiring.py @@ -4,7 +4,6 @@ heavy integration concern verified end-to-end in a later task. DeviceStateMonitor's own wiring is likewise not unit-tested at this level. """ -from gently.app.temperature_sampler import TemperatureSampler def test_agent_initializes_temperature_sampler_attribute(): @@ -22,9 +21,5 @@ def test_agent_initializes_temperature_sampler_attribute(): "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" - ) + 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 index 4784880b..4b88fcdb 100644 --- a/tests/test_temperature_stamp.py +++ b/tests/test_temperature_stamp.py @@ -52,7 +52,9 @@ def test_volume_metadata_carries_temperature(file_store): 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}) + 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 @@ -77,9 +79,7 @@ def test_burst_stamp_writes_temperature(file_store): embryo.num_slices = 2 embryo.exposure_ms = 50.0 - frames_data = [ - {"volume": np.zeros((2, 4, 4), dtype="uint16"), "acquired_at_epoch": None} - ] + 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}): diff --git a/tests/test_temperature_store.py b/tests/test_temperature_store.py index 9e5d5fbe..dfc36fd9 100644 --- a/tests/test_temperature_store.py +++ b/tests/test_temperature_store.py @@ -1,12 +1,11 @@ """Tests for FileStore temperature log methods.""" -import pytest - 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") @@ -14,8 +13,14 @@ def _new_session(file_store): 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"}) + 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] @@ -23,8 +28,12 @@ def test_append_and_read_roundtrip(file_store): 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"}) + 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] @@ -36,16 +45,20 @@ def test_read_unknown_session_is_empty(file_store): 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.""" - import uuid 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"}) + 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. - from gently.core.file_store import FileStore # Locate the temperature.jsonl via the store's internal path. sd = file_store._session_dir(sid) log_path = sd / "temperature.jsonl" 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 index 6ee8fbe3..c8d7f854 100644 --- a/tests/test_wait_for_lock.py +++ b/tests/test_wait_for_lock.py @@ -1,15 +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 + 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 + 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