Skip to content

feat: Squid Core Service — REST+SSE API and MCP bridge rewrite#578

Open
hongquanli wants to merge 33 commits into
masterfrom
feat/squid-core-service
Open

feat: Squid Core Service — REST+SSE API and MCP bridge rewrite#578
hongquanli wants to merge 33 commits into
masterfrom
feat/squid-core-service

Conversation

@hongquanli

@hongquanli hongquanli commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

Summary

Replaces the ad-hoc TCP control server with a transport-agnostic squid_service package 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-approved microscope_* permissions keep working unchanged.

Why

The old microscope_control_server.py fused 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:

  • Systemic FieldInfo default bug — pydantic Field() used as plain function defaults meant optional params silently received FieldInfo objects (e.g. move_to(y_mm=5) passed FieldInfo into move_x_to).
  • Well-coordinate bug — the TCP server's hand-rolled well math omitted WELLPLATE_OFFSET_X/Y_mm, so its coordinates disagreed with the GUI.
  • Dead progress codeget_acquisition_status read worker attributes (current_fov_index, total_fovs) that don't exist.
  • Outcome-blind acquisitions — status cleared to idle with no success/failure distinction; abort/error reported as SUCCESS whenever Slack wasn't configured.

What's in it

New software/squid_service/ package

  • Canonical Fault model — 8 categories, numeric codes in 1000-blocks, scheduler_action hint, recoverable/terminal/plate_removable, monotonic sequence numbers, sanitized messages (no stack traces to remote callers)
  • State machineUNINITIALIZED → INITIALIZING → INITIALIZED → ACQUIRING → PROCESSING → ERROR → RECOVERING; state-changing commands rejected unless INITIALIZED; both GUI- and API-started acquisitions drive it, so API clients always see truthful state; acquisitions that end in error transition to ERROR with the fault preserved
  • Job store — full lifecycle, progress (FOVs, elapsed, AF/save failure counts), outcome, skipped_fovs; last job persisted across restart
  • Event bus — SSE with Last-Event-Id replay, resume_gap, replay/live dedupe
  • Method registry — named server-side acquisition configs (URS API-METH-001..005), no client filesystem paths; full CRUD + validate, hot-reload

REST + SSE API (squid_service/rest/) — FastAPI on port 8060: system/motion/imaging/autofocus/acquisitions/jobs/methods/sample_formats/debug + /v1/events. GET /docs Swagger UI, GET /openapi.json. Bearer auth (off by default on loopback, mandatory on non-loopback binds), 202 + Location for acquisitions, canonical fault on every non-2xx.

Three ways to run a scan (exactly one per request): inline grid, named method, or GUI-saved yaml_path — each with wells/output_path/sample_format/autofocus overrides.

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 on category/terminal.

scripts/run_acquisition.py ported to REST (--method flag, SQUID_API_TOKEN), plus a latent exit-code bug fixed.

Docs — new quickstart-api.md (copy-paste, verified against the simulator) and quickstart-mcp.md (plain-English Claude prompts); core-service-api.md reference; updated automation.md / mcp_integration.md.

Added after PR creation (2026-07-03)

  • Wells-by-name methods + run-time Z policy (d4764ae4..74cc0119): a wellplate method can now specify wellplate_scan.wells: "A1:B3" — X/Y derived from the plate definition; no more hand-written regions[].center_mm for plate scans (the coordinate form still parses). New z_reference field 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).
  • Behavior change: the API path now honors z_stacking_config (previously ignored); the service sets z_range[0] = baseline and the worker performs the FROM CENTER shift — matching the GUI exactly.
  • Headless mode (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.py mirrors the GUI's controller wiring with the Qt-free base classes. main_headless.py owns SIGINT/SIGTERM itself (uvicorn's main-thread signal capture replays the signal after serve(), 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 to SUCCESS; SIGTERM mid-384-FOV-run → ABORTED + clean exit, port released, zero orphans; a disk-full run surfaced the canonical ACQUISITION/6002 fault.
  • Test count now 180 locally; CI green on the current head.

Testing

  • 136 tests across service / REST / MCP bridge / acquisitions (simulated 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.
  • Full CI-style suite green (one pre-existing failure — test_multi_point_with_laser_af — confirmed identical on master; one Qt-abort in test_widgets.py confirmed environmental, passes in-process).
  • black==25.12.0 (CI version) clean; ruff F401/F841 clean.
  • Live smoke against the simulation GUI: both ports serving, stage moved via REST, real acquisition run to SUCCESS, MCP bridge round-trip verified.
  • Every task individually reviewed; final whole-branch review passed after its 3 Criticals were fixed and re-verified.

Reviewer notes

  • Port 8060, not the spec's 5060 — browsers block 5060 (SIP) as ERR_UNSAFE_PORT, which would make /docs unreachable. INI-configurable via [CORE_SERVICE] port.
  • Not in this PR (need MultiPointWorker internals; 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 populated skipped_fovs manifest, RESERVED state. Reserved endpoints return canonical 501s; the skipped_fovs field exists and is forward-compatible.
  • First hardware test checklist (things simulation can't prove): 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); MCP home/long move timeout behavior; kill -TERM mid-acquisition → port 5060/8060 rebinds on restart; main_headless.py on real hardware (camera SDK callback threads have never run without a Qt event loop in this codebase).
  • Follow-up PR (small, mechanical): event-bus publish-under-lock, _on_acq_finished ordering, reset() under lock, save-fallback DRY, Settings toggle label ("MCP Control Server" now gates both servers), explicit uvicorn/sse-starlette/httpx deps, a GET /v1/jobs list endpoint.

🤖 Generated with Claude Code

hongquanli and others added 30 commits July 2, 2026 12:49
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>
hongquanli and others added 2 commits July 3, 2026 20:44
…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>
@hongquanli

hongquanli commented Jul 4, 2026

Copy link
Copy Markdown
Contributor Author

Review guide

This is a big diff (43 files, +7.2k), but it layers cleanly. Suggested reading order — each layer only depends on the ones before it:

  1. squid_service/faults.py — the fault taxonomy every layer speaks (categories, codes, HTTP mapping). ~160 lines, sets the vocabulary.
  2. squid_service/state.py, events.py, jobs.py, wells.py — small, self-contained, unit-tested.
  3. squid_service/service.py — the core. Everything interesting is here: state guards (_exclusive), the acquisition path (start_acquisition_configure_regions/_configure_controller), controller-callback chaining (_wrap_controller_callbacks — how GUI and API acquisitions share one state machine), Z handling (_resolve_z_reference, _reset_z_range_and_focus_map).
  4. squid_service/rest/ — deliberately thin delegation; sse.py has the one subtle bit (replay/dedupe).
  5. mcp_microscope_server.py — pure HTTP client, tool table.
  6. main_hcs.py wiring, control/acquisition_yaml_loader.py (additive change only), docs.
  7. main_headless.py + squid_service/headless.py (added 2026-07-05) — the same service/REST layers served from a GUI-free process. The factory mirrors gui_hcs.load_objects() wiring with the Qt-free controller base classes; the entry point owns SIGINT/SIGTERM itself (uvicorn's main-thread signal capture replays the signal after serve(), which would skip teardown) and on shutdown aborts an in-flight acquisition, closes the hardware, and reaps leftover JobRunner children.

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):

  • GUI/API coexistence: both drive the shared MultiPointController; API runs are 409-guarded, GUI runs are tracked via chained callbacks. A simultaneous-start race window exists and is documented as a known limitation in core-service-api.md.
  • Z semantics: API path now honors z_stacking_config (previously ignored) and resets z_range + focus-map state on every run — intended, matches GUI behavior, but worth a domain-expert eye.
  • Security posture decisions: bearer auth off by default on loopback / mandatory on non-loopback binds; python_exec explicitly unsandboxed behind a GUI opt-in toggle.
  • Port 8060 instead of the spec's 5060 (browsers hard-block 5060 as SIP → ERR_UNSAFE_PORT).
  • Deprecation stance: legacy TCP 5050 untouched and still starts alongside; both gated by the same Settings toggle.

Fastest way to see it work: python3 main_hcs.py --simulation --start-server, then open http://127.0.0.1:8060/docs (live Swagger against the sim) or follow software/docs/quickstart-api.md — every example there was verified against the simulator. For the GUI-free variant: python3 main_headless.py --simulation.

🤖 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant