feat: Squid Core Service — REST+SSE API and MCP bridge rewrite#578
Open
hongquanli wants to merge 33 commits into
Open
feat: Squid Core Service — REST+SSE API and MCP bridge rewrite#578hongquanli wants to merge 33 commits into
hongquanli wants to merge 33 commits into
Conversation
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…vice Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Test file was at repo root and never collected by pytest. Moved to software/tests/squid_service/ where CI actually runs tests from (working directory: software). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Implements the StateMachine class with thread-safe state transitions, allowed state graph validation, and transition callbacks. Includes InstrumentState enum and BUSY_STATES constant as per spec §3. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds squid_service/events.py: a thread-safe pub/sub EventBus with monotonic event ids, unbounded per-subscriber queues, and a bounded ring-buffer for Last-Event-Id replay (spec §2.6). Backs the SSE endpoint's resume/gap-detection behavior. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…sed import - Remove unused json import - Update create(), get(), active, last, and complete() to return job.model_copy(deep=True) - Copies are taken while holding the lock to prevent torn reads - Internal id-keyed methods (mark_running, update_progress, wait) continue operating on live objects - All 20 existing tests pass unchanged Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ection Adds pydantic v2 request models for the squid_service REST API and a ServiceConfig that enforces auth on any non-loopback bind. Wires a [CORE_SERVICE] INI section into control/_def.py following the existing [VIEWS]/[GENERAL]/[SIMULATION] block pattern (try/except + has_option guards) so host/port/auth/methods_dir are overridable per machine. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Implements the Task 8 service facade over the Microscope stack plus the Qt-thread GuiBridge, per the URS delta (LA-WC-0001): - capabilities()/version() share firmware/software version derivation - initialize(home) performs read-only stage/camera/mcu probes and can home the stage without being a no-op, with a plain non-blocking lock guard replacing the reference's awkward context-manager ternary - autofocus_status() reports available/initialized/reference_set/readiness, using LaserAutofocusController.laser_af_properties.has_reference (the same flag move_to_target() itself gates on) as the reference-set signal - autofocus_store_reference()/autofocus_correct() add guarded set_reference()/measure_displacement() ops with AutofocusCorrectRequest validation Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Added two tests to verify hardware failure handling: one for probe failures during initialize() that transition to ERROR state with HARDWARE_FAULT, and one for camera acquisition failures producing recoverable HARDWARE_TRANSIENT faults. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Server-side YAML method store referenced by name; clients never send filesystem paths. Validates names against a safe pattern, parse-validates configs via the canonical loader (no hardware), and summarizes each method for listing. Unit tests cover save/list/get roundtrip, overwrite semantics, unknown/invalid-name faults, and unparseable-file reporting. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…oller callback wiring Wire SquidCoreService to MultiPointController: chain observers onto the controller callbacks (originals fire first, ours never propagate), track jobs through preflight/start/abort, and publish state + progress + completion events. - preflight()/start_acquisition() route yaml_path, named method, or grid source - grid mode: flexible-region parity with legacy TCP run_acquisition - autofocus + sample_format request overrides - ERROR-state transition on runtime end_reason=="error" (ACQUISITION_RUNTIME); validation failures and user_abort stay recoverable - af_failures/save_failures counters and operator/scheduler_job_id audit fields on job records; api_request.json written alongside output - method CRUD service ops incl. delete-while-running rejection and validate_method Integration tests run real simulated acquisitions (lifecycle, reject-while- running, abort, run-by-method, grid, autofocus override, method CRUD, validate). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ROR-state correctness) MultiPointWorker only invokes signal_slack_acquisition_finished (and signal_slack_timepoint_notification) when a Slack notifier is configured, so with the default (no notifier) AcquisitionStats never reaches SquidCoreService._on_acq_finished. Every acquisition -- including aborted and failed runs -- was therefore reported as outcome SUCCESS and never drove the instrument to ERROR. Add SquidCoreService._derive_end_reason(), which falls back to the worker's own (side-effect-free) _compute_end_reason() when stats are missing, or to the abort flag if the worker is unavailable. Also fall back to the worker's _acquisition_error_count/_laser_af_failures for the job result and progress in that case, since the per-timepoint Slack callback that normally accumulates af_failures never fires either. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds a FastAPI REST surface (system/motion/imaging/autofocus/acquisitions/jobs/ methods, sample_formats) plus an SSE /v1/events endpoint with session_started, Last-Event-Id replay, resume_gap and live tail, all backed by the SquidCoreService facade and canonical FaultError -> JSON error envelopes. Fixes an SSE test hang: the /v1/events generator is an infinite live tail, but Starlette's TestClient (and httpx's ASGITransport) buffer the entire ASGI response before returning and only deliver http.disconnect after the response completes, so consuming the stream via client.stream() deadlocked (response completion waits for the generator to end; the generator waits for a disconnect that only arrives after completion). The stream is now a directly-testable module-level async generator (sse_event_stream) with unchanged production behavior; the test drives it directly with bounded reads then closes it, verifying replay, live tail, the is_disconnected break, and finally-unsubscribe. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds two review-mandated tests for sse_event_stream: one that shrinks the EventBus ring buffer to force history eviction and asserts a resume_gap event precedes the surviving replay tail, and one that drives the subscribe-before-replay window to overlap the replay set with the live queue and asserts the yielded_up_to guard prevents any duplicate id from being emitted. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds POST /v1/debug/python_exec (gated by an explicit opt-in flag, not a sandbox) plus a status endpoint, and a URS delta (API-COMPAT-002) pair of GET/POST /v1/debug/settings endpoints giving REST parity to the legacy TCP view/performance-mode debug commands. performance_mode is routed through GuiBridge (fire-and-forget QTimer dispatch, CONFIG_CAPABILITY_MISSING when headless); view settings mutate control._def directly. display_plate_view was dropped from the settings payload: the underlying control._def.DISPLAY_PLATE_VIEW flag no longer exists in this codebase (plate view was already unified into the mosaic view), so referencing it would raise AttributeError on every call. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Wrap the entire image-save block in python_exec to catch all write exceptions (bad dtype, disk full, permissions) and raise canonical IO faults. Add test coverage for image auto-save path with uint16 numpy array. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Add CoreServiceServer, a uvicorn-in-daemon-thread wrapper with start/stop/is_running, and wire it into main_hcs.py: when CORE_SERVICE_ENABLED, construct SquidCoreService (with GuiBridge, job persistence at cache/last_job.json, and the configured methods dir) plus the FastAPI app, start it alongside the legacy TCP server (--start-server flag and the Settings toggle control both), stop it on aboutToQuit and on toggle-off, and route the python-exec toggle to both servers. GUI startup survives core-service setup failures (logged, REST API unavailable). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…lable Move squid_service imports inside the try/except block so any ImportError is caught by the existing exception handler and only logs an error, allowing the GUI to continue in degraded mode without the REST API. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Replace the TCP-scraping mcp_microscope_server.py with an httpx-based stdio bridge over the new Squid Core Service REST API (SQUID_API_URL, default http://127.0.0.1:5060). The static, curated tool list preserves every legacy microscope_* tool name and argument name so existing .mcp.json configs and pre-approved permissions keep working; tool errors pass through the canonical Fault JSON verbatim, and transport failures synthesize a HARDWARE_TRANSIENT fault. Also folds in the URS API-COMPAT-002 delta: legacy grid-mode microscope_run_acquisition, performance-mode and view-settings tools mapped onto /v1/debug/settings, and four new tools (get_methods, run_method, autofocus_status, store_af_reference). Skips microscope_set_display_plate_view since the underlying DISPLAY_PLATE_VIEW flag no longer exists on master. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The legacy TCP command _cmd_acquire_laser_af_image was dropped without a replacement when the MCP bridge was rewritten onto the REST API. Add LaserAfImageRequest, SquidCoreService.autofocus_acquire_image (guarded like autofocus_run, with AUTOFOCUS_NOT_READY/HARDWARE_TRANSIENT_TIMEOUT faults and acquire()-style save-path handling), POST /v1/autofocus/acquire_image, and the microscope_acquire_laser_af_image MCP tool with the original argument names, so the capability has REST/MCP parity again. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
run_acquisition.py now talks to squid_service's REST API (default port 5060) instead of the deprecated newline-JSON TCP protocol (port 5050), via httpx. Adds --method as an alternative to --yaml (exactly one required) to run a named server-side acquisition method, and fixes a latent bug where --no-launch --wait never actually propagated the acquisition outcome to the process exit code. Updates docs/automation.md and docs/mcp_integration.md to reflect the REST API and the current MCP bridge tool set, and adds docs/core-service-api.md as the canonical REST API reference (endpoints, fault model, instrument/job states, SSE, polling guidance, deferred-501 endpoints with URS ids, and INI configuration). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Also clarifies the /v1/jobs/last note in core-service-api.md (route-registration detail, not a client-side ordering requirement). Final verification evidence (see .superpowers/sdd/task-15-report.md): - black==25.12.0 (CI version): 35 files unchanged - ruff F401/F841: clean - targeted suites: 78 passed in combined run incl. previously-aborting widget test - full-suite Qt abort in test_widgets.py confirmed environment/pre-existing (zero branch diff on involved files; passes with our modules in-process) - test_multi_point_with_laser_af failure confirmed identical on origin/master - live MCP smoke: ping/status/position/methods OK against sim GUI Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Reject all state-changing commands unless INITIALIZED (ERROR-state acquisition no longer orphans a job with a raw 500; URS API-LIFE-003) - Wrap home_xyz() in initialize(home=true) so homing failure lands in ERROR with a HARDWARE_FAULT instead of wedging INITIALIZING - Set controller z_range from current stage Z and clear focus-map state in both API acquisition config paths (second run no longer inherits the first plate's Z) - Add catch-all 500 handler (sanitized HARDWARE_FAULT_INTERNAL 5999) and 404 canonical-fault handler to the REST app - autofocus_correct now honors move_to_target()'s result (no false corrected:true when the laser AF range rejects the move) - Restore legacy save_downsampled_overview setting via /v1/debug/settings and the MCP bridge (URS API-COMPAT-002) - Per-call httpx timeouts for abort (timeout_s+10) and home (300s) - run_acquisition.py honors SQUID_API_TOKEN; docs updated (remote-host auth example, GUI/API concurrent-start known limitation) - New tests: ERROR-path e2e, homing-failure recovery, z_range refresh, 500/404 handler shapes, restored bridge tool (136 total passing) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…safe SIP port) Chrome/Edge refuse ERR_UNSAFE_PORT on 5060, making /docs unreachable in a browser. API clients were unaffected but the Swagger UI is a first-class integration aid (URS API-CONN-003). Port remains INI-configurable via [CORE_SERVICE] port. Deviation from the spec docx noted in core-service-api.md. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Task-first getting-started guide covering the three acquisition modes (inline grid, named method, GUI-saved YAML) plus job tracking, SSE, abort, preflight, and the CLI/MCP wrappers. Examples verified against the running simulator (real channel names, real 202/job-status response bodies). Linked from automation.md and core-service-api.md. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Companion to the plate-scan quickstart: two-click launch and natural-language example prompts (what to say to Claude, not tool signatures), since the MCP audience drives the microscope through conversation, not hand-called tools. Linked from mcp_integration.md and the plate-scan quickstart. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Pairs with quickstart-mcp.md (interface-named siblings) and matches core-service-api.md. Link text normalized to 'API Quickstart'; the doc's plate-scan hero example is unchanged. Content is unaffected. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…quest model
Task 16 (schema half): the acquisition schema now supports specifying wells by
name in a method/YAML and choosing a run-time Z baseline in the request.
- Loader: AcquisitionYAMLData gains `wells: Optional[str]`. parse_acquisition_yaml
reads wellplate_scan.wells (string "A1:B3"/"A1,B2" or a YAML list, normalized to a
comma-separated string). Specifying both `wells` and a non-empty `regions` raises
ValueError. Fully additive: existing regions-only methods parse identically.
- Models: new strict ZMillimeters {z_mm} and ZReference =
Union[Literal["current","autofocus"], ZMillimeters]; AcquisitionRequest gains
`z_reference: ZReference = "current"`.
- Tests: loader wells cases (string/list/both-error/absent/empty/regions-unaffected);
z_reference model validation (default/current/autofocus/z_mm/garbage-rejected).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ce policy
Wire the wells-by-name field and z_reference into the acquisition path.
- Region precedence is now overrides.wells -> yaml wells -> yaml regions -> fault,
applied in both check_regions (validation) and _configure_regions (setup). Wells are
derived from the plate definition via well_center_mm; wells-derived regions take
region z = the resolved z0.
- New _resolve_z_reference(req) returns the run's Z baseline z0: "current"/"autofocus"
-> current stage z; {"z_mm": v} -> v. New named preflight check "z_reference" (added
to both yaml and grid checks, after "regions"): z_mm outside the stage Z limits ->
INVALID_PARAM_OUT_OF_RANGE (2001, component stage.z); "autofocus" with no AF mode
enabled for the run -> INVALID_PARAM (2002); "autofocus" with reflection AF but no
stored reference, or contrast-only AF with no controller attached ->
AUTOFOCUS_NOT_READY (8002).
- _reset_z_range_and_focus_map takes z0 and sets z_range=(z0, z0+span). _configure_
controller now sets the controller's z_stacking_config from the method so the worker
applies the FROM CENTER / FROM TOP shift (the API path previously ignored it); FROM
CENTER is realized by the worker at run time, not by pre-shifting z_range (verified
against widgets.py / multi_point_worker.py). Grid runs honor z_reference identically.
- methods._summarize includes the method's "wells".
- Tests: method-with-wells e2e (coords match well_center_mm), z_mm sets z_range and
region z, z_mm out-of-limits faults with no job, autofocus-no-reference -> 8002 (no
job), autofocus-AF-disabled -> 2002 (no job), FROM CENTER baseline; REST wells-method
+ z_reference happy path (202 -> SUCCESS).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…licy
- quickstart-api.md: named-method hero example now uses wellplate_scan.wells
("A1:B3") instead of regions/center_mm; added Z-baseline guidance (default current
stage z; z_reference {"z_mm": ...} for an explicit baseline; "autofocus" to require a
ready AF); noted regions remain for irregular/manual layouts (either/or with wells).
- core-service-api.md: documented z_reference on AcquisitionRequest (current /
{"z_mm"} / autofocus, with the fault codes) and the wellplate_scan.wells method field
plus the either/or with regions.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Contributor
Author
Review guideThis is a big diff (43 files, +7.2k), but it layers cleanly. Suggested reading order — each layer only depends on the ones before it:
Where human judgment matters most (machine review already did line-level correctness — every task had an independent implement/review cycle plus a final whole-branch review):
Fastest way to see it work: 🤖 Generated with Claude Code |
main_headless.py serves the same REST+SSE API as the GUI-embedded server from a QApplication-free process, for scheduler-driven instruments and remote operation: - squid_service/headless.py: production factory mirroring gui_hcs wiring with the Qt-free controller base classes (AutoFocusController, MultiPointController, ScanCoordinates); shares the microscope's lazily initialized laser-AF controller and selects an initial channel so the end-of-acquisition mode restore has a valid configuration. - main_headless.py owns SIGINT/SIGTERM itself and runs uvicorn in a thread (uvicorn's main-thread signal capture replays the signal after serve(), which would kill the process before teardown). Shutdown order: abort in-flight acquisition (bounded 60s), stop server, close hardware, reap leftover JobRunner children, release the single-instance lock. - Live-verified in simulation: acquisition to SUCCESS; SIGTERM mid-run aborts to ABORTED and exits cleanly; no orphaned subprocesses; port released; disk-full run surfaced canonical ACQUISITION/6002 fault. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Replaces the ad-hoc TCP control server with a transport-agnostic
squid_servicepackage exposed as a REST + SSE API (port 8060, served inside the GUI process) and a rewritten MCP bridge over that API. Driven by the internal Core Service spec and customer URS LA-WC-0001 (scheduler integration).The legacy TCP server (port 5050) is untouched and still runs — deprecated for one release. Existing
.mcp.json, the GUI launcher, and pre-approvedmicroscope_*permissions keep working unchanged.Why
The old
microscope_control_server.pyfused transport, validation, state, and hardware access in one 1,600-line class. This branch splits those concerns and, along the way, retires real bugs it surfaced:FieldInfodefault bug — pydanticField()used as plain function defaults meant optional params silently receivedFieldInfoobjects (e.g.move_to(y_mm=5)passedFieldInfointomove_x_to).WELLPLATE_OFFSET_X/Y_mm, so its coordinates disagreed with the GUI.get_acquisition_statusread worker attributes (current_fov_index,total_fovs) that don't exist.What's in it
New
software/squid_service/packagescheduler_actionhint,recoverable/terminal/plate_removable, monotonic sequence numbers, sanitized messages (no stack traces to remote callers)UNINITIALIZED → INITIALIZING → INITIALIZED → ACQUIRING → PROCESSING → ERROR → RECOVERING; state-changing commands rejected unlessINITIALIZED; both GUI- and API-started acquisitions drive it, so API clients always see truthful state; acquisitions that end in error transition toERRORwith the fault preservedskipped_fovs; last job persisted across restartLast-Event-Idreplay,resume_gap, replay/live dedupeREST + SSE API (
squid_service/rest/) — FastAPI on port 8060: system/motion/imaging/autofocus/acquisitions/jobs/methods/sample_formats/debug +/v1/events.GET /docsSwagger UI,GET /openapi.json. Bearer auth (off by default on loopback, mandatory on non-loopback binds),202 + Locationfor acquisitions, canonical fault on every non-2xx.Three ways to run a scan (exactly one per request): inline
grid, namedmethod, or GUI-savedyaml_path— each withwells/output_path/sample_format/autofocusoverrides.Rewritten MCP bridge (
mcp_microscope_server.py) — thin curated-tool HTTP client (39 tools), all legacy names/args preserved; tool errors surface the canonical fault JSON so agents branch oncategory/terminal.scripts/run_acquisition.pyported to REST (--methodflag,SQUID_API_TOKEN), plus a latent exit-code bug fixed.Docs — new
quickstart-api.md(copy-paste, verified against the simulator) andquickstart-mcp.md(plain-English Claude prompts);core-service-api.mdreference; updatedautomation.md/mcp_integration.md.Added after PR creation (2026-07-03)
d4764ae4..74cc0119): a wellplate method can now specifywellplate_scan.wells: "A1:B3"— X/Y derived from the plate definition; no more hand-writtenregions[].center_mmfor plate scans (the coordinate form still parses). Newz_referencefield on acquisition requests:"current"(default, today's behavior),{"z_mm": <float>}(explicit baseline, limit-validated at preflight), or"autofocus"(requires AF enabled and a stored reference, enforced at preflight so it fails early — not mid-plate).z_stacking_config(previously ignored); the service setsz_range[0] = baselineand the worker performs the FROM CENTER shift — matching the GUI exactly.00702d14, 2026-07-05):python3 main_headless.py [--simulation]serves the identical API from a QApplication-free process — for scheduler-driven instruments and remote operation.squid_service/headless.pymirrors the GUI's controller wiring with the Qt-free base classes.main_headless.pyowns SIGINT/SIGTERM itself (uvicorn's main-thread signal capture replays the signal afterserve(), killing the process before any teardown — so uvicorn runs in a thread); shutdown aborts an in-flight acquisition (bounded 60 s), stops the server, closes the hardware, and reaps leftover JobRunner children so unattended instruments don't accumulate orphaned subprocesses. Live-verified in simulation: acquisition toSUCCESS; SIGTERM mid-384-FOV-run →ABORTED+ clean exit, port released, zero orphans; a disk-full run surfaced the canonicalACQUISITION/6002 fault.Testing
Microscope), including full acquisition lifecycle, graceful abort →ABORTED, busy-rejection, ERROR-state recovery, method CRUD, auth on/off, SSE replay/gap/dedupe, and forced hardware-fault paths.test_multi_point_with_laser_af— confirmed identical onmaster; one Qt-abort intest_widgets.pyconfirmed environmental, passes in-process).black==25.12.0(CI version) clean;ruffF401/F841 clean.SUCCESS, MCP bridge round-trip verified.Reviewer notes
ERR_UNSAFE_PORT, which would make/docsunreachable. INI-configurable via[CORE_SERVICE] port.MultiPointWorkerinternals; matching the vendor "to be implemented" URS responses):emergency_stop, plate interlocks (confirm_plate_loaded/prepare_for_unload), per-acquisition AF/save-failure thresholds and populatedskipped_fovsmanifest,RESERVEDstate. Reserved endpoints return canonical501s; theskipped_fovsfield exists and is forward-compatible.initialize(home=true)with induced homing failure; two back-to-back API runs with a Z move between (z_range refresh); real abort → stage/illumination safe; REST polling during GUI live view (camera-SDK thread affinity); MCPhome/longmovetimeout behavior; kill -TERM mid-acquisition → port 5060/8060 rebinds on restart;main_headless.pyon real hardware (camera SDK callback threads have never run without a Qt event loop in this codebase)._on_acq_finishedordering,reset()under lock, save-fallback DRY, Settings toggle label ("MCP Control Server" now gates both servers), explicituvicorn/sse-starlette/httpxdeps, aGET /v1/jobslist endpoint.🤖 Generated with Claude Code