Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
13ade7c
fix(stage-a): 🐛 renew leases against the deadline the owner granted
muthmann Aug 3, 2026
8239fae
fix(stage-a): 🐛 say when a run wrote no sensor readout
muthmann Aug 3, 2026
5145e5e
ci: 👷 build installable plugin bundles for four platforms
muthmann Aug 4, 2026
ce99eae
ci: 🐛 stop the build failing on the host's own CI layout
muthmann Aug 4, 2026
35b5696
ci: 🔧 build against the host branch that has the API
muthmann Aug 4, 2026
b2bcf9b
refactor(evesmlm): ♻️ share types through a crate, not between plugins
muthmann Aug 4, 2026
2a46196
fix(stage-a): 🐛 find the Teensy on Windows' nameless COM ports
muthmann Aug 4, 2026
b7166dc
fix(stage-a-a1): 🐛 accept protocol files a spreadsheet saved
muthmann Aug 5, 2026
2861912
feat(stage-a-a1): ✨ apply confirmed camera profiles
muthmann Aug 12, 2026
6941fd1
fix(stage-a-a1): 🐛 use generic camera sessions
muthmann Aug 12, 2026
60d97fa
refactor(stage-a-a1): ♻️ trust generic host confirmation
muthmann Aug 13, 2026
29e5897
feat(stage-a): ✨ carry A2 timing and photodiode geometry on the contract
muthmann Aug 13, 2026
553d85d
feat(stage-a-modulation): ✨ prepare the firmware for A2 step latency
muthmann Aug 13, 2026
cc9d18a
feat(stage-a-photodiode): ✨ measure the direct camera and emission paths
muthmann Aug 13, 2026
39eaa9f
feat(stage-a-a1): ✨ let the sidecar own experiment provenance
muthmann Aug 13, 2026
5f05720
feat(stage-a-a2): ✨ add the fluorescence step-latency protocol runner
muthmann Aug 13, 2026
130ef98
feat(stage-a-a4): ✨ add the contrast-threshold survey plugin
muthmann Aug 13, 2026
df4229e
docs: 📝 index the two new Stage-A workflows
muthmann Aug 13, 2026
7c23805
fix(stage-a-a1): 🐛 keep the protocol's remaining bench time on screen
muthmann Aug 14, 2026
f123331
feat(stage-a): ✨ add guided PD references and A2 bring-up automation
muthmann Sep 5, 2026
c216424
fix(ci): 🐛 build plugins against augur-rs main
muthmann Sep 5, 2026
0d01f08
feat(stage-a-a2): ✨ simplify protocol and capture owner state
muthmann Sep 5, 2026
e38cb09
fix(stage-a-a2): 🐛 declare camera configuration commands
muthmann Sep 5, 2026
b40b878
fix(stage-a-a2): 🐛 automate measurement point setup
muthmann Sep 5, 2026
a00db48
fix(stage-a-a2): 🐛 preserve synchronized captures and diagnose failures
muthmann Sep 7, 2026
7c43446
feat(stage-a): ✨ add Windows capture alerts and lab packaging
muthmann Sep 7, 2026
71242a4
fix(stage-a-a1): 🐛 retry a rejected camera start instead of skipping …
muthmann Sep 7, 2026
c6d7b4e
fix(stage-a-a1): 🐛 refuse a survey whose sidecars the placement canno…
muthmann Sep 7, 2026
a068f6b
fix(stage-a-a1): 🐛 repeat a failed point, lose it, and stop on a streak
muthmann Sep 7, 2026
e6fc903
fix(stage-a-a1): 🐛 state the controller's A1 mode instead of inheriti…
muthmann Sep 7, 2026
d9deb62
fix(stage-a-a1): 🐛 change the controller mode through its owner, not …
muthmann Sep 7, 2026
c05ecff
fix(stage-a-photodiode): 🐛 write recordings off the stream reader thread
muthmann Sep 7, 2026
583129d
fix(stage-a): 🐛 confirm controller commands and keep every point's re…
muthmann Sep 7, 2026
2ef83ef
chore(stage-a-a1): 📄 add the resumed flux-discriminator protocols
muthmann Sep 7, 2026
b90926c
fix(stage-a): 🐛 keep the protocol parser out of the plugins' link graph
muthmann Sep 7, 2026
cf0244e
test(stage-a-a1): ✅ compare the sidecar path against the same derivation
muthmann Sep 7, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
219 changes: 219 additions & 0 deletions .github/workflows/build-plugins.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
name: Build Plugins

# Produces drop-in plugin folders for a machine that has no Rust toolchain: the
# bench downloads a bundle, copies its folders into ~/.augur/plugins/, and hits
# "Scan for New Plugins". Pull requests get workflow artifacts; main also
# publishes a rolling release so the download needs no GitHub login.

on:
push:
branches:
- main
pull_request:
workflow_dispatch:
inputs:
augur_rs_ref:
description: "augur-rs ref to build against (branch, tag or SHA)"
required: false
default: v2.1.1

concurrency:
group: build-plugins-${{ github.ref }}
cancel-in-progress: true

permissions:
contents: read

env:
CARGO_TERM_COLOR: always
# The paired camera-session changes are part of augur-rs main. Keep manual
# runs overridable so older or diagnostic host revisions remain testable.
AUGUR_RS_REF: ${{ inputs.augur_rs_ref || 'main' }}

jobs:
build:
name: ${{ matrix.name }}
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
include:
- os: macos-latest
name: macOS (arm64)
bundle: macos-arm64
- os: macos-15-intel
name: macOS (x86_64)
bundle: macos-x86_64
- os: ubuntu-latest
name: Linux (x86_64)
bundle: linux-x86_64
- os: windows-latest
name: Windows (x86_64)
bundle: windows-x86_64

defaults:
run:
# The repo drives its builds through two bash scripts; use the same
# shell on Windows so there is exactly one code path to reason about.
shell: bash

steps:
# This workspace depends on the host by path (../augur-rs/augur-core), so
# CI has to reproduce the two-sibling-checkout layout, not clone one repo.
- name: Check out augur-plugins
uses: actions/checkout@v5
with:
path: augur-plugins

- name: Check out augur-rs
uses: actions/checkout@v5
with:
repository: muthmann/augur-rs
ref: ${{ env.AUGUR_RS_REF }}
path: augur-rs

- name: Pin the host revision and disarm the source patch
run: |
echo "AUGUR_RS_SHA=$(git -C augur-rs rev-parse HEAD)" >> "$GITHUB_ENV"
# build-runtime-plugins.sh patches [patch."…/augur-rs.git"] whenever a
# sibling augur-rs *git checkout* exists. This workspace already
# depends on it by path, so that patch matches nothing in the crate
# graph — it only costs cargo a fetch of the checkout. Removing .git
# makes the script's detection fail and the path deps win outright.
rm -rf augur-rs/.git

- name: Resolve the pinned Rust toolchain
run: |
channel="$(sed -n 's/^channel *= *"\(.*\)"$/\1/p' augur-plugins/rust-toolchain.toml | head -n 1)"
if [[ -z "${channel}" ]]; then
echo "No channel found in augur-plugins/rust-toolchain.toml" >&2
exit 1
fi
echo "RUST_CHANNEL=${channel}" >> "$GITHUB_ENV"

- name: Install Rust toolchain
uses: actions-rust-lang/setup-rust-toolchain@v1
with:
toolchain: ${{ env.RUST_CHANNEL }}
cache-workspaces: augur-plugins
# The action injects RUSTFLAGS="-D warnings" by default. That is right
# for a lint job and wrong here: this job ships artifacts, and a dead-
# code warning in one plugin must not deny the bench a bundle for all
# of them. Lint gating belongs in its own job, not in the build.
rustflags: ""

- name: Install Linux system dependencies
if: runner.os == 'Linux'
# Only what the plugin crates actually link. augur-gui's own dependency
# script is deliberately not reused: it is a superset (X11/Wayland/GL for
# the GUI, which no plugin links) and it does not exist on every augur-rs
# revision this job can be pointed at, so borrowing it made the Linux
# build fail on the value of augur_rs_ref. serialport needs libudev.
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends pkg-config libudev-dev

- name: Test Stage-A acquisition and controller contracts
working-directory: augur-plugins
run: cargo test -p augur-plugin-stage-a-a1 -p augur-plugin-stage-a-a2 -p augur-plugin-stage-a-modulation -p augur-plugin-stage-a-photodiode -p stage-a-io -p stage-a-plugin-contract

- name: Build runtime plugins
working-directory: augur-plugins
run: bash scripts/build-runtime-plugins.sh --profile release

- name: Stage installable plugin folders
working-directory: augur-plugins
run: bash scripts/install-built-plugins.sh --profile release --dest "dist/${{ matrix.bundle }}"

- name: Set up Python for the lab watcher
uses: actions/setup-python@v5
with:
python-version: "3.12"

- name: Test the independent lab watcher
working-directory: augur-plugins
run: python scripts/test_stage_a_watch.py

- name: Build the Windows lab watcher
if: runner.os == 'Windows'
working-directory: augur-plugins
run: |
python -m pip install pyinstaller==6.16.0
python -m PyInstaller --noconfirm --clean --onefile --name Laborwache \
--distpath dist/windows-x86_64/lab-watch --workpath target/lab-watch \
--specpath target scripts/stage_a_watch.py
cp docs/features/stage-a-lab-watch.md dist/windows-x86_64/lab-watch/README.md

- name: Write build provenance
working-directory: augur-plugins
run: |
{
echo "bundle: ${{ matrix.bundle }}"
echo "built_at: $(date -u +%Y-%m-%dT%H:%M:%SZ)"
echo "augur_plugins: $(git rev-parse HEAD)"
echo "augur_rs_ref: ${AUGUR_RS_REF}"
echo "augur_rs_sha: ${AUGUR_RS_SHA}"
echo "rustc: $(rustc --version)"
echo
echo "Copy the plugin folders next to this file into ~/.augur/plugins/,"
echo "then use Plugins -> Scan for New Plugins in augur-gui."
} > "dist/${{ matrix.bundle }}/BUILD-INFO.txt"

- name: Upload plugin bundle
uses: actions/upload-artifact@v4
with:
name: augur-plugins-${{ matrix.bundle }}
path: augur-plugins/dist/${{ matrix.bundle }}
if-no-files-found: error

release:
name: Publish rolling release
needs: build
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
permissions:
contents: write

steps:
- name: Download every plugin bundle
uses: actions/download-artifact@v4
with:
path: bundles
pattern: augur-plugins-*

- name: Package one archive per platform
run: |
set -euo pipefail
mkdir -p dist
for bundle_dir in bundles/augur-plugins-*/; do
bundle="$(basename "${bundle_dir%/}")"
(cd "${bundle_dir}" && zip -qr "${GITHUB_WORKSPACE}/dist/${bundle}.zip" .)
echo "Packaged ${bundle}.zip"
done
(cd dist && sha256sum ./*.zip > SHA256SUMS.txt)
ls -l dist

- name: Publish rolling release
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
tag="plugins-latest"
# Delete and recreate rather than upload --clobber: it retags at the
# new commit and guarantees no asset from an older build survives.
gh release delete "${tag}" --yes --cleanup-tag || true
gh release create "${tag}" dist/* \
--title "Prebuilt plugins (latest main)" \
--notes "$(printf '%s\n' \
"Prebuilt AugurRS plugins, rebuilt on every push to \`main\`." \
"" \
"- augur-plugins: \`${GITHUB_SHA}\`" \
"- built against augur-rs \`${AUGUR_RS_REF}\`" \
"" \
"Download the archive for your platform, unpack it, and copy the" \
"plugin folders inside into \`~/.augur/plugins/\`. Then open augur-gui," \
"go to **Plugins**, and click **Scan for New Plugins**." \
"" \
"\`BUILD-INFO.txt\` in each archive records the exact revisions and" \
"compiler the libraries were built with. Verify downloads against" \
"\`SHA256SUMS.txt\`.")"
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
/target
/dist
Cargo.lock
*.swp
*.swo
Expand Down
4 changes: 4 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
[workspace]
members = [
"evesmlm-types",
"stage-a-io",
"stage-a-plugin-contract",
"plugins/stage-a-a1",
"plugins/stage-a-a2",
"plugins/stage-a-a4",
"plugins/stage-a-modulation",
"plugins/stage-a-photodiode",
"plugins/localization",
Expand All @@ -27,6 +30,7 @@ augur-core = { path = "../augur-rs/augur-core" }
augur-plugin-api = { path = "../augur-rs/augur-plugin-api" }
augur-plugin-types = { path = "../augur-rs/augur-plugin-types" }
egui = "0.27"
evesmlm-types = { path = "evesmlm-types" }
rustfft = "6"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
Expand Down
26 changes: 26 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,31 @@ The plugin crates under `plugins/` are under active development and not yet read

## Quick Start

### Download Prebuilt Plugins (no toolchain needed)

Every push to `main` publishes freshly built plugins for macOS (arm64 and x86_64),
Linux and Windows to the rolling
[`plugins-latest`](https://github.com/muthmann/augur-plugins/releases/tag/plugins-latest)
release. This is the recommended route for a measurement machine.

```bash
curl -LO https://github.com/muthmann/augur-plugins/releases/download/plugins-latest/augur-plugins-macos-arm64.zip
unzip augur-plugins-macos-arm64.zip -d augur-plugins-bundle
mkdir -p ~/.augur/plugins
cp -R augur-plugins-bundle/*/ ~/.augur/plugins/
```

Pick the archive matching the machine: `macos-arm64`, `macos-x86_64`,
`linux-x86_64`, or `windows-x86_64`. Then open `augur-gui`, go to **Plugins**, and
click **Scan for New Plugins**.

Each archive contains a `BUILD-INFO.txt` recording the `augur-rs` revision and the
`rustc` version the libraries were built against — quote it in any ABI-mismatch
report. Verify downloads against `SHA256SUMS.txt` from the same release.

Pull requests build the same bundles as workflow artifacts. See
[CI Prebuilt Plugin Bundles](./docs/features/ci-prebuilt-plugin-bundles.md).

### Build One Plugin

```bash
Expand Down Expand Up @@ -145,6 +170,7 @@ augur-plugins/

- [Plugin API Notes](./docs/plugin-api.md) — repo-local summary of the current runtime contract
- [Installing Plugins](./docs/installing-plugins.md) — build, copy, reload, and troubleshoot installed plugins
- [CI Prebuilt Plugin Bundles](./docs/features/ci-prebuilt-plugin-bundles.md) — how the downloadable per-platform bundles are built and published
- [Architecture Notes](./docs/architecture.md) — repository role, execution model, host views, and shared settings
- [augur-rs Plugin Authoring Guide](https://github.com/muthmann/augur-rs/blob/main/docs/features/plugin-authoring-guide.md) — canonical host/runtime authoring guide
- [augur-rs Global Settings Guide](https://github.com/muthmann/augur-rs/blob/main/docs/features/global-settings-menu.md) — host-owned settings published to plugins
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
# ADR 029 — A leased run renews against the deadline the owner granted, not the one it asked for

- **Status:** Accepted
- **Date:** 2026-08-03
- **Relates to:** ADR 005 (device ownership), ADR 007 (owner orchestration),
ADR 009 (recording coordinator), ADR 027 (declarative protocols),
[Stage-A A1 Analysis](../features/stage-a-a1.md)

## Context

Both Stage-A device owners hand out an automation lease with a TTL, and both
**cap** the TTL they grant:

```rust
// modulation and photodiode, independently
const MAX_LEASE_TTL_MS: u64 = 60_000;
fn lease_deadline(ttl_ms: u64) -> u64 {
now_unix_ms().saturating_add(ttl_ms.clamp(MIN_LEASE_TTL_MS, MAX_LEASE_TTL_MS))
}
```

The cap is a dead-man switch and it is right: an automation client that crashes
mid-run must not leave the laser driven indefinitely. A lease that lapses makes
the modulation owner queue `STOP` + `MOD wave=OFF`, and makes the photodiode
owner finalize its recording as `LeaseExpired`.

A1 asked for a TTL covering its whole run — a frequency ladder, an amplitude
sweep, or a protocol file's remaining points — and renewed **once per point**,
in the same tick that retargeted the drive. The clamp is silent: the request is
answered `Applied`, so A1 believed it held the drive for forty minutes when the
owner had granted sixty seconds.

That worked only while every point was shorter than the cap. It is not:

- the shipped example protocol has a `duration_s = 40, settle_s = 4` row, and
every row also pays the camera start/stop and photodiode
connect/lease/start/finalize handshakes;
- `acquire_photodiode` asks for `duration_s + 60 s`, so **any recording longer
than the cap** outlived its own photodiode lease.

Past the granted deadline, one root cause surfaced as three unrelated-looking
failures in the same status line:

| symptom | actual cause |
| --- | --- |
| `the modulation owner requires an active automation lease` | the lease was reaped and the drive safe-offed |
| `cannot write a quantitative A1 sidecar without a fresh photodiode optical summary` | no drive → no modulated light, and the PDQ had been finalized as `LeaseExpired` |
| `Camera: … events, … no trigger signal` | no drive → the Teensy stopped emitting the phase-0 `EXT_TRIGGER` |

The third is the one that reads as a hardware fault. It sent the operator after
a trigger cable that was never disconnected.

## Decision

**The owner's cap stays. The client renews against the deadline the owner
publishes.**

Both owners already advertise the truth: `ModulationStateV1.lease` and
`PhotodiodeSummaryV1.lease` carry a `LeaseSnapshotV1 { lease_id, holder,
expires_at_unix_ms, .. }`. A1 never read it.

A1 gains one heartbeat, `drive_lease_heartbeat`, running on every control tick
ahead of the runners:

- it finds the modulation lease A1 currently holds — outermost runner first,
since a nested run inherits the enclosing lease id — and the photodiode lease
of a recording in flight;
- it renews only what the **owner's own snapshot** confirms A1 is holding, so a
lease the owner has already dropped is not chased;
- it renews once less than `LEASE_RENEW_MARGIN_MS` (20 s) of the granted window
is left, no more often than every `LEASE_RENEW_MIN_INTERVAL_MS` (2 s) — the
control plane ticks at 20 Hz and the owner's snapshot lags a renewal by a tick
or two.

The per-point renewals stay. They are correct and they cost nothing; the
heartbeat covers the interval between them.

The whole-run TTL helpers stay too, and keep asking for the run's real remaining
time. That is the honest statement of need, and it is the owner's job — not the
client's — to decide how much of it to grant.

## Consequences

- A point may now be arbitrarily long. The protocol's `duration_s` is bounded
by the protocol schema (1..=3600 s), not by an owner's lease cap.
- The dead-man switch is intact: if A1 stops ticking, the heartbeat stops with
it and the lease lapses within the cap, exactly as before.
- A lease A1 loses anyway (owner restart, an operator disconnect) is not
papered over. The heartbeat goes quiet because the owner's snapshot no longer
names A1 as the holder, and the runner's own retarget reports the real
failure in its own words.
- Renewal replies are not routed to any runner. An unmatched `request_id`
already falls through `on_service_reply` untouched, so a heartbeat cannot
be mistaken for a point's retarget outcome.
- The owners were left alone. Raising `MAX_LEASE_TTL_MS` to survey length would
have fixed the symptom by deleting the safety property that motivated it.

## Also fixed here

`on_discontinuity` asked `recording.is_active() || sweep.is_some()` to decide
whether a `SourceChanged` was self-inflicted. Starting and stopping the host
recorder raises it twice per recording, and between two points of a protocol or
a frequency ladder neither of those is true — so the run's own boundary was
treated as an idle-time reset and wiped the survey's pilot windows, background
floor and response curve mid-run. The question is now `automation_active()`:
the same set `request_stop` winds down.
Loading
Loading