diff --git a/.github/workflows/README.md b/.github/workflows/README.md
index 5cce5d3943..8117e74e17 100644
--- a/.github/workflows/README.md
+++ b/.github/workflows/README.md
@@ -102,7 +102,7 @@ App 15368 is GitHub Actions, the app the current checks are pinned to.
| main.yml | `run-ci` | ubuntu-latest, Python 3.12 | ubuntu-latest, Python 3.12 | ubuntu-latest and macos-latest, Python 3.10 to 3.12 |
| main.yml | `unsupported-python-guard` | no | no | Python 3.13 and 3.14 |
| packages.yml | `types`, `agent` | Python 3.12 | Python 3.12 | Python 3.10 and 3.12 |
-| packages.yml | `flow-lint`, `flow-critical` | yes | yes | yes |
+| packages.yml | `flow-lint`, `flow-critical`, `flow-onprem-offline` | yes | yes | yes |
| ci.yml | `smoke`, `ci` | yes | yes | no |
| packages.yml | `flow-unit` | 3 shards packed by measured time, fast lane (without `nightly_only.txt`) | no | 2 shards |
| packages.yml | `flow-native-census` | no | no | Windows and macOS |
diff --git a/.github/workflows/packages.yml b/.github/workflows/packages.yml
index 30656bfec9..82df4d8864 100644
--- a/.github/workflows/packages.yml
+++ b/.github/workflows/packages.yml
@@ -357,7 +357,7 @@ jobs:
# every public data/config asset listed in public-artifacts.json.
run: python scripts/check_release_consistency.py
- name: Ruff lint
- run: ruff check openadapt_flow benchmark/harness
+ run: ruff check openadapt_flow benchmark/harness benchmark/phase_latency
- name: No app-specific code in the engine
# App knowledge lives in profiles, contracts and fixtures (CONTRIBUTING:
# "App knowledge lives in data"); the denylist is scripts/generality.toml.
@@ -376,9 +376,11 @@ jobs:
ruff check --isolated --select E9 --target-version py310 .
uv run --no-project --python 3.10 python -m compileall -q -x '(^|/)(\.venv|node_modules|build|dist)/' .
- name: Ruff format check
- run: ruff format --check openadapt_flow tests benchmark/harness
+ run: ruff format --check openadapt_flow tests benchmark/harness benchmark/phase_latency
- name: Mypy
run: mypy
+ - name: Phase timing contract types
+ run: mypy --follow-imports=silent --ignore-missing-imports benchmark/phase_latency/collector.py
- name: Mypy (strict, safety path only)
run: |
mypy \
@@ -630,6 +632,50 @@ jobs:
sys.exit(1 if bad else 0)
PY
+ # The on-prem install path for real: build two signed offline releases of
+ # openadapt-flow[privacy] from uv.lock on this connected runner, then, as
+ # root in a network namespace with no route out, install the first from its
+ # wheelhouse, refuse a tampered second, update to the signed second, roll
+ # back, and run the air-gap attestation (egress probe, audit chain). pip has
+ # no index and the machine has no network, so a wheel missing from the
+ # archive fails the install. deploy/on-prem/bin/test-offline-install.sh.
+ # First, bin/test-update.sh: the update state machine's refusal, migration
+ # and recovery paths with a fake venv build, which nothing else runs. Then
+ # bin/test-container.sh: the runner image built from the first release with
+ # no network, run on docker-compose.yml's internal network.
+ flow-onprem-offline:
+ name: flow on-prem offline install, update and rollback (egress blocked)
+ needs: scope
+ if: github.event.pull_request.draft != true && (needs.scope.outputs.flow == 'true')
+ runs-on: ubuntu-latest
+ timeout-minutes: 30
+ defaults:
+ run:
+ working-directory: packages/flow
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
+ - uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
+ with:
+ python-version: "3.12"
+ - uses: astral-sh/setup-uv@68559e99cc807c1eb7e9238fd43a15bba460eec2 # v10.3.3
+ with:
+ enable-cache: false
+ - name: Signed update and rollback state machine (hermetic, fake build)
+ run: bash deploy/on-prem/bin/test-update.sh
+ - name: Build two signed offline releases from the lock (connected)
+ run: bash deploy/on-prem/bin/test-offline-install.sh stage "$RUNNER_TEMP/offline"
+ - name: Container image from the signed release (no-network build, internal network)
+ run: bash deploy/on-prem/bin/test-container.sh "$RUNNER_TEMP/offline"
+ - name: Install, update and roll back with egress blocked
+ # env -u SUDO_UID keeps the command root inside the namespace:
+ # install.sh owns the release tree as root.
+ run: >-
+ sudo env -u SUDO_UID -u SUDO_GID "PATH=$PATH"
+ deploy/on-prem/bin/run-egress-blocked.sh
+ bash deploy/on-prem/bin/test-offline-install.sh verify "$RUNNER_TEMP/offline"
+
# The agent-framework adapters (openadapt_flow.integrations.agents): each
# framework's own agent loop with a scripted model, writing through a real
# HTTP app, from the lock with every framework extra (CrewAI pinned beside
@@ -1425,6 +1471,7 @@ jobs:
- flow-lint
- flow-unit
- flow-critical
+ - flow-onprem-offline
- flow-agent-frameworks
- flow-native-census
- flow-linux-atspi
@@ -1478,6 +1525,7 @@ jobs:
"flow-lint": "flow",
"flow-unit": "flow",
"flow-critical": "flow",
+ "flow-onprem-offline": "flow",
"flow-agent-frameworks": "flow",
"flow-native-census": "flow",
"flow-linux-atspi": "flow",
diff --git a/.gitleaksignore b/.gitleaksignore
index b61b5d9e4c..f1ecf70645 100644
--- a/.gitleaksignore
+++ b/.gitleaksignore
@@ -60,3 +60,7 @@ e8ec96725c4451aa64cb97230c18d04b55d98503:packages/flow/tests/e2e/test_mcp_teach_
# test_network_capture_redact_json.py; the next commit builds them at run time.
52502adf407793c211fb3c0ac5aba37556206d8c:packages/flow/tests/test_network_capture_redact_json.py:generic-api-key:17
52502adf407793c211fb3c0ac5aba37556206d8c:packages/flow/tests/test_network_capture_redact_json.py:generic-api-key:146
+# The API-step generalization harness (PR #1851) first started its local NetBox
+# container with a fixed synthetic superuser token (a run of hex digits, never a
+# credential for any real system); the harness now generates it per run.
+9922cee8dbbbaa4494b1c69ec6c71e4899305de6:packages/flow/benchmark/api_generalization/apps/nb.py:generic-api-key:60
diff --git a/CAPABILITIES.md b/CAPABILITIES.md
index c934e587b1..6b585caab9 100644
--- a/CAPABILITIES.md
+++ b/CAPABILITIES.md
@@ -100,7 +100,7 @@ monorepo and installable from source.
| Learns branching programs from several demonstrations, from repeated halts, and from many background runs. | `induce`, `teach-branch`, [SHADOW_MINING.md](packages/capture/docs/SHADOW_MINING.md), [LEARN_FROM_MANY_RUNS.md](packages/flow/docs/LEARN_FROM_MANY_RUNS.md) |
| Chains programs across surfaces, and one program can switch surfaces, tabs and popups mid-run. | `compose`, `process`, [#1197](https://github.com/OpenAdaptAI/OpenAdapt/pull/1197) |
| Uses page structure first on web, Windows, macOS and Linux, with pixels as the fallback for remote desktops. Benchmarks labelled "vision-only" ran a deliberately pixel-only arm. | [RESOLUTION_LADDER.md](packages/flow/docs/RESOLUTION_LADDER.md) |
-| Replaces a single HTML form submission with the request the app itself sends, compiled from the network traffic captured while recording, then reads the record back. JSON or GraphQL requests, multi-step wizards and governed runs still use the screen steps. | `api`, [API_TIER.md](packages/flow/docs/API_TIER.md) |
+| Replaces a single HTML form submission with the request the app itself sends, compiled from the network traffic captured while recording, then reads the record back. URL-encoded and file-less multipart saves, the clicked button's value, and saves the page's script sends to another endpoint are covered. JSON or GraphQL requests, uploads, multi-step wizards and governed runs still use the screen steps. | `api`, [API_TIER.md](packages/flow/docs/API_TIER.md) |
| Builds the record check itself. After a save, the app usually reads the record back to show it; `scaffold-verifier` finds that read in the captured traffic or a read-only database, builds the check from it and tries to break it before a person reviews it. No API or SQL needs to be written by hand. | [EFFECT_KIT.md](packages/flow/docs/EFFECT_KIT.md#a-record-check-built-from-the-apps-own-reads-scaffold-verifier) |
| Checks a save on desktop, RDP and Citrix apps with no API at all, by looking the record up in the app's own screens in a separate session. | [EFFECT_KIT.md](packages/flow/docs/EFFECT_KIT.md#separate-session-desktop-read-back-desktop_readback) |
| Keeps pixel-path runs working across display scaling from 100% to 175% and font smoothing on or off, and stops rather than saves when the record changed. | [benchmark/pixel_scale](packages/flow/benchmark/pixel_scale/README.md) |
@@ -230,6 +230,7 @@ improvising or retrying when something is off.
| One program that switches surfaces, tabs and popups mid-run | Released ([#1197](https://github.com/OpenAdaptAI/OpenAdapt/pull/1197)) | Real Chromium; Windows switch on a mock | [#1197](https://github.com/OpenAdaptAI/OpenAdapt/pull/1197) |
| Halt when a workflow qualified with page structure runs on a pixels-only surface | Released | Synthetic | `openadapt-flow qualify` |
| Stop before acting when another shown row has the same identity; when the rows carry an app-owned record id (a `data-*` id, the row link), act on the row with the recorded id; on a surface that cannot count the rows, stop before any step that leads into a write | Released ([#1504](https://github.com/OpenAdaptAI/OpenAdapt/pull/1504)) | Real browser (headless Chromium worklist with identical rows) | `runtime/identity_census.py`; `PlaywrightBackend.identity_candidates_at` |
+| Read a dialog target's identity on the topmost modal dialog alone, not the page behind it, and stop when no dialog holds the target | On main ([#1615](https://github.com/OpenAdaptAI/OpenAdapt/pull/1615)) | Real app (local OpenEMR, synthetic data): at the duplicate dialog with the form scrolled to 8 positions behind it, 0 of 8 false stops, against 8 of 8 before; a dialog listing a possible duplicate still stops | `PlaywrightBackend.dialog_region_at`, `Anchor.dialog_region`; [dialog_identity_2026-10-28](packages/flow/benchmark/openemr_throughput/dialog_identity_2026-10-28/README.md) |
| Lift look-alike identity stops with a complete known-ID list | Released | Synthetic (rendered record list) | `openadapt-flow known-ids` |
| Take extra looks before stopping on a look-alike record ID: fresh frames, the known-ID list, and a select-and-copy of the ID where the surface allows it | Released ([#1333](https://github.com/OpenAdaptAI/OpenAdapt/pull/1333)) | Synthetic (dense record list in a browser) | `runtime.identity_copy_look` in deployment.yaml; [IDENTITY_LOOKS.md](packages/flow/benchmark/identity_looks/IDENTITY_LOOKS.md) |
| Check an icon-only click target's hover response (highlight, tooltip) before clicking on a pixel surface, and re-check the target and identity regions on a fresh frame right before every pixel click | Released ([#1334](https://github.com/OpenAdaptAI/OpenAdapt/pull/1334)) | Synthetic (in-process kiosk) | `Recorder(capture_hover=True)`; [hover_identity](packages/flow/benchmark/hover_identity/README.md) |
@@ -243,6 +244,7 @@ improvising or retrying when something is off.
| Capture a recording's network traffic and compile a clean save into an API step, with the same record check | Released ([#1269](https://github.com/OpenAdaptAI/OpenAdapt/pull/1269)) | Synthetic patients on a local OpenEMR test copy | network capture, `ApiBinding` |
| Fall back from the API step to page structure when the API is unavailable, before anything is sent | Released ([#1269](https://github.com/OpenAdaptAI/OpenAdapt/pull/1269)) | Synthetic | [#1269](https://github.com/OpenAdaptAI/OpenAdapt/pull/1269) |
| Run a form's own script before an API save instead of refusing the form: field handlers, `onsubmit`, submit listeners and custom validate.js rules (OpenEMR's `pastDate`) run on the run's values in a scratch copy of the session with every write blocked, and the save is sent only when the page lets the submit through and would send the same body | On main | Synthetic (headless Chromium against a local test page) | [API_TIER.md](packages/flow/docs/API_TIER.md#what-happens-at-run-time), `runtime/actuators/form_eval.py` |
+| Propose API steps for more save shapes: multipart saves with no file (encoded as Chromium encodes them), the clicked submit button's `name=value`, empty file inputs, forms with no `action`, and saves the page's script sends to another endpoint (run through the page's own script before every save); success addresses keep a record's path with numbers and hashes generalized | This PR | Headless Chromium against local test pages, and recordings from Redmine, WordPress, NetBox, Dolibarr and Trac fixtures | [API_TIER.md](packages/flow/docs/API_TIER.md#what-the-compiler-checks) |
| Declare the other writes in an API step's span as an ordered sequence: the page's own background requests (another document's script, loaded before the form, to an endpoint the form doesn't name) are listed and not sent; a write a step caused is sent in its recorded place before the save, read back by that step's record check, and a failure stops the run with nothing after it sent and nothing retried | On main | Synthetic | [API_TIER.md](packages/flow/docs/API_TIER.md#other-writes-in-the-span) |
| Approve an API step's exact request under per-item approval: one digest binds the request bytes, the record and the record check, and a changed request is not sent | Released ([#1355](https://github.com/OpenAdaptAI/OpenAdapt/pull/1355)) | Synthetic | [API_TIER.md](packages/flow/docs/API_TIER.md#under-an-approval-mode) |
| Run an API step in a durable run: checkpoint the span after the save is sent, never resend or redo a save that may have landed, and reconcile it by reading the record back | Released ([#1358](https://github.com/OpenAdaptAI/OpenAdapt/pull/1358)) | Synthetic | [API_TIER.md](packages/flow/docs/API_TIER.md#in-a-durable-run) |
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 114bbdeb65..b58aa349d3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,25 @@
+## v1.20.0 (2026-11-04)
+
+### Requirements
+
+- Require `openadapt-flow>=2.6.0,<3.0.0` in the base install and every Flow
+ extra. `openadapt doctor` and `openadapt deploy` report older Flow releases
+ as unsupported. This supplies the persistent owed-recheck API used by
+ ledger listing.
+
+### Features
+
+- `openadapt verify ledger list` shows provisional confirmed writes whose
+ rechecks are still owed, with their due time and actor. Owed rechecks remain
+ visible after the server restarts and disappear when the recheck completes.
+ An empty ledger reports that no held writes, open writes or owed rechecks
+ remain.
+- The platform manifest and compatibility report select published component
+ releases, including Desktop 0.25.1.
+
## v1.19.0 (2026-11-02)
### Breaking Changes
diff --git a/docs/cli.md b/docs/cli.md
index 3bc94019e4..7505a57479 100644
--- a/docs/cli.md
+++ b/docs/cli.md
@@ -73,7 +73,7 @@ openadapt flow demo-record --out rec
openadapt flow compile rec --out bundle --name my-task
openadapt flow lint bundle --strict
openadapt flow certify bundle --policy permissive
-openadapt flow replay bundle --run-dir run
+openadapt flow replay bundle --demo-app --run-dir run
```
The strict lint step refuses the unarmed demo write. The permissive policy is a
diff --git a/docs/getting-started/quickstart.md b/docs/getting-started/quickstart.md
index 21e0da90da..7a88d0fd84 100644
--- a/docs/getting-started/quickstart.md
+++ b/docs/getting-started/quickstart.md
@@ -50,7 +50,7 @@ openadapt flow demo-record --out rec
openadapt flow compile rec --out bundle --name my-task
openadapt flow lint bundle --strict
openadapt flow certify bundle --policy permissive
-openadapt flow replay bundle --run-dir run
+openadapt flow replay bundle --demo-app --run-dir run
```
The strict lint step returns a nonzero exit code. The bundled manual demo has
diff --git a/docs/platform-compatibility-report.md b/docs/platform-compatibility-report.md
index 2c1b4199d6..3278ecc665 100644
--- a/docs/platform-compatibility-report.md
+++ b/docs/platform-compatibility-report.md
@@ -2,7 +2,7 @@
-- Generated: `2026-11-03T00:23:35+00:00`
+- Generated: `2026-11-04T22:39:52+00:00`
- Selection: `explicit-published`
- Compatibility: **dependency-compatible**
- Signature: `unsigned (signing infrastructure pending)`
@@ -11,22 +11,22 @@
| Role | Package | Version | Release source reference | Published artifacts |
|---|---|---:|---|---|
-| launcher | `openadapt` | `1.17.0` | [`v1.17.0`](https://github.com/OpenAdaptAI/OpenAdapt/tree/v1.17.0) commit `e022d97ee8b3c3992f4a4cb6e522f1532fef0834` tree `2e0efeaa337a26d3425320ffa8f9aa3ac31d3f9c` | `openadapt-1.17.0-py3-none-any.whl` `c33c069c8cd9c270562200339b631fb77423b5b7f24364b5f0a6a0730e5ca8b8` `openadapt-1.17.0.tar.gz` `03926f94f4d9d37eeb8c6c9cbdc94516d43c0ace971f2282b3310d77d73b6547` |
-| flow | `openadapt-flow` | `2.5.0` | [`openadapt-flow-v2.5.0`](https://github.com/OpenAdaptAI/OpenAdapt/tree/openadapt-flow-v2.5.0/packages/flow) commit `5a3bf49a0bf2d662430386295a1c34036c064d58` tree `cab536c90e79fcfe887b8876e884ec2ed651f23e` | `openadapt_flow-2.5.0-py3-none-any.whl` `1dbc5d42d103cfa8a452d95805dde60c7d460035ec4d81df5cc642a32f118339` `openadapt_flow-2.5.0.tar.gz` `54d99e77976ee7d9e6bc171b2ea148497528ead77b226fdf6fd4713b37c689b3` |
+| launcher | `openadapt` | `1.19.0` | [`v1.19.0`](https://github.com/OpenAdaptAI/OpenAdapt/tree/v1.19.0) commit `1a64acd47bf6e60de20b7ac97993bd23122eb697` tree `4ff3db2dc38334cd3957e66f89c95ea292abd9b8` | `openadapt-1.19.0-py3-none-any.whl` `0a6963404d5b2a83aa83f8701c017838a8d09878128264b729064515b722a8b4` `openadapt-1.19.0.tar.gz` `fc513011804644133ba8c93d41979cf2136829316839afb870e5092079551887` |
+| flow | `openadapt-flow` | `2.6.0` | [`openadapt-flow-v2.6.0`](https://github.com/OpenAdaptAI/OpenAdapt/tree/openadapt-flow-v2.6.0/packages/flow) commit `1c832365d11e9dbe2a166485b13f7b8effd6d8d0` tree `954b03e36d350803bec59046ffaf56c02c3d556b` | `openadapt_flow-2.6.0-py3-none-any.whl` `43718301bdb2579b1a78c60dfc025e8d60f2be9a3bc4172894c4194e0600fa9a` `openadapt_flow-2.6.0.tar.gz` `4f1b2b9e28ec666c0e50ef2593d985ba387efc42bfb9b8f9f24148f6c8085112` |
| capture | `openadapt-capture` | `1.2.2` | [`v1.2.2`](https://github.com/OpenAdaptAI/openadapt-capture/tree/v1.2.2) commit `06ade22948c12dcc6608bd54b5cca5c9c306e77c` tree `b0a3b5b53f23536e01314f36aa46fd5b2d8baba6` | `openadapt_capture-1.2.2-py3-none-any.whl` `550fffe50990fa8431d332bdd5fe4755976d83baa7ad62553f50ad9e4ddcd1ae` `openadapt_capture-1.2.2.tar.gz` `6f4298d3daadf9cced8994207e34b3116a79209ef792e98f778a0770e2f3406c` |
| privacy | `openadapt-privacy` | `1.1.2` | [`v1.1.2`](https://github.com/OpenAdaptAI/openadapt-privacy/tree/v1.1.2) commit `9ae96d8ed73ea5b9e445d0604b5db48788936a04` tree `fe59ca5f04fc99d16424e9228dcd797b05115cb8` | `openadapt_privacy-1.1.2-py3-none-any.whl` `6e542b2fea44bdf8872430687946ec805fef22dc7d228a56a1e65871beebe47a` `openadapt_privacy-1.1.2.tar.gz` `d90287edaa13470955f15d5f6eeb37b075678aeb55de324f8b8207682009bcc2` |
| types | `openadapt-types` | `0.24.0` | [`openadapt-types-v0.24.0`](https://github.com/OpenAdaptAI/OpenAdapt/tree/openadapt-types-v0.24.0/packages/types) commit `f2f04c5b432d88b262d46103a542b0e2c3dcc8a9` tree `38ccf341ec74a0dd0facc9f64343c27644a7ca7d` | `openadapt_types-0.24.0-py3-none-any.whl` `8025e8a4dc9235fd81d4b24ae7fa7e544120abdb8d4ee7df160e4f50d9230d75` `openadapt_types-0.24.0.tar.gz` `16dcd1da1c2c78c08d650a19eb9cc439474f996812a87198b316d51bfac443db` |
-| desktop | `openadapt-desktop` | `0.16.0` | [`v0.16.0`](https://github.com/OpenAdaptAI/openadapt-desktop/tree/v0.16.0) commit `7c59750d37be09a2871cd1bada8cb0bce946d363` tree `9a1307794df6f2c77ee3dab90973845f3d08ee53` | `openadapt_desktop-0.16.0-py3-none-any.whl` `06e608a934deef56c93ee1ca8de511d6daba08459626e8245bd0db4c391e64e9` `openadapt_desktop-0.16.0.tar.gz` `15f0c59a4d481328da76f3e5aa6a29ab8cdced8ab183c97346d69b4c14c996ff` |
+| desktop | `openadapt-desktop` | `0.25.1` | [`openadapt-desktop-v0.25.1`](https://github.com/OpenAdaptAI/OpenAdapt/tree/openadapt-desktop-v0.25.1/packages/desktop) commit `b21a86d4a263f2bd8c4e8037e81f09457e998bdb` tree `77acb2dfaab10e413f0d348ce98fbff9e24da752` | `openadapt_desktop-0.25.1-py3-none-any.whl` `5f55ecffe173d0f7740cbbf011c19b763a495d9e1b37054f4f6ccf16a07a7be2` `openadapt_desktop-0.25.1.tar.gz` `5a9c22996b76a0efbcfa87d3710d88611ab2feb772de284d7233b9d38b04241b` |
| agent | `openadapt-agent` | `2.2.1` | [`openadapt-agent-v2.2.1`](https://github.com/OpenAdaptAI/OpenAdapt/tree/openadapt-agent-v2.2.1/packages/agent) commit `8d0758b9687744b07355c6512b6a57942bec4bb4` tree `5300b0849929dd3051c57640ba6fcd74ac483728` | `openadapt_agent-2.2.1-py3-none-any.whl` `847499c4091144cb0c1bb1d2731ef064ae9cd844867c2b603acf573c6e12928a` `openadapt_agent-2.2.1.tar.gz` `859e6b7d464ab813aba85af7f1f227ac9afcdb60112bc564719a82d3766fb733` |
## Runtime selections
| Unit | Version | Selected or locked OpenAdapt packages |
|---|---:|---|
-| launcher_environment | `1.17.0` | `openadapt==1.17.0` `openadapt-flow==2.5.0` |
-| customer_runner | `2.5.0` | `openadapt-flow==2.5.0` `openadapt-types==0.24.0` |
-| desktop_sidecar | `0.16.0` | `openadapt-capture==1.2.1` `openadapt-flow==1.34.0` `openadapt-privacy==1.0.0` `openadapt-types==0.10.0` |
-| agent_bridge | `2.2.1` | `openadapt-agent==2.2.1` `openadapt-flow==2.5.0` |
+| launcher_environment | `1.19.0` | `openadapt==1.19.0` `openadapt-flow==2.6.0` |
+| customer_runner | `2.6.0` | `openadapt-flow==2.6.0` `openadapt-types==0.24.0` |
+| desktop_sidecar | `0.25.1` | `openadapt-capture==1.3.0` `openadapt-flow==2.4.1` `openadapt-privacy==1.1.3` `openadapt-types==0.24.0` |
+| agent_bridge | `2.2.1` | `openadapt-agent==2.2.1` `openadapt-flow==2.6.0` |
## Selected-platform dependency compatibility
@@ -34,33 +34,33 @@ These edges show whether each selected integration version satisfies its publish
| Scope | Source | Target | Constraint | Selected | Activation |
|---|---|---|---|---:|---|
-| desktop-sidecar-lock | `openadapt-desktop==0.16.0` | `openadapt-capture` | `>=1.2.1` | `1.2.1` | `always` |
-| desktop-sidecar-lock | `openadapt-desktop==0.16.0` | `openadapt-flow` | `==1.34.0` | `1.34.0` | `extra == "build"` |
-| desktop-sidecar-lock | `openadapt-desktop==0.16.0` | `openadapt-privacy` | `>=1.0.0` | `1.0.0` | `always` |
-| desktop-sidecar-lock | `openadapt-desktop==0.16.0` | `openadapt-types` | `==0.10.0` | `0.10.0` | `always` |
-| selected-platform | `openadapt-agent==2.2.1` | `openadapt-flow` | `<3,>=2.2.0` | `2.5.0` | `always` `extra == "tutorial"` |
+| desktop-sidecar-lock | `openadapt-desktop==0.25.1` | `openadapt-capture` | `>=1.3.0` | `1.3.0` | `always` |
+| desktop-sidecar-lock | `openadapt-desktop==0.25.1` | `openadapt-flow` | `==2.4.1` | `2.4.1` | `extra == "build"` |
+| desktop-sidecar-lock | `openadapt-desktop==0.25.1` | `openadapt-privacy` | `>=1.0.0` | `1.1.3` | `always` |
+| desktop-sidecar-lock | `openadapt-desktop==0.25.1` | `openadapt-types` | `<0.25.0,>=0.24.0` | `0.24.0` | `always` |
+| selected-platform | `openadapt-agent==2.2.1` | `openadapt-flow` | `<3,>=2.2.0` | `2.6.0` | `always` `extra == "tutorial"` |
| selected-platform | `openadapt-agent==2.2.1` | `openadapt-privacy` | `>=1.1.2` | `1.1.2` | `extra == "repair-screens"` |
| selected-platform | `openadapt-capture==1.2.2` | `openadapt-privacy` | `>=0.1.0` | `1.1.2` | `extra == "all"` `extra == "privacy"` |
-| selected-platform | `openadapt-flow==2.5.0` | `openadapt-capture` | `>=1.2.0` | `1.2.2` | `extra == "capture"` |
-| selected-platform | `openadapt-flow==2.5.0` | `openadapt-privacy` | `>=1.0.0` | `1.1.2` | `extra == "privacy"` |
-| selected-platform | `openadapt-flow==2.5.0` | `openadapt-types` | `<0.25.0,>=0.24.0` | `0.24.0` | `always` |
-| selected-platform | `openadapt==1.17.0` | `openadapt-agent` | `<3,>=2.1.0` | `2.2.1` | `python_version < "3.13"` `python_version < "3.13" and extra == "agent"` `python_version < "3.13" and extra == "all"` |
-| selected-platform | `openadapt==1.17.0` | `openadapt-capture` | `<2.0.0,>=1.2.0` | `1.2.2` | `extra == "all"` `extra == "capture"` `extra == "core"` |
-| selected-platform | `openadapt==1.17.0` | `openadapt-flow` | `<3.0.0,>=2.0.0` | `2.5.0` | `(sys_platform == "darwin" and python_version < "3.13") and extra == "all"` `(sys_platform == "darwin" and python_version < "3.13") and extra == "macos"` `(sys_platform == "linux" and python_version < "3.13") and extra == "all"` `(sys_platform == "linux" and python_version < "3.13") and extra == "linux"` `python_version < "3.13"` `python_version < "3.13" and extra == "all"` `python_version < "3.13" and extra == "browser"` `python_version < "3.13" and extra == "capture"` `python_version < "3.13" and extra == "core"` `python_version < "3.13" and extra == "flow"` `python_version < "3.13" and extra == "privacy"` `python_version < "3.13" and extra == "rdp"` `python_version < "3.13" and extra == "windows"` |
+| selected-platform | `openadapt-flow==2.6.0` | `openadapt-capture` | `>=1.2.0` | `1.2.2` | `extra == "capture"` |
+| selected-platform | `openadapt-flow==2.6.0` | `openadapt-privacy` | `>=1.0.0` | `1.1.2` | `extra == "privacy"` |
+| selected-platform | `openadapt-flow==2.6.0` | `openadapt-types` | `<0.25.0,>=0.24.0` | `0.24.0` | `always` |
+| selected-platform | `openadapt==1.19.0` | `openadapt-agent` | `<3,>=2.1.0` | `2.2.1` | `python_version < "3.13"` `python_version < "3.13" and extra == "agent"` `python_version < "3.13" and extra == "all"` |
+| selected-platform | `openadapt==1.19.0` | `openadapt-capture` | `<2.0.0,>=1.2.0` | `1.2.2` | `extra == "all"` `extra == "capture"` `extra == "core"` |
+| selected-platform | `openadapt==1.19.0` | `openadapt-flow` | `<3.0.0,>=2.5.0` | `2.6.0` | `(sys_platform == "darwin" and python_version < "3.13") and extra == "all"` `(sys_platform == "darwin" and python_version < "3.13") and extra == "macos"` `(sys_platform == "linux" and python_version < "3.13") and extra == "all"` `(sys_platform == "linux" and python_version < "3.13") and extra == "linux"` `python_version < "3.13"` `python_version < "3.13" and extra == "all"` `python_version < "3.13" and extra == "browser"` `python_version < "3.13" and extra == "capture"` `python_version < "3.13" and extra == "core"` `python_version < "3.13" and extra == "flow"` `python_version < "3.13" and extra == "privacy"` `python_version < "3.13" and extra == "rdp"` `python_version < "3.13" and extra == "windows"` |
## Cross-component schemas
| Contract | Owner | Accepted versions | Exact source |
|---|---|---|---|
-| `workflow_bundle` | `flow` | `schema_version` [1, 2] | [`packages/flow/openadapt_flow/bundle_validation.py`](https://github.com/OpenAdaptAI/OpenAdapt/blob/5a3bf49a0bf2d662430386295a1c34036c064d58/packages/flow/openadapt_flow/bundle_validation.py) `63532fb8e49f25a8aba8dccca505362a9293b8db9cbc0f2904dd8f9a6e23fea8` [`packages/flow/openadapt_flow/ir.py`](https://github.com/OpenAdaptAI/OpenAdapt/blob/5a3bf49a0bf2d662430386295a1c34036c064d58/packages/flow/openadapt_flow/ir.py) `efb7fdce6fa625f631901389ca767b016f4db3111caa2905321ff3da675e376d` |
+| `workflow_bundle` | `flow` | `schema_version` [1, 2] | [`packages/flow/openadapt_flow/bundle_validation.py`](https://github.com/OpenAdaptAI/OpenAdapt/blob/1c832365d11e9dbe2a166485b13f7b8effd6d8d0/packages/flow/openadapt_flow/bundle_validation.py) `63532fb8e49f25a8aba8dccca505362a9293b8db9cbc0f2904dd8f9a6e23fea8` [`packages/flow/openadapt_flow/ir.py`](https://github.com/OpenAdaptAI/OpenAdapt/blob/1c832365d11e9dbe2a166485b13f7b8effd6d8d0/packages/flow/openadapt_flow/ir.py) `509e9ef8666ec65496b4b7afdd0b2bc8af4ba0663e64a4079ce1813950bf028e` |
| `capture_structural_observation` | `capture` | `openadapt.capture.structural-observation/v` [1] | [`openadapt_capture/structural.py`](https://github.com/OpenAdaptAI/openadapt-capture/blob/06ade22948c12dcc6608bd54b5cca5c9c306e77c/openadapt_capture/structural.py) `8f8bce37148a3357ace147c46a7c45e6d5501b70622b24b1ac2490f2def304fc` |
| `control_overlay_frame` | `types` | `openadapt.control-overlay-frame/v` [1, 2] | [`packages/types/openadapt_types/control_overlay.py`](https://github.com/OpenAdaptAI/OpenAdapt/blob/f2f04c5b432d88b262d46103a542b0e2c3dcc8a9/packages/types/openadapt_types/control_overlay.py) `3aa01f89b43e12478c9e2ff1a9e61689397370691d15247ded9dcd5b05242790` [`packages/types/openadapt_types/control_overlay_tracking.py`](https://github.com/OpenAdaptAI/OpenAdapt/blob/f2f04c5b432d88b262d46103a542b0e2c3dcc8a9/packages/types/openadapt_types/control_overlay_tracking.py) `af8d934f0572c5f4e9c63ba720d4a1d0e73c8cdf3f7b87cc4562c01a8a551755` |
| `control_overlay_timeline` | `types` | `openadapt.control-overlay-timeline/v` [1, 2] | [`packages/types/openadapt_types/control_overlay.py`](https://github.com/OpenAdaptAI/OpenAdapt/blob/f2f04c5b432d88b262d46103a542b0e2c3dcc8a9/packages/types/openadapt_types/control_overlay.py) `3aa01f89b43e12478c9e2ff1a9e61689397370691d15247ded9dcd5b05242790` [`packages/types/openadapt_types/control_overlay_tracking.py`](https://github.com/OpenAdaptAI/OpenAdapt/blob/f2f04c5b432d88b262d46103a542b0e2c3dcc8a9/packages/types/openadapt_types/control_overlay_tracking.py) `af8d934f0572c5f4e9c63ba720d4a1d0e73c8cdf3f7b87cc4562c01a8a551755` |
| `human_decision_task` | `types` | `openadapt.human-decision-task/v` [1, 2] | [`packages/types/openadapt_types/human_decision.py`](https://github.com/OpenAdaptAI/OpenAdapt/blob/f2f04c5b432d88b262d46103a542b0e2c3dcc8a9/packages/types/openadapt_types/human_decision.py) `0bb39a3575f511e10d01d92858b340982adee2f0fc20d2409441dbc0b2c55c4f` |
| `human_decision_receipt` | `types` | `openadapt.human-decision-receipt/v` [1] | [`packages/types/openadapt_types/human_decision.py`](https://github.com/OpenAdaptAI/OpenAdapt/blob/f2f04c5b432d88b262d46103a542b0e2c3dcc8a9/packages/types/openadapt_types/human_decision.py) `0bb39a3575f511e10d01d92858b340982adee2f0fc20d2409441dbc0b2c55c4f` |
-| `human_decision_relay` | `flow` | `openadapt.human-decision-relay/v` [2] | [`packages/flow/openadapt_flow/console/decision_relay.py`](https://github.com/OpenAdaptAI/OpenAdapt/blob/5a3bf49a0bf2d662430386295a1c34036c064d58/packages/flow/openadapt_flow/console/decision_relay.py) `d5320404d4342dbb52be1f2ead1cdb5cf0e22877feaaa1e47974b301df697292` |
-| `remote_decision_projection` | `flow` | `openadapt.remote-decision-projection/v` [1] | [`packages/flow/openadapt_flow/console/human_decisions.py`](https://github.com/OpenAdaptAI/OpenAdapt/blob/5a3bf49a0bf2d662430386295a1c34036c064d58/packages/flow/openadapt_flow/console/human_decisions.py) `02651ad8888a5c17bfff6fb47165ba861193243dc0f7c951f8b94f544ca19477` |
-| `runtime_validation` | `flow` | `openadapt.runtime-validation/v` [1, 2, 3] | [`packages/flow/openadapt_flow/runtime_validation.py`](https://github.com/OpenAdaptAI/OpenAdapt/blob/5a3bf49a0bf2d662430386295a1c34036c064d58/packages/flow/openadapt_flow/runtime_validation.py) `a5a220f570c5ee5769ac2f21d8d140bb69373d4de2ccc0ebfd08540122883f0e` |
+| `human_decision_relay` | `flow` | `openadapt.human-decision-relay/v` [2] | [`packages/flow/openadapt_flow/console/decision_relay.py`](https://github.com/OpenAdaptAI/OpenAdapt/blob/1c832365d11e9dbe2a166485b13f7b8effd6d8d0/packages/flow/openadapt_flow/console/decision_relay.py) `d5320404d4342dbb52be1f2ead1cdb5cf0e22877feaaa1e47974b301df697292` |
+| `remote_decision_projection` | `flow` | `openadapt.remote-decision-projection/v` [1] | [`packages/flow/openadapt_flow/console/human_decisions.py`](https://github.com/OpenAdaptAI/OpenAdapt/blob/1c832365d11e9dbe2a166485b13f7b8effd6d8d0/packages/flow/openadapt_flow/console/human_decisions.py) `02651ad8888a5c17bfff6fb47165ba861193243dc0f7c951f8b94f544ca19477` |
+| `runtime_validation` | `flow` | `openadapt.runtime-validation/v` [1, 2, 3] | [`packages/flow/openadapt_flow/runtime_validation.py`](https://github.com/OpenAdaptAI/OpenAdapt/blob/1c832365d11e9dbe2a166485b13f7b8effd6d8d0/packages/flow/openadapt_flow/runtime_validation.py) `a5a220f570c5ee5769ac2f21d8d140bb69373d4de2ccc0ebfd08540122883f0e` |
## Host operating systems
@@ -82,5 +82,5 @@ Compatibility and delivery facts do not assign lifecycle state. Use the signed P
## Qualification evidence
- [openadapt-public-status](https://openadapt.ai/status.json): Canonical machine-readable release, capability, and substrate qualification status, maintained in openadapt-web (public/status.json).
-- [flow-public-demo-evidence-packs](https://github.com/OpenAdaptAI/OpenAdapt/tree/5a3bf49a0bf2d662430386295a1c34036c064d58/packages/flow/public-demo/evidence-packs): Versioned public evidence packs with per-pack manifest files, replay evidence, and fault-case results.
-- [flow-effectbench-task-pack](https://github.com/OpenAdaptAI/OpenAdapt/blob/5a3bf49a0bf2d662430386295a1c34036c064d58/packages/flow/benchmark/effectbench/task_pack/manifest.json): Effect-verification benchmark task pack manifest used by the flow qualification harness.
+- [flow-public-demo-evidence-packs](https://github.com/OpenAdaptAI/OpenAdapt/tree/1c832365d11e9dbe2a166485b13f7b8effd6d8d0/packages/flow/public-demo/evidence-packs): Versioned public evidence packs with per-pack manifest files, replay evidence, and fault-case results.
+- [flow-effectbench-task-pack](https://github.com/OpenAdaptAI/OpenAdapt/blob/1c832365d11e9dbe2a166485b13f7b8effd6d8d0/packages/flow/benchmark/effectbench/task_pack/manifest.json): Effect-verification benchmark task pack manifest used by the flow qualification harness.
diff --git a/llms-full.txt b/llms-full.txt
index ec09e0ba23..86de7479c0 100644
--- a/llms-full.txt
+++ b/llms-full.txt
@@ -503,7 +503,7 @@ monorepo and installable from source.
| Learns branching programs from several demonstrations, from repeated halts, and from many background runs. | `induce`, `teach-branch`, [SHADOW_MINING.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/capture/docs/SHADOW_MINING.md), [LEARN_FROM_MANY_RUNS.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/LEARN_FROM_MANY_RUNS.md) |
| Chains programs across surfaces, and one program can switch surfaces, tabs and popups mid-run. | `compose`, `process`, [#1197](https://github.com/OpenAdaptAI/OpenAdapt/pull/1197) |
| Uses page structure first on web, Windows, macOS and Linux, with pixels as the fallback for remote desktops. Benchmarks labelled "vision-only" ran a deliberately pixel-only arm. | [RESOLUTION_LADDER.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/RESOLUTION_LADDER.md) |
-| Replaces a single HTML form submission with the request the app itself sends, compiled from the network traffic captured while recording, then reads the record back. JSON or GraphQL requests, multi-step wizards and governed runs still use the screen steps. | `api`, [API_TIER.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/API_TIER.md) |
+| Replaces a single HTML form submission with the request the app itself sends, compiled from the network traffic captured while recording, then reads the record back. URL-encoded and file-less multipart saves, the clicked button's value, and saves the page's script sends to another endpoint are covered. JSON or GraphQL requests, uploads, multi-step wizards and governed runs still use the screen steps. | `api`, [API_TIER.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/API_TIER.md) |
| Builds the record check itself. After a save, the app usually reads the record back to show it; `scaffold-verifier` finds that read in the captured traffic or a read-only database, builds the check from it and tries to break it before a person reviews it. No API or SQL needs to be written by hand. | [EFFECT_KIT.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/EFFECT_KIT.md#a-record-check-built-from-the-apps-own-reads-scaffold-verifier) |
| Checks a save on desktop, RDP and Citrix apps with no API at all, by looking the record up in the app's own screens in a separate session. | [EFFECT_KIT.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/EFFECT_KIT.md#separate-session-desktop-read-back-desktop_readback) |
| Keeps pixel-path runs working across display scaling from 100% to 175% and font smoothing on or off, and stops rather than saves when the record changed. | [benchmark/pixel_scale](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/benchmark/pixel_scale/README.md) |
@@ -633,6 +633,7 @@ improvising or retrying when something is off.
| One program that switches surfaces, tabs and popups mid-run | Released ([#1197](https://github.com/OpenAdaptAI/OpenAdapt/pull/1197)) | Real Chromium; Windows switch on a mock | [#1197](https://github.com/OpenAdaptAI/OpenAdapt/pull/1197) |
| Halt when a workflow qualified with page structure runs on a pixels-only surface | Released | Synthetic | `openadapt-flow qualify` |
| Stop before acting when another shown row has the same identity; when the rows carry an app-owned record id (a `data-*` id, the row link), act on the row with the recorded id; on a surface that cannot count the rows, stop before any step that leads into a write | Released ([#1504](https://github.com/OpenAdaptAI/OpenAdapt/pull/1504)) | Real browser (headless Chromium worklist with identical rows) | `runtime/identity_census.py`; `PlaywrightBackend.identity_candidates_at` |
+| Read a dialog target's identity on the topmost modal dialog alone, not the page behind it, and stop when no dialog holds the target | On main ([#1615](https://github.com/OpenAdaptAI/OpenAdapt/pull/1615)) | Real app (local OpenEMR, synthetic data): at the duplicate dialog with the form scrolled to 8 positions behind it, 0 of 8 false stops, against 8 of 8 before; a dialog listing a possible duplicate still stops | `PlaywrightBackend.dialog_region_at`, `Anchor.dialog_region`; [dialog_identity_2026-10-28](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/benchmark/openemr_throughput/dialog_identity_2026-10-28/README.md) |
| Lift look-alike identity stops with a complete known-ID list | Released | Synthetic (rendered record list) | `openadapt-flow known-ids` |
| Take extra looks before stopping on a look-alike record ID: fresh frames, the known-ID list, and a select-and-copy of the ID where the surface allows it | Released ([#1333](https://github.com/OpenAdaptAI/OpenAdapt/pull/1333)) | Synthetic (dense record list in a browser) | `runtime.identity_copy_look` in deployment.yaml; [IDENTITY_LOOKS.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/benchmark/identity_looks/IDENTITY_LOOKS.md) |
| Check an icon-only click target's hover response (highlight, tooltip) before clicking on a pixel surface, and re-check the target and identity regions on a fresh frame right before every pixel click | Released ([#1334](https://github.com/OpenAdaptAI/OpenAdapt/pull/1334)) | Synthetic (in-process kiosk) | `Recorder(capture_hover=True)`; [hover_identity](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/benchmark/hover_identity/README.md) |
@@ -646,6 +647,7 @@ improvising or retrying when something is off.
| Capture a recording's network traffic and compile a clean save into an API step, with the same record check | Released ([#1269](https://github.com/OpenAdaptAI/OpenAdapt/pull/1269)) | Synthetic patients on a local OpenEMR test copy | network capture, `ApiBinding` |
| Fall back from the API step to page structure when the API is unavailable, before anything is sent | Released ([#1269](https://github.com/OpenAdaptAI/OpenAdapt/pull/1269)) | Synthetic | [#1269](https://github.com/OpenAdaptAI/OpenAdapt/pull/1269) |
| Run a form's own script before an API save instead of refusing the form: field handlers, `onsubmit`, submit listeners and custom validate.js rules (OpenEMR's `pastDate`) run on the run's values in a scratch copy of the session with every write blocked, and the save is sent only when the page lets the submit through and would send the same body | On main | Synthetic (headless Chromium against a local test page) | [API_TIER.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/API_TIER.md#what-happens-at-run-time), `runtime/actuators/form_eval.py` |
+| Propose API steps for more save shapes: multipart saves with no file (encoded as Chromium encodes them), the clicked submit button's `name=value`, empty file inputs, forms with no `action`, and saves the page's script sends to another endpoint (run through the page's own script before every save); success addresses keep a record's path with numbers and hashes generalized | This PR | Headless Chromium against local test pages, and recordings from Redmine, WordPress, NetBox, Dolibarr and Trac fixtures | [API_TIER.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/API_TIER.md#what-the-compiler-checks) |
| Declare the other writes in an API step's span as an ordered sequence: the page's own background requests (another document's script, loaded before the form, to an endpoint the form doesn't name) are listed and not sent; a write a step caused is sent in its recorded place before the save, read back by that step's record check, and a failure stops the run with nothing after it sent and nothing retried | On main | Synthetic | [API_TIER.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/API_TIER.md#other-writes-in-the-span) |
| Approve an API step's exact request under per-item approval: one digest binds the request bytes, the record and the record check, and a changed request is not sent | Released ([#1355](https://github.com/OpenAdaptAI/OpenAdapt/pull/1355)) | Synthetic | [API_TIER.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/API_TIER.md#under-an-approval-mode) |
| Run an API step in a durable run: checkpoint the span after the save is sent, never resend or redo a save that may have landed, and reconcile it by reading the record back | Released ([#1358](https://github.com/OpenAdaptAI/OpenAdapt/pull/1358)) | Synthetic | [API_TIER.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/API_TIER.md#in-a-durable-run) |
diff --git a/openadapt/cli.py b/openadapt/cli.py
index cb1306fc9e..d0a9b0b32e 100644
--- a/openadapt/cli.py
+++ b/openadapt/cli.py
@@ -3,7 +3,7 @@
Usage:
openadapt flow record --url --out rec # record a workflow
openadapt flow compile rec --out bundle # compile it
- openadapt flow replay bundle # run it, local, $0
+ openadapt flow replay bundle --url # run it, local, $0
openadapt flow lint bundle
openadapt flow certify bundle --policy clinical-write
@@ -117,7 +117,7 @@ def main():
openadapt flow compile rec --out bundle --name demo
openadapt flow lint bundle
openadapt flow certify bundle --policy permissive
- openadapt flow replay bundle
+ openadapt flow replay bundle --demo-app
Lint lists the demo's coverage gaps. The demo has warnings, so
`lint --strict` exits 1 on it.
@@ -849,9 +849,9 @@ def _warn_about_launcher_conflicts() -> None:
_SECRET_REFERENCE = re.compile(r"^(?:env:[A-Z][A-Z0-9_]*|keychain:[^/\s]+/[^/\s]+)$")
# Flow 1.33 and 1.34 probe four X11 libraries in the wrong case, so on Linux
# they refuse to download Chromium on hosts that have them. 1.35.0 fixed it.
-# Launcher 1.19 requires Flow 2.5: `openadapt shadow` forwards to its shadow
-# command, and `openadapt verify serve` offers its recheck.
-_SUPPORTED_FLOW_RANGE = ">=2.5.0,<3.0.0"
+# Launcher 1.20 requires Flow 2.6: ledger listing uses its persistent
+# owed-recheck API.
+_SUPPORTED_FLOW_RANGE = ">=2.6.0,<3.0.0"
_RDP_INSTALL_COMMAND = _pip_install("openadapt[rdp]")
_CHROMIUM_SYSTEM_LIBS_COMMAND = (
f"{shlex.quote(sys.executable)} -m playwright install-deps chromium"
@@ -861,7 +861,7 @@ def _warn_about_launcher_conflicts() -> None:
def _supported_flow_version(value: str) -> bool:
"""Return whether a stable Flow version is in the launcher's supported range."""
parsed = _release_tuple(value)
- return parsed is not None and (2, 5, 0) <= parsed < (3, 0, 0)
+ return parsed is not None and (2, 6, 0) <= parsed < (3, 0, 0)
# The Python modules each native or remote Flow backend imports when its
@@ -1350,12 +1350,23 @@ def verify_ledger() -> None:
@verify_ledger.command("list")
@_LEDGER_OPTION
def verify_ledger_list(ledger_dir: Optional[str]) -> None:
- """Show held and open writes: key, state, when it began, outcome."""
+ """Show held and open writes: key, state, when it began, outcome.
+
+ Also each provisional CONFIRMED whose recheck is still owed, with its due
+ time, so a recheck isn't lost when the server that holds it restarts.
+ """
ledger = _write_ledger(ledger_dir)
rows = ledger.unresolved()
- if not rows:
- click.echo("No held or open writes.")
+ owed = ledger.rechecks_owed()
+ if not rows and not owed:
+ click.echo("No held or open writes. No rechecks owed.")
return
+ for key, attempt in owed:
+ click.echo(
+ f"{key} recheck-owed began {attempt.begun.get('at')} "
+ f"due {attempt.recheck_owed} by {attempt.begun.get('actor_kind')}:"
+ f"{attempt.begun.get('actor_id')}"
+ )
for key, attempt in rows:
outcome = (attempt.resolved or {}).get("outcome", "-")
state = "review" if attempt.review else ledger.state(attempt)
diff --git a/packages/desktop/scripts/smoke_test_frozen_flow.py b/packages/desktop/scripts/smoke_test_frozen_flow.py
index e836bc71aa..cb3eb1b3bb 100644
--- a/packages/desktop/scripts/smoke_test_frozen_flow.py
+++ b/packages/desktop/scripts/smoke_test_frozen_flow.py
@@ -663,11 +663,16 @@ def main() -> int:
],
env=env,
)
+ # A Flow that serves the demo app only when asked takes --demo-app;
+ # an older pinned Flow serves it whenever no URL is given.
+ replay_help, _ = _run([*flow, "replay", "--help"], env=env)
+ demo_app = ["--demo-app"] if "--demo-app" in replay_help else []
_, replay_seconds = _run(
[
*flow,
"replay",
str(root / "bundle"),
+ *demo_app,
"--run-dir",
str(root / "run"),
],
diff --git a/packages/flow/.github/ISSUE_TEMPLATE/bug_report.yml b/packages/flow/.github/ISSUE_TEMPLATE/bug_report.yml
index be6258e74a..3c4e088c8e 100644
--- a/packages/flow/.github/ISSUE_TEMPLATE/bug_report.yml
+++ b/packages/flow/.github/ISSUE_TEMPLATE/bug_report.yml
@@ -22,7 +22,7 @@ body:
placeholder: |
1. openadapt-flow demo-record --out rec
2. openadapt-flow compile rec --out bundle --name my-task
- 3. openadapt-flow replay bundle
+ 3. openadapt-flow replay bundle --demo-app
validations:
required: true
- type: textarea
diff --git a/packages/flow/CHANGELOG.md b/packages/flow/CHANGELOG.md
index ffcdd9ddb4..75a582a84b 100644
--- a/packages/flow/CHANGELOG.md
+++ b/packages/flow/CHANGELOG.md
@@ -17,6 +17,10 @@
## Unreleased
+### Changed
+
+- No command falls back to the demo app on its own. `replay` needs a target: `--url`, `backend.url` in `--config`, or `--demo-app` to serve the bundled MockMed (the tutorial and quickstart commands pass it; `--drift` needs it). The variant router's first-screen probe captures nothing without a URL. `qualify accept`'s break-it check no longer runs every workflow's oracle against MockMed: it builds the lie from the workflow itself (a store that did not change, and a screen showing the workflow's own success text and every value the run typed). The reference Execute worker refuses, before running anything, an admission whose `target_url` it cannot drive, instead of resetting and reading that app as if it were the demo.
+
### Fixes
- The OpenEMR contracts (`benchmark/lifecycle_proof/verify.yaml`, `benchmark/openemr_local/verify.yaml`) now declare how the New Patient form stores what was typed: street and city in capitals, names with each word capitalized. Without it the exact record check refuted 19 of 20 correct saves on the stand-in suites (#1811). A contract value's `stored` form gains `capitalize_words` (OpenEMR's "C" fields); the set stays closed and the check stays exact.
diff --git a/packages/flow/README.md b/packages/flow/README.md
index 7e7fbdc5ca..600d4720e4 100644
--- a/packages/flow/README.md
+++ b/packages/flow/README.md
@@ -265,7 +265,9 @@ no implicit browser default in production; with a hand-written config, set
`backend.kind` or pass `--backend`.
`certify` checks the bundle against a policy. `clinical-write` is the strict
-built-in policy, and `permissive` is a smoke check. A new recording usually
+built-in policy, `record-write` asks for the same evidence for a system of
+record that is not clinical (claims or back-office, say), and `permissive` is
+a smoke check. A new recording usually
fails `clinical-write` until its contracts are complete; `lint` lists the
gaps. `run --profile standard` checks the policy again, replays, and reads the
record back.
diff --git a/packages/flow/benchmark/api_generalization/PROTOCOL.md b/packages/flow/benchmark/api_generalization/PROTOCOL.md
new file mode 100644
index 0000000000..d6dcb51291
--- /dev/null
+++ b/packages/flow/benchmark/api_generalization/PROTOCOL.md
@@ -0,0 +1,138 @@
+# Protocol: does the API step generalize beyond the OpenEMR referral?
+
+## Question
+
+The API step ([docs/API_TIER.md](../../docs/API_TIER.md)) replaces one HTML
+form save with the request the app's own page sends, then reads the record
+back. Its evidence so far is one workflow on one app (the OpenEMR referral).
+This experiment asks whether the same compiler and runtime, unchanged, cover
+other apps that save records through server-rendered HTML forms, and whether
+they stay safe when they do.
+
+## Frozen engine
+
+The compiler and runtime are frozen at one `main` commit taken after the six
+review fixes merged (JSON redaction, provenance-based parameter binding, URL
+identifiers, refusing spans with other writes, HTML validation, hashing the
+full replaced-step semantics). The commit is recorded in
+`results/engine.json` and in every `compile.json` and `runs.csv` row
+(`engine_sha`). Every run checks `git diff -- openadapt_flow` is
+empty. No compiler or runtime code changes during the experiment; bugs found
+are logged in the report and the experiment continues.
+
+## Apps and workflows
+
+Five unrelated open-source apps, each run locally in a container as an
+external service (no app source is copied into this repository), with
+synthetic data only. One or two save workflows each, chosen as the
+ordinary "create a record" task of each app, not by whether the API step
+would cover it:
+
+| App (license) | Kind | Workflow | Adapter |
+| --- | --- | --- | --- |
+| Redmine 6.0 (GPL-2.0) | ticketing, Rails | new issue | `apps/redmine.py` |
+| WordPress 6.6 (GPL-2.0) | CMS, PHP | visitor posts a comment | `apps/wordpress_comment.py` |
+| WordPress 6.6 | CMS, PHP | editor adds a category | `apps/wordpress_category.py` |
+| NetBox 4.1 (Apache-2.0) | inventory, Django | add an IP address | `apps/netbox_ip.py` |
+| Dolibarr 20 (GPL-3.0) | ERP/CRM, PHP | new contact | `apps/dolibarr_contact.py` |
+| Dolibarr 20 | ERP/CRM, PHP | new customer (third party) | `apps/dolibarr_thirdparty.py` |
+| Trac 1.6 (BSD-3-Clause) | ticketing, Python | new ticket | `apps/trac.py` |
+
+ERPNext was considered and left out: its desk saves records as JSON over
+XHR, not through a server-rendered form, so it is outside the population
+this question is about. Odoo is in the same position.
+
+## Procedure per workflow
+
+1. **Record** (`harness.py record`): a scripted person signs in (not
+ recorded), network capture starts, the form page opens, and the person
+ fills the form and clicks save through `Recorder` on the generic
+ `PlaywrightBackend`. The judge must confirm the demonstration saved the
+ right record, or the recording is redone.
+2. **Compile** (`harness.py compile`): `compile_recording`; the person marks
+ the fields that may be blank as optional parameters; the save step gets
+ the record check (`Effect` list: exactly one record matched by the unique
+ field, and each typed field read back); then `propose_api_step`
+ (`openadapt-flow api propose`). When an API step is proposed, the harness
+ admits it under the name `benchmark-fixture` (`openadapt-flow api admit`
+ in use). `compile.json` keeps the proposal, or the reasons for none.
+3. **Matrix** (`harness.py matrix`): each case below runs in both arms, the
+ arm order alternating by case. Each run gets a fresh browser context,
+ signs in, opens the form, and runs the bundle with
+ `Replayer(api_steps=False)` (arm `gui`) or `Replayer(api_steps=True)`
+ (arm `api`). Values carry a per-run tag so repeated runs never collide
+ with an app's own duplicate rules.
+4. **Timing** (`harness.py timing`), only where an API step ran: normal
+ cases, both arms interleaved, in a quiet window agreed with the
+ coordinator, with the machine's 1-minute load logged per run.
+
+One app fixture runs at a time. Disk is checked before each app; the
+experiment stops below 15 GiB free.
+
+## Cases
+
+| Case | Kind | Condition | The app should end up with |
+| --- | --- | --- | --- |
+| N1-N3 | normal | three ordinary value sets | one new record with the values |
+| B1 | blank | optional fields left blank | one new record, blanks stored empty |
+| D1 | dup_values | the same text in several fields | one new record with the values |
+| C1 | changed_default | after recording, the app's default for a field the person never touches changes (Redmine default priority, NetBox custom-field default, Dolibarr company country, Trac default priority) | one new record holding the **new** default, as a person's save would |
+| S1 | expired_session | the server ends the session after the form is open (session rows or files deleted) | no record |
+| F1 | fault_reject | the proxy answers the save with 503 without forwarding it | no record |
+| F2 | fault_partial | the proxy forwards the save, the app commits, the proxy answers 502 | one record (no second one) |
+| F3 | fault_duplicate | the proxy sends the save to the app twice | two records exist (the fixture made the second); the run must not report success |
+| V1 | validation | a value the server rejects | no record |
+
+Faults are injected by `proxy.py`, a reverse proxy in front of each app that
+both arms go through, on the first request that is the save (path, and for
+Trac, a body without the auto-preview's `preview=`). `C1` is not applicable
+to forms with no app-configurable default the person leaves alone (the
+WordPress comment and category forms, the Dolibarr third-party form). `S1`
+is not applicable to the anonymous WordPress comment.
+
+## Scoring
+
+The judge (`common.judge`) reads the whole record table before and after
+each run through the app's database, with its own query, separate from the
+record check the run itself uses. A run is:
+
+- **correct** when the app holds what it should and the run's own claim
+ matches: success exactly when the expected write happened, or
+ `RECONCILIATION_REQUIRED` after a lost response (F2), and never success
+ when the fixture duplicated the save (F3);
+- an **incorrect write** when the app holds something the run should not
+ have caused: a wrong value, an extra record, a changed or deleted record
+ (the fixture's own duplicate in F3 is not counted against the run);
+- a **false success** when the run reported success and the judge disagrees;
+- a **missed write** when nothing wrong was written but an expected record
+ is missing (an honest stop).
+
+## Measures
+
+Per app and overall: coverage (workflows with an API step proposed and
+admitted against those refused, with the compiler's reasons); incorrect
+writes and false successes; correct refusals and fallbacks to the GUI steps
+(`api_fallback` on the run); manual setup minutes per app (adapter writing
+and fixture bring-up, from the session log); and, where the API step ran,
+engine time against the GUI steps.
+
+## Counterfactual arm (labelled, not a product result)
+
+Development compiles on `main` 3cb70fa6 showed every workflow refused
+before any proposal; three of them (WordPress comment, Dolibarr contact,
+Trac) only because the clicked submit button's own `name=value` is in the
+saved request and the compiler skips submit buttons when it reads the form.
+To find what would block next, and to exercise the frozen **runtime** on new
+apps at all, `counterfactual.py` copies those recordings with only that one
+pair removed from the captured save, and the same frozen compiler runs on
+the copy. Those bundles run as arm `api_cf`. Their numbers describe the
+runtime on these apps under that one input edit; they are not coverage of
+the shipped compiler and are reported apart.
+
+## Known before the protocol was finalized
+
+The development compiles above (on 3cb70fa6, before the review fixes) and a
+development matrix on the Trac counterfactual bundle (used to fix the
+harness: preview posts, required blank parameters, Trac validation rule)
+were seen before this protocol was finalized. Their outputs are not part of
+the results.
diff --git a/packages/flow/benchmark/api_generalization/__init__.py b/packages/flow/benchmark/api_generalization/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/packages/flow/benchmark/api_generalization/apps/__init__.py b/packages/flow/benchmark/api_generalization/apps/__init__.py
new file mode 100644
index 0000000000..8f3b0f70fc
--- /dev/null
+++ b/packages/flow/benchmark/api_generalization/apps/__init__.py
@@ -0,0 +1 @@
+"""One adapter per app (see common.AppBase)."""
diff --git a/packages/flow/benchmark/api_generalization/apps/doli.py b/packages/flow/benchmark/api_generalization/apps/doli.py
new file mode 100644
index 0000000000..d9f1af5139
--- /dev/null
+++ b/packages/flow/benchmark/api_generalization/apps/doli.py
@@ -0,0 +1,58 @@
+"""Dolibarr 20 (ERP/CRM, PHP, MariaDB): the shared fixture.
+
+Runs as an external service from the project's image; nothing from
+Dolibarr (GPL-3.0) is copied here. Synthetic companies and contacts.
+"""
+
+from __future__ import annotations
+
+import subprocess
+from typing import Any
+
+from benchmark.api_generalization.common import AppBase, mysql_rows, podman
+
+POD = "apigen-doli"
+DB_C, APP_C = "apigen-doli-db", "apigen-doli-app"
+APP_IMAGE = "docker.io/dolibarr/dolibarr:20.0.2"
+DB_IMAGE = "docker.io/library/mariadb@sha256:efb4959ef2c835cd735dbc388eb9ad6aab0c78dd64febcd51bc17481111890c4"
+DB_PASS = "synthetic-db-pass" # throwaway local container
+ADMIN_PASS = "synthetic-admin-pass" # throwaway local container
+UPSTREAM, PROXY = 9851, 9852
+
+
+def dsql(query: str) -> list[dict[str, Any]]:
+ return mysql_rows(DB_C, "root", DB_PASS, "dolibarr", query)
+
+
+class DolibarrBase(AppBase):
+ upstream_port = UPSTREAM
+ proxy_port = PROXY
+
+ def up(self) -> None:
+ if subprocess.run(["podman", "pod", "exists", POD]).returncode == 0:
+ podman("pod", "start", POD)
+ self.wait_http("/")
+ return
+ podman("pod", "create", "--name", POD, "-p", f"127.0.0.1:{UPSTREAM}:80")
+ podman("run", "-d", "--pod", POD, "--name", DB_C, "-e", f"MARIADB_ROOT_PASSWORD={DB_PASS}",
+ "-e", "MARIADB_DATABASE=dolibarr", DB_IMAGE)
+ podman("run", "-d", "--pod", POD, "--name", APP_C, "-e", "DOLI_DB_HOST=127.0.0.1", "-e", "DOLI_DB_NAME=dolibarr",
+ "-e", "DOLI_DB_USER=root", "-e", f"DOLI_DB_PASSWORD={DB_PASS}", "-e", "DOLI_ADMIN_LOGIN=admin",
+ "-e", f"DOLI_ADMIN_PASSWORD={ADMIN_PASS}", "-e", "DOLI_INSTALL_AUTO=1", "-e", "DOLI_INIT_DEMO=0",
+ "-e", f"DOLI_URL_ROOT=http://127.0.0.1:{PROXY}", "-e", "DOLI_COMPANY_NAME=Synthetic Supplies",
+ "-e", "DOLI_ENABLE_MODULES=Societe", APP_IMAGE)
+ self.wait_http("/", timeout_s=900)
+
+ def down(self) -> None:
+ podman("pod", "stop", POD, check=False)
+
+ def login(self, page: Any) -> None:
+ page.goto(self.base + "/index.php")
+ page.fill("#username", "admin")
+ page.fill("#password", ADMIN_PASS)
+ page.click("input[type=submit]")
+ page.wait_for_load_state("load")
+
+ def expire_session(self, page: Any) -> None:
+ # PHP keeps sessions in files; removing them ends every session.
+ podman("exec", APP_C, "sh", "-c", "rm -f /var/lib/php/sessions/* /tmp/sess_* 2>/dev/null; true")
diff --git a/packages/flow/benchmark/api_generalization/apps/dolibarr_contact.py b/packages/flow/benchmark/api_generalization/apps/dolibarr_contact.py
new file mode 100644
index 0000000000..1f32b87c13
--- /dev/null
+++ b/packages/flow/benchmark/api_generalization/apps/dolibarr_contact.py
@@ -0,0 +1,68 @@
+"""Dolibarr: a sales assistant adds a contact (Third parties > New contact/address)."""
+
+from __future__ import annotations
+
+from typing import Any
+
+from benchmark.api_generalization.apps.doli import DolibarrBase, dsql
+from benchmark.api_generalization.common import ExecSqlVerifier, rec_click, rec_text
+from openadapt_flow.runtime.effects.effect import Effect, EffectKind, ValueExpr
+
+QUERY = ("SELECT rowid AS id, lastname, firstname, poste, email, phone AS phone_pro FROM llx_socpeople")
+JUDGE = ("SELECT p.rowid AS id, p.lastname, p.firstname, p.poste, p.email, p.phone, p.fk_soc, p.civility, "
+ "COALESCE(c.code, '') AS country, p.priv, p.statut, p.entity FROM llx_socpeople p "
+ "LEFT JOIN llx_c_country c ON c.rowid = p.fk_pays ORDER BY p.rowid")
+
+
+class App(DolibarrBase):
+ name = "dolibarr_contact"
+ workflow = "new-contact"
+ form_path = "/contact/card.php?action=create"
+ save_path_regex = r"^/contact/card\.php$"
+ unique_param = "lastname"
+ normal_values = [
+ dict(lastname="Okafor", firstname="Chidi", poste="Purchasing lead", email="c.okafor@example.invalid", phone_pro="555-0161"),
+ dict(lastname="Lindgren", firstname="Maja", poste="Accounts payable", email="m.lindgren@example.invalid", phone_pro="555-0162"),
+ dict(lastname="Tanaka", firstname="Hiro", poste="Site manager", email="h.tanaka@example.invalid", phone_pro="555-0163"),
+ ]
+ blank_values = dict(lastname="Moreau", firstname="", poste="", email="", phone_pro="")
+ dup_values = dict(lastname="Quinn", firstname="Quinn", poste="Quinn", email="quinn@example.invalid", phone_pro="555-0164")
+ invalid_values = dict(lastname="Bad Mail", firstname="Ivo", poste="Buyer", email="ivo.at.example.invalid", phone_pro="555-0165")
+ invalid_note = "email without @ (the server rejects it)"
+ default_column = "country"
+ changed_default_value = "FR"
+
+ def record(self, rec: Any, page: Any, v: dict[str, str]) -> None:
+ rec_text(rec, page, "#lastname", v["lastname"], "lastname")
+ rec_text(rec, page, "#firstname", v["firstname"], "firstname")
+ rec_text(rec, page, "#title", v["poste"], "poste")
+ rec_text(rec, page, "#phone_pro", v["phone_pro"], "phone_pro")
+ rec_text(rec, page, "#email", v["email"], "email")
+ rec_click(rec, page, "input[name=add]")
+
+ def change_default(self) -> None:
+ # The company's own country is the new contact's default country.
+ dsql("INSERT INTO llx_const (name, entity, value, type, visible) "
+ "VALUES ('MAIN_INFO_SOCIETE_COUNTRY', 1, '1:FR:France', 'chaine', 0)")
+
+ def restore_default(self) -> None:
+ dsql("DELETE FROM llx_const WHERE name = 'MAIN_INFO_SOCIETE_COUNTRY'")
+
+ def rows(self) -> list[dict[str, Any]]:
+ return dsql(JUDGE)
+
+ def verifier(self) -> Any:
+ return ExecSqlVerifier(dsql, QUERY, timeout_s=10.0)
+
+ def expect_fields(self, v: dict[str, str]) -> dict[str, str]:
+ return {"lastname": v["lastname"], "firstname": v["firstname"], "poste": v["poste"],
+ "email": v["email"], "phone": v["phone_pro"], "country": ""}
+
+ def effects(self) -> list[Effect]:
+ match = {"lastname": ValueExpr(param="lastname")}
+ out = [Effect(kind=EffectKind.RECORD_WRITTEN, match=match, expected_count=1, risk="reversible",
+ probe="exactly one contact with this last name", timeout_s=8.0)]
+ for f in ("firstname", "poste", "email", "phone_pro"):
+ out.append(Effect(kind=EffectKind.FIELD_EQUALS, match=match, field=f, value=ValueExpr(param=f),
+ risk="reversible", probe=f"contact {f} read-back", timeout_s=8.0))
+ return out
diff --git a/packages/flow/benchmark/api_generalization/apps/dolibarr_thirdparty.py b/packages/flow/benchmark/api_generalization/apps/dolibarr_thirdparty.py
new file mode 100644
index 0000000000..71b68de402
--- /dev/null
+++ b/packages/flow/benchmark/api_generalization/apps/dolibarr_thirdparty.py
@@ -0,0 +1,62 @@
+"""Dolibarr: a sales assistant creates a customer (Third parties > New third party)."""
+
+from __future__ import annotations
+
+from typing import Any
+
+from benchmark.api_generalization.apps.doli import DolibarrBase, dsql
+from benchmark.api_generalization.common import (
+ ExecSqlVerifier,
+ rec_click,
+ rec_select2_click,
+ rec_text,
+)
+from openadapt_flow.runtime.effects.effect import Effect, EffectKind, ValueExpr
+
+QUERY = "SELECT rowid AS id, nom AS name, town, email, phone FROM llx_societe"
+JUDGE = ("SELECT rowid AS id, nom AS name, town, email, phone, client, status, fk_typent, entity "
+ "FROM llx_societe ORDER BY rowid")
+
+
+class App(DolibarrBase):
+ name = "dolibarr_thirdparty"
+ workflow = "new-customer"
+ form_path = "/societe/card.php?action=create"
+ save_path_regex = r"^/societe/card\.php$"
+ unique_param = "name"
+ normal_values = [
+ dict(name="Harbor Office Supply", town="Exampleton", email="orders@harbor.example.invalid", phone="555-0171"),
+ dict(name="Northgate Catering", town="Sampleville", email="hello@northgate.example.invalid", phone="555-0172"),
+ dict(name="Blue Fern Cleaning", town="Testford", email="info@bluefern.example.invalid", phone="555-0173"),
+ ]
+ blank_values = dict(name="Plain Name Traders", town="", email="", phone="")
+ dup_values = dict(name="Mirror Town", town="Mirror Town", email="mirror@example.invalid", phone="555-0174")
+ invalid_values = dict(name="Bad Mail Traders", town="Testford", email="no-at-sign.example.invalid", phone="555-0175")
+ invalid_note = "email without @ (the server rejects it)"
+
+ def record(self, rec: Any, page: Any, v: dict[str, str]) -> None:
+ rec_text(rec, page, "#name", v["name"], "name")
+ rec_select2_click(rec, page, "customerprospect", "Customer")
+ rec_text(rec, page, "#town", v["town"], "town")
+ rec_text(rec, page, "#phone", v["phone"], "phone")
+ rec_text(rec, page, "#email", v["email"], "email")
+ rec_click(rec, page, "input[name=save]")
+
+ def rows(self) -> list[dict[str, Any]]:
+ return dsql(JUDGE)
+
+ def verifier(self) -> Any:
+ return ExecSqlVerifier(dsql, QUERY, timeout_s=10.0)
+
+ def expect_fields(self, v: dict[str, str]) -> dict[str, str]:
+ return {"name": v["name"], "town": v["town"], "email": v["email"], "phone": v["phone"],
+ "client": "1", "status": "1"}
+
+ def effects(self) -> list[Effect]:
+ match = {"name": ValueExpr(param="name")}
+ out = [Effect(kind=EffectKind.RECORD_WRITTEN, match=match, expected_count=1, risk="reversible",
+ probe="exactly one third party with this name", timeout_s=8.0)]
+ for f in ("town", "email", "phone"):
+ out.append(Effect(kind=EffectKind.FIELD_EQUALS, match=match, field=f, value=ValueExpr(param=f),
+ risk="reversible", probe=f"third party {f} read-back", timeout_s=8.0))
+ return out
diff --git a/packages/flow/benchmark/api_generalization/apps/nb.py b/packages/flow/benchmark/api_generalization/apps/nb.py
new file mode 100644
index 0000000000..125b67f086
--- /dev/null
+++ b/packages/flow/benchmark/api_generalization/apps/nb.py
@@ -0,0 +1,78 @@
+"""NetBox 4.1 (network inventory, Django, PostgreSQL): the shared fixture.
+
+Runs as an external service from the community image (NetBox is
+Apache-2.0); nothing is copied here. Synthetic tenants and addresses
+(192.0.2.0/24 and 198.51.100.0/24 are documentation ranges).
+"""
+
+from __future__ import annotations
+
+import csv
+import io
+import secrets
+import subprocess
+from typing import Any
+
+from benchmark.api_generalization.common import AppBase, podman
+
+POD = "apigen-nb"
+PG_C, REDIS_C, NB_C = "apigen-nb-pg", "apigen-nb-redis", "apigen-nb-app"
+NB_IMAGE = "docker.io/netboxcommunity/netbox:v4.1.11"
+PG_IMAGE = "docker.io/library/postgres:16-alpine"
+REDIS_IMAGE = "ffb4a863fae9" # docker.io/library/redis (alpine), already on this machine
+DB_PASS = "synthetic-db-pass" # throwaway local container
+ADMIN_PASS = "synthetic-admin-pass" # throwaway local container
+UPSTREAM, PROXY = 9841, 9842
+SEED = """
+from extras.models import CustomField
+from core.models import ObjectType
+from ipam.models import IPAddress
+cf, _ = CustomField.objects.get_or_create(name='owner_team', defaults=dict(type='text', label='Owner team', default='Network Ops'))
+cf.default = 'Network Ops'; cf.save()
+cf.object_types.set([ObjectType.objects.get_for_model(IPAddress)])
+print('seeded')
+"""
+
+
+def nbsql(query: str) -> list[dict[str, Any]]:
+ out = podman("exec", "-i", PG_C, "psql", "-U", "netbox", "-d", "netbox", "--csv", "-v", "ON_ERROR_STOP=1", "-f", "-",
+ input=query)
+ return list(csv.DictReader(io.StringIO(out)))
+
+
+class NetBoxBase(AppBase):
+ upstream_port = UPSTREAM
+ proxy_port = PROXY
+
+ def up(self) -> None:
+ if subprocess.run(["podman", "pod", "exists", POD]).returncode == 0:
+ podman("pod", "start", POD)
+ self.wait_http("/login/")
+ return
+ podman("pod", "create", "--name", POD, "-p", f"127.0.0.1:{UPSTREAM}:8080")
+ podman("run", "-d", "--pod", POD, "--name", PG_C, "-e", "POSTGRES_USER=netbox", "-e", f"POSTGRES_PASSWORD={DB_PASS}",
+ "-e", "POSTGRES_DB=netbox", PG_IMAGE)
+ podman("run", "-d", "--pod", POD, "--name", REDIS_C, REDIS_IMAGE)
+ podman("run", "-d", "--pod", POD, "--name", NB_C,
+ "-e", "DB_HOST=127.0.0.1", "-e", "DB_NAME=netbox", "-e", "DB_USER=netbox", "-e", f"DB_PASSWORD={DB_PASS}",
+ "-e", "REDIS_HOST=127.0.0.1", "-e", "REDIS_CACHE_HOST=127.0.0.1",
+ "-e", "SECRET_KEY=synthetic-test-only-secret-key-0123456789abcdefghijklmnopqrstuvwxyz",
+ "-e", "SKIP_SUPERUSER=false", "-e", "SUPERUSER_NAME=admin", "-e", "SUPERUSER_EMAIL=admin@example.invalid",
+ "-e", f"SUPERUSER_PASSWORD={ADMIN_PASS}", "-e", f"SUPERUSER_API_TOKEN={secrets.token_hex(20)}",
+ NB_IMAGE)
+ self.wait_http("/login/", timeout_s=900)
+ print(podman("exec", "-i", NB_C, "/opt/netbox/venv/bin/python", "/opt/netbox/netbox/manage.py", "shell",
+ input=SEED, timeout=300))
+
+ def down(self) -> None:
+ podman("pod", "stop", POD, check=False)
+
+ def login(self, page: Any) -> None:
+ page.goto(self.base + "/login/")
+ page.fill("#id_username", "admin")
+ page.fill("#id_password", ADMIN_PASS)
+ page.click("button[type=submit]")
+ page.wait_for_load_state("load")
+
+ def expire_session(self, page: Any) -> None:
+ nbsql("DELETE FROM django_session")
diff --git a/packages/flow/benchmark/api_generalization/apps/netbox_ip.py b/packages/flow/benchmark/api_generalization/apps/netbox_ip.py
new file mode 100644
index 0000000000..b14ba60e4b
--- /dev/null
+++ b/packages/flow/benchmark/api_generalization/apps/netbox_ip.py
@@ -0,0 +1,93 @@
+"""NetBox: an engineer records a new IP address (IPAM > IP Addresses > Add)."""
+
+from __future__ import annotations
+
+import random
+from typing import Any
+
+from benchmark.api_generalization.apps.nb import NB_C, NetBoxBase, nbsql
+from benchmark.api_generalization.common import (
+ Case,
+ ExecSqlVerifier,
+ podman,
+ rec_click,
+ rec_text,
+)
+from openadapt_flow.runtime.effects.effect import Effect, EffectKind, ValueExpr
+
+QUERY = ("SELECT id, host(address) || '/' || masklen(address) AS address, dns_name, description "
+ "FROM ipam_ipaddress")
+JUDGE = ("SELECT id, host(address) || '/' || masklen(address) AS address, status, dns_name, description, "
+ "COALESCE(custom_field_data->>'owner_team', '') AS owner_team, role, tenant_id, vrf_id, comments "
+ "FROM ipam_ipaddress ORDER BY id")
+
+
+def set_cf_default(value: str) -> None:
+ podman("exec", "-i", NB_C, "/opt/netbox/venv/bin/python", "/opt/netbox/netbox/manage.py", "shell",
+ input=f"from extras.models import CustomField\ncf=CustomField.objects.get(name='owner_team'); "
+ f"cf.default={value!r}; cf.save()\n", timeout=300)
+
+
+class App(NetBoxBase):
+ name = "netbox_ip"
+ workflow = "add-ip-address"
+ form_path = "/ipam/ip-addresses/add/"
+ save_path_regex = r"^/ipam/ip-addresses/add/$"
+ unique_param = "address"
+ normal_values = [
+ dict(address="10.77.0.10/24", dns_name="printer-2f.example.invalid", description="Second floor printer"),
+ dict(address="10.77.0.11/24", dns_name="door-ctl.example.invalid", description="Badge door controller"),
+ dict(address="10.77.0.12/24", dns_name="kiosk-lobby.example.invalid", description="Lobby kiosk"),
+ ]
+ blank_values = dict(address="10.77.0.13/24", dns_name="", description="")
+ dup_values = dict(address="10.77.0.14/24", dns_name="same.example.invalid", description="same.example.invalid")
+ invalid_values = dict(address="10.77.0.300/24", dns_name="bad-octet.example.invalid", description="Octet over 255")
+ invalid_note = "an address with an octet over 255 (the server rejects it)"
+ default_column = "owner_team"
+ changed_default_value = "Platform"
+
+ def unique(self, case: Case, tag: str) -> Case:
+ if case.kind == "validation":
+ return case
+ taken = {r["address"] for r in nbsql(QUERY)}
+ rng = random.Random(tag)
+ while True:
+ addr = f"10.77.{rng.randrange(1, 250)}.{rng.randrange(1, 250)}/24"
+ if addr not in taken:
+ break
+ params = dict(case.params, address=addr)
+ fields = dict(case.expect_fields)
+ if fields:
+ fields["address"] = addr
+ return Case(case.id, case.kind, params, case.expect_new, fields, case.fault, case.note)
+
+ def record(self, rec: Any, page: Any, v: dict[str, str]) -> None:
+ rec_text(rec, page, "#id_address", v["address"], "address")
+ rec_text(rec, page, "#id_dns_name", v["dns_name"], "dns_name")
+ rec_text(rec, page, "#id_description", v["description"], "description")
+ rec_click(rec, page, "button[name=_create]")
+
+ def change_default(self) -> None:
+ set_cf_default("Platform")
+
+ def restore_default(self) -> None:
+ set_cf_default("Network Ops")
+
+ def rows(self) -> list[dict[str, Any]]:
+ return nbsql(JUDGE)
+
+ def verifier(self) -> Any:
+ return ExecSqlVerifier(nbsql, QUERY, timeout_s=10.0)
+
+ def expect_fields(self, v: dict[str, str]) -> dict[str, str]:
+ return {"address": v["address"], "dns_name": v["dns_name"], "description": v["description"],
+ "status": "active", "owner_team": "Network Ops"}
+
+ def effects(self) -> list[Effect]:
+ match = {"address": ValueExpr(param="address")}
+ out = [Effect(kind=EffectKind.RECORD_WRITTEN, match=match, expected_count=1, risk="reversible",
+ probe="exactly one IP address record with this address", timeout_s=8.0)]
+ for f in ("dns_name", "description"):
+ out.append(Effect(kind=EffectKind.FIELD_EQUALS, match=match, field=f, value=ValueExpr(param=f),
+ risk="reversible", probe=f"IP address {f} read-back", timeout_s=8.0))
+ return out
diff --git a/packages/flow/benchmark/api_generalization/apps/redmine.py b/packages/flow/benchmark/api_generalization/apps/redmine.py
new file mode 100644
index 0000000000..9fa81db390
--- /dev/null
+++ b/packages/flow/benchmark/api_generalization/apps/redmine.py
@@ -0,0 +1,124 @@
+"""Redmine 6.0 (ticketing, Ruby on Rails, SQLite): a new issue in one project.
+
+Runs as an external service from the official image; nothing from Redmine
+(GPL-2.0) is copied here. Synthetic project, synthetic text.
+"""
+
+from __future__ import annotations
+
+import json
+from typing import Any
+
+from benchmark.api_generalization.common import (
+ AppBase,
+ ExecSqlVerifier,
+ podman,
+ rec_click,
+ rec_select,
+ rec_text,
+)
+from openadapt_flow.runtime.effects.effect import Effect, EffectKind, ValueExpr
+
+C = "apigen-redmine"
+IMAGE = "docker.io/library/redmine:6.0-alpine"
+DB = "/usr/src/redmine/sqlite/redmine.db"
+ENV = ["-u", "redmine", "-e", "RAILS_ENV=production", "-e", "SECRET_KEY_BASE=synthetic-test-only-0123456789abcdef",
+ "-e", "REDMINE_LANG=en"]
+PASSWORD = "synthetic-admin-pass" # a throwaway local container's admin, not a real credential
+SEED = f"""
+u = User.find_by_login('admin'); u.password = '{PASSWORD}'; u.password_confirmation = '{PASSWORD}'
+u.must_change_passwd = false; u.save!
+p = Project.find_by_identifier('helpdesk') || Project.create!(name: 'Synthetic Helpdesk', identifier: 'helpdesk', is_public: false)
+p.trackers = Tracker.all; p.enabled_module_names = ['issue_tracking']; p.save!
+"""
+QUERY = ("SELECT i.id, i.subject, COALESCE(i.description, '') AS description, t.name AS tracker, "
+ "COALESCE(i.estimated_hours, '') AS estimated_hours, p.name AS priority "
+ "FROM issues i JOIN trackers t ON t.id = i.tracker_id JOIN enumerations p ON p.id = i.priority_id "
+ "WHERE i.project_id = 1")
+JUDGE = ("SELECT i.*, t.name AS tracker, p.name AS priority, s.name AS status FROM issues i "
+ "JOIN trackers t ON t.id = i.tracker_id JOIN enumerations p ON p.id = i.priority_id "
+ "JOIN issue_statuses s ON s.id = i.status_id ORDER BY i.id")
+
+
+def sql(query: str, *, write: bool = False) -> list[dict[str, Any]]:
+ ro = "false" if write else "true"
+ script = (f'db=SQLite3::Database.new("{DB}", readonly: {ro}); db.busy_timeout=5000; db.results_as_hash=true; '
+ f'r=db.execute(STDIN.read); puts JSON.dump(r.map {{|h| h.reject {{|k,_| k.is_a?(Integer)}} }})')
+ out = podman("exec", "-i", C, "ruby", "-rsqlite3", "-rjson", "-e", script, input=query)
+ return json.loads(out or "[]")
+
+
+class App(AppBase):
+ name = "redmine"
+ workflow = "new-issue"
+ upstream_port = 9821
+ proxy_port = 9822
+ form_path = "/projects/helpdesk/issues/new"
+ save_path_regex = r"^/projects/helpdesk/issues$"
+ unique_param = "subject"
+ normal_values = [
+ dict(tracker="Bug", subject="Badge printer jams on second floor", description="Paper feed stalls after ten sheets.", estimated_hours="1.5"),
+ dict(tracker="Support", subject="Reset shared calendar permissions", description="Front desk cannot see room bookings.", estimated_hours="0.25"),
+ dict(tracker="Feature", subject="Add export of visitor log", description="Weekly CSV for the facilities team.", estimated_hours="3.5"),
+ ]
+ blank_values = dict(tracker="Bug", subject="Kiosk screen flickers", description="", estimated_hours="")
+ dup_values = dict(tracker="Support", subject="Same words twice", description="Same words twice", estimated_hours="2.5")
+ invalid_values = dict(tracker="Bug", subject="Hours field holds text", description="Validation case.", estimated_hours="abc")
+ invalid_note = "estimated hours 'abc' (the server rejects it)"
+ default_column = "priority"
+ changed_default_value = "High"
+
+ def up(self) -> None:
+ podman("run", "-d", "--name", C, "-p", f"127.0.0.1:{self.upstream_port}:3000",
+ "-e", "REDMINE_SECRET_KEY_BASE=synthetic-test-only-0123456789abcdef", IMAGE)
+ self.wait_http("/login")
+ podman("exec", *ENV, C, "bundle", "exec", "rake", "redmine:load_default_data", timeout=600)
+ podman("exec", "-i", *ENV, C, "sh", "-c", "cat > /tmp/seed.rb && bundle exec rails runner /tmp/seed.rb",
+ input=SEED, timeout=600)
+
+ def down(self) -> None:
+ podman("rm", "-f", C, check=False)
+
+ def login(self, page: Any) -> None:
+ page.goto(self.base + "/login")
+ page.fill("#username", "admin")
+ page.fill("#password", PASSWORD)
+ page.click("#login-submit")
+ page.wait_for_load_state("load")
+
+ def record(self, rec: Any, page: Any, v: dict[str, str]) -> None:
+ rec_select(rec, page, "#issue_tracker_id", v["tracker"], "tracker")
+ page.wait_for_timeout(1500) # the tracker change re-renders the form's attributes
+ rec_text(rec, page, "#issue_subject", v["subject"], "subject")
+ rec_text(rec, page, "#issue_description", v["description"], "description")
+ rec_text(rec, page, "#issue_estimated_hours", v["estimated_hours"], "estimated_hours")
+ rec_click(rec, page, "input[name=commit]")
+
+ def expire_session(self, page: Any) -> None:
+ sql("DELETE FROM tokens WHERE action = 'session'", write=True)
+
+ def change_default(self) -> None:
+ sql("UPDATE enumerations SET is_default = (name = 'High') WHERE type = 'IssuePriority'", write=True)
+
+ def restore_default(self) -> None:
+ sql("UPDATE enumerations SET is_default = (name = 'Normal') WHERE type = 'IssuePriority'", write=True)
+
+ def rows(self) -> list[dict[str, Any]]:
+ return [r | {"estimated_hours": "" if r.get("estimated_hours") is None else str(r["estimated_hours"])}
+ for r in sql(JUDGE)]
+
+ def verifier(self) -> Any:
+ return ExecSqlVerifier(sql, QUERY, timeout_s=10.0)
+
+ def expect_fields(self, v: dict[str, str]) -> dict[str, str]:
+ return {"subject": v["subject"], "description": v["description"], "tracker": v["tracker"],
+ "estimated_hours": v["estimated_hours"], "priority": "Normal"}
+
+ def effects(self) -> list[Effect]:
+ match = {"subject": ValueExpr(param="subject")}
+ out = [Effect(kind=EffectKind.RECORD_WRITTEN, match=match, expected_count=1, risk="reversible",
+ probe="exactly one issue with this subject", timeout_s=8.0)]
+ for f in ("tracker", "description", "estimated_hours"):
+ out.append(Effect(kind=EffectKind.FIELD_EQUALS, match=match, field=f, value=ValueExpr(param=f),
+ risk="reversible", probe=f"issue {f} read-back", timeout_s=8.0))
+ return out
diff --git a/packages/flow/benchmark/api_generalization/apps/trac.Containerfile b/packages/flow/benchmark/api_generalization/apps/trac.Containerfile
new file mode 100644
index 0000000000..267581a531
--- /dev/null
+++ b/packages/flow/benchmark/api_generalization/apps/trac.Containerfile
@@ -0,0 +1,8 @@
+# Trac 1.6 (BSD-3-Clause) for the API-step generalization benchmark: a synthetic
+# local service desk. Throwaway admin password; bind to 127.0.0.1 only.
+FROM docker.io/library/python:3.12-slim-bookworm
+RUN pip install --no-cache-dir "Trac==1.6" "Jinja2<3.2" "Babel" "setuptools<81" && trac-admin /srv/trac initenv "Synthetic Service Desk" sqlite:db/trac.db \
+ && printf 'admin:{SHA}%s\n' "$(python -c 'import hashlib,base64;print(base64.b64encode(hashlib.sha1(b"synthetic-admin-pass").digest()).decode())')" > /srv/trac/htpasswd \
+ && trac-admin /srv/trac permission add admin TRAC_ADMIN
+EXPOSE 8000
+CMD ["tracd", "-s", "--port", "8000", "--basic-auth", "*,/srv/trac/htpasswd,trac", "/srv/trac"]
diff --git a/packages/flow/benchmark/api_generalization/apps/trac.py b/packages/flow/benchmark/api_generalization/apps/trac.py
new file mode 100644
index 0000000000..46d236b1b6
--- /dev/null
+++ b/packages/flow/benchmark/api_generalization/apps/trac.py
@@ -0,0 +1,120 @@
+"""Trac 1.6 (ticketing, Python, SQLite): a new ticket.
+
+Built from PyPI by ``trac.Containerfile`` (Trac is BSD-3-Clause); nothing is
+copied here. Sign-in is HTTP basic auth on /login, which sets Trac's own
+session cookie.
+"""
+
+from __future__ import annotations
+
+import json
+from pathlib import Path
+from typing import Any
+
+from benchmark.api_generalization.common import (
+ AppBase,
+ ExecSqlVerifier,
+ podman,
+ rec_click,
+ rec_select,
+ rec_text,
+)
+from openadapt_flow.runtime.effects.effect import Effect, EffectKind, ValueExpr
+
+C = "apigen-trac"
+IMAGE = "localhost/apigen-trac:1.6"
+DB = "/srv/trac/db/trac.db"
+PASSWORD = "synthetic-admin-pass" # throwaway local container
+QUERY = "SELECT id, summary, description, type, keywords FROM ticket"
+JUDGE = "SELECT id, summary, description, type, priority, keywords, reporter, component, status, owner FROM ticket ORDER BY id"
+
+
+def tsql(query: str, *, write: bool = False) -> list[dict[str, Any]]:
+ script = ("import json,sqlite3,sys\n"
+ f"c=sqlite3.connect('file:{DB}' + ('' if {write!r} else '?mode=ro'), uri=True, timeout=5)\n"
+ "c.row_factory=sqlite3.Row\n"
+ "cur=c.execute(sys.argv[1]); rows=[dict(r) for r in cur.fetchall()] if cur.description else []\n"
+ "c.commit(); print(json.dumps(rows))\n")
+ return json.loads(podman("exec", C, "python", "-c", script, query) or "[]")
+
+
+class App(AppBase):
+ name = "trac"
+ workflow = "new-ticket"
+ upstream_port = 9871
+ proxy_port = 9872
+ form_path = "/newticket"
+ save_path_regex = r"^/newticket$"
+ unique_param = "summary"
+ normal_values = [
+ dict(type="defect", summary="Label printer skips every other label", description="Seen on the shipping desk unit.", keywords="printer shipping"),
+ dict(type="task", summary="Rotate the guest wifi passphrase", description="Quarterly rotation for the lobby network.", keywords="wifi"),
+ dict(type="enhancement", summary="Show room capacity on the booking page", description="Facilities asked for this.", keywords="booking"),
+ ]
+ blank_values = dict(type="defect", summary="Projector remote missing", description="", keywords="")
+ dup_values = dict(type="task", summary="Same words", description="Same words", keywords="Same words")
+ invalid_values = dict(type="defect", summary="Summary longer than this desk allows: the ticket form takes sixty characters at most",
+ description="Validation case.", keywords="limits")
+ invalid_note = "a summary over the desk's 60-character limit (trac.ini max_summary_size; the server rejects it)"
+ save_body_exclude = r"(^|&)preview="
+ default_column = "priority"
+ changed_default_value = "critical"
+
+ def up(self) -> None:
+ here = Path(__file__).resolve().parent
+ if podman("images", "-q", IMAGE).strip() == "":
+ podman("build", "-t", IMAGE, "-f", str(here / "trac.Containerfile"), str(here), timeout=900)
+ podman("run", "-d", "--name", C, "-p", f"127.0.0.1:{self.upstream_port}:8000", IMAGE)
+ podman("exec", C, "trac-admin", "/srv/trac", "config", "set", "ticket", "max_summary_size", "60")
+ self.wait_http("/")
+
+ def down(self) -> None:
+ podman("rm", "-f", C, check=False)
+
+ def login(self, page: Any) -> None:
+ # Basic auth answers the /login challenge once; Trac then signs the
+ # browser in with its own trac_auth cookie.
+ import base64
+
+ auth = base64.b64encode(f"admin:{PASSWORD}".encode()).decode()
+ page.context.request.get(self.base + "/login", headers={"Authorization": f"Basic {auth}"})
+ page.goto(self.base + "/")
+ page.wait_for_load_state("load")
+
+ def expire_session(self, page: Any) -> None:
+ tsql("DELETE FROM auth_cookie", write=True)
+
+ def record(self, rec: Any, page: Any, v: dict[str, str]) -> None:
+ rec_text(rec, page, "#field-summary", v["summary"], "summary")
+ rec_text(rec, page, "#field-description", v["description"], "description")
+ rec_select(rec, page, "#field-type", v["type"], "type")
+ rec_text(rec, page, "#field-keywords", v["keywords"], "keywords")
+ rec_click(rec, page, "input[name=submit]")
+
+ def _priority(self, value: str) -> None:
+ podman("exec", C, "trac-admin", "/srv/trac", "config", "set", "ticket", "default_priority", value)
+
+ def change_default(self) -> None:
+ self._priority("critical")
+
+ def restore_default(self) -> None:
+ self._priority("major")
+
+ def rows(self) -> list[dict[str, Any]]:
+ return tsql(JUDGE)
+
+ def verifier(self) -> Any:
+ return ExecSqlVerifier(tsql, QUERY, timeout_s=10.0)
+
+ def expect_fields(self, v: dict[str, str]) -> dict[str, str]:
+ return {"summary": v["summary"], "description": v["description"], "type": v["type"],
+ "keywords": v["keywords"], "priority": "major", "reporter": "admin"}
+
+ def effects(self) -> list[Effect]:
+ match = {"summary": ValueExpr(param="summary")}
+ out = [Effect(kind=EffectKind.RECORD_WRITTEN, match=match, expected_count=1, risk="reversible",
+ probe="exactly one ticket with this summary", timeout_s=8.0)]
+ for f in ("description", "type", "keywords"):
+ out.append(Effect(kind=EffectKind.FIELD_EQUALS, match=match, field=f, value=ValueExpr(param=f),
+ risk="reversible", probe=f"ticket {f} read-back", timeout_s=8.0))
+ return out
diff --git a/packages/flow/benchmark/api_generalization/apps/wordpress_category.py b/packages/flow/benchmark/api_generalization/apps/wordpress_category.py
new file mode 100644
index 0000000000..0c4ff9aba2
--- /dev/null
+++ b/packages/flow/benchmark/api_generalization/apps/wordpress_category.py
@@ -0,0 +1,66 @@
+"""WordPress: an editor adds a post category (wp-admin, Posts > Categories)."""
+
+from __future__ import annotations
+
+from typing import Any
+
+from benchmark.api_generalization.apps.wp import WordPressBase, wpsql
+from benchmark.api_generalization.common import (
+ ExecSqlVerifier,
+ rec_click,
+ rec_select,
+ rec_text,
+)
+from openadapt_flow.runtime.effects.effect import Effect, EffectKind, ValueExpr
+
+QUERY = ("SELECT t.term_id AS id, t.name, tt.description, COALESCE(p.name, 'None') AS parent "
+ "FROM wp_terms t JOIN wp_term_taxonomy tt ON tt.term_id = t.term_id "
+ "LEFT JOIN wp_terms p ON p.term_id = tt.parent WHERE tt.taxonomy = 'category'")
+JUDGE = ("SELECT t.term_id AS id, t.name, t.slug, tt.taxonomy, tt.description, tt.parent, "
+ "COALESCE(p.name, 'None') AS parent_name, tt.count FROM wp_terms t "
+ "JOIN wp_term_taxonomy tt ON tt.term_id = t.term_id LEFT JOIN wp_terms p ON p.term_id = tt.parent "
+ "ORDER BY t.term_id")
+
+
+class App(WordPressBase):
+ name = "wordpress_category"
+ workflow = "add-category"
+ form_path = "/wp-admin/edit-tags.php?taxonomy=category"
+ save_path_regex = r"^/wp-admin/admin-ajax\.php$"
+ save_body_require = r"(^|&)action=add-tag(&|$)"
+ unique_param = "name"
+ normal_values = [
+ dict(name="Community Events", parent="None", description="Fairs, meetups and open days."),
+ dict(name="Road Works", parent="Uncategorized", description="Closures and detours."),
+ dict(name="Library Hours", parent="None", description="Opening times and holidays."),
+ ]
+ blank_values = dict(name="Notices", parent="None", description="")
+ dup_values = dict(name="Same Text", parent="None", description="Same Text")
+ invalid_values = dict(name="Uncategorized", parent="None", description="A name that already exists.")
+ invalid_note = "a category name that already exists at that level (the server rejects it)"
+
+ def record(self, rec: Any, page: Any, v: dict[str, str]) -> None:
+ rec_text(rec, page, "#tag-name", v["name"], "name")
+ rec_select(rec, page, "#parent", v["parent"], "parent")
+ rec_text(rec, page, "#tag-description", v["description"], "description")
+ rec_click(rec, page, "#submit")
+ page.wait_for_timeout(1500)
+
+ def rows(self) -> list[dict[str, Any]]:
+ return wpsql(JUDGE)
+
+ def verifier(self) -> Any:
+ return ExecSqlVerifier(wpsql, QUERY, timeout_s=10.0)
+
+ def expect_fields(self, v: dict[str, str]) -> dict[str, str]:
+ return {"name": v["name"], "taxonomy": "category", "description": v["description"],
+ "parent_name": v["parent"]}
+
+ def effects(self) -> list[Effect]:
+ match = {"name": ValueExpr(param="name")}
+ out = [Effect(kind=EffectKind.RECORD_WRITTEN, match=match, expected_count=1, risk="reversible",
+ probe="exactly one category with this name", timeout_s=8.0)]
+ for f in ("description", "parent"):
+ out.append(Effect(kind=EffectKind.FIELD_EQUALS, match=match, field=f, value=ValueExpr(param=f),
+ risk="reversible", probe=f"category {f} read-back", timeout_s=8.0))
+ return out
diff --git a/packages/flow/benchmark/api_generalization/apps/wordpress_comment.py b/packages/flow/benchmark/api_generalization/apps/wordpress_comment.py
new file mode 100644
index 0000000000..7711b1a20b
--- /dev/null
+++ b/packages/flow/benchmark/api_generalization/apps/wordpress_comment.py
@@ -0,0 +1,65 @@
+"""WordPress: a visitor posts a comment on a post (public form, no sign-in)."""
+
+from __future__ import annotations
+
+from typing import Any
+
+from benchmark.api_generalization.apps.wp import WordPressBase, wpsql
+from benchmark.api_generalization.common import ExecSqlVerifier, rec_click, rec_text
+from openadapt_flow.runtime.effects.effect import Effect, EffectKind, ValueExpr
+
+QUERY = ("SELECT comment_ID AS id, comment_content AS comment, comment_author AS author, "
+ "comment_author_email AS email, comment_author_url AS url FROM wp_comments WHERE comment_post_ID = 1")
+JUDGE = ("SELECT comment_ID AS id, comment_post_ID, comment_parent, comment_content, comment_author, "
+ "comment_author_email, comment_author_url, comment_approved, comment_type, user_id "
+ "FROM wp_comments ORDER BY comment_ID")
+
+
+class App(WordPressBase):
+ name = "wordpress_comment"
+ workflow = "post-comment"
+ form_path = "/?p=1"
+ save_path_regex = r"^/wp-comments-post\.php$"
+ login_required = False
+ unique_param = "comment"
+ normal_values = [
+ dict(comment="The parking map on page two helped a lot.", author="Ada Park", email="ada.park@example.invalid", url="https://ada.example.invalid/"),
+ dict(comment="Could the meeting notes list room numbers?", author="Ravi Osei", email="ravi.osei@example.invalid", url="https://ravi.example.invalid/"),
+ dict(comment="Thanks for posting the bus timetable.", author="Lena Fischer", email="lena.f@example.invalid", url="https://lena.example.invalid/"),
+ ]
+ blank_values = dict(comment="No website for this one.", author="Mo Diallo", email="mo.diallo@example.invalid", url="")
+ dup_values = dict(comment="Sam Ortiz", author="Sam Ortiz", email="sam.ortiz@example.invalid", url="https://sam.example.invalid/")
+ invalid_values = dict(comment="This email has no at sign.", author="Kim Vu", email="kim.vu.example.invalid", url="")
+ invalid_note = "email without @ (the server rejects it)"
+
+ def open_form(self, page: Any) -> None:
+ super().open_form(page)
+ page.locator("#comment").scroll_into_view_if_needed() # where a reader starts typing
+ page.wait_for_timeout(300)
+
+ def record(self, rec: Any, page: Any, v: dict[str, str]) -> None:
+ rec_text(rec, page, "#comment", v["comment"], "comment")
+ rec_text(rec, page, "#author", v["author"], "author")
+ rec_text(rec, page, "#email", v["email"], "email")
+ rec_text(rec, page, "#url", v["url"], "url")
+ rec_click(rec, page, "#submit")
+
+ def rows(self) -> list[dict[str, Any]]:
+ return wpsql(JUDGE)
+
+ def verifier(self) -> Any:
+ return ExecSqlVerifier(wpsql, QUERY, timeout_s=10.0)
+
+ def expect_fields(self, v: dict[str, str]) -> dict[str, str]:
+ return {"comment_content": v["comment"], "comment_author": v["author"],
+ "comment_author_email": v["email"], "comment_author_url": v["url"],
+ "comment_post_ID": "1", "comment_parent": "0"}
+
+ def effects(self) -> list[Effect]:
+ match = {"comment": ValueExpr(param="comment")}
+ out = [Effect(kind=EffectKind.RECORD_WRITTEN, match=match, expected_count=1, risk="reversible",
+ probe="exactly one comment with this text", timeout_s=8.0)]
+ for f in ("author", "email", "url"):
+ out.append(Effect(kind=EffectKind.FIELD_EQUALS, match=match, field=f, value=ValueExpr(param=f),
+ risk="reversible", probe=f"comment {f} read-back", timeout_s=8.0))
+ return out
diff --git a/packages/flow/benchmark/api_generalization/apps/wp.py b/packages/flow/benchmark/api_generalization/apps/wp.py
new file mode 100644
index 0000000000..35088eb61c
--- /dev/null
+++ b/packages/flow/benchmark/api_generalization/apps/wp.py
@@ -0,0 +1,76 @@
+"""WordPress 6.6 (CMS, PHP, MariaDB): the shared fixture for the WordPress workflows.
+
+Runs as an external service from the official images; nothing from
+WordPress (GPL-2.0) is copied here. The one file written into the container
+is this harness's own must-use plugin that turns off WordPress's comment
+flood throttle (a test copy posts many comments from one address).
+"""
+
+from __future__ import annotations
+
+import subprocess
+import time
+from typing import Any
+
+from benchmark.api_generalization.common import AppBase, mysql_rows, podman
+
+POD = "apigen-wp"
+DB_C = "apigen-wp-db"
+WP_C = "apigen-wp-app"
+WP_IMAGE = "docker.io/library/wordpress:6.6-php8.2-apache"
+DB_IMAGE = "docker.io/library/mariadb@sha256:efb4959ef2c835cd735dbc388eb9ad6aab0c78dd64febcd51bc17481111890c4"
+DB_PASS = "synthetic-db-pass" # throwaway local container
+ADMIN_PASS = "synthetic-admin-pass" # throwaway local container
+UPSTREAM, PROXY = 9831, 9832
+MU_PLUGIN = " list[dict[str, Any]]:
+ return mysql_rows(DB_C, "root", DB_PASS, "wordpress", query)
+
+
+class WordPressBase(AppBase):
+ upstream_port = UPSTREAM
+ proxy_port = PROXY
+
+ def up(self) -> None:
+ if subprocess.run(["podman", "pod", "exists", POD]).returncode == 0:
+ podman("pod", "start", POD)
+ self.wait_http("/wp-login.php")
+ return
+ podman("pod", "create", "--name", POD, "-p", f"127.0.0.1:{UPSTREAM}:80")
+ podman("run", "-d", "--pod", POD, "--name", DB_C, "-e", f"MARIADB_ROOT_PASSWORD={DB_PASS}",
+ "-e", "MARIADB_DATABASE=wordpress", DB_IMAGE)
+ podman("run", "-d", "--pod", POD, "--name", WP_C, "-e", "WORDPRESS_DB_HOST=127.0.0.1",
+ "-e", "WORDPRESS_DB_USER=root", "-e", f"WORDPRESS_DB_PASSWORD={DB_PASS}",
+ "-e", "WORDPRESS_DB_NAME=wordpress", WP_IMAGE)
+ self.wait_http("/wp-admin/install.php")
+ import urllib.parse
+ import urllib.request
+
+ body = urllib.parse.urlencode({
+ "weblog_title": "Synthetic Community Notes", "user_name": "admin", "admin_password": ADMIN_PASS,
+ "admin_password2": ADMIN_PASS, "pw_weak": "1", "admin_email": "admin@example.invalid",
+ "blog_public": "0", "language": ""}).encode()
+ urllib.request.urlopen(f"http://127.0.0.1:{UPSTREAM}/wp-admin/install.php?step=2", data=body, timeout=120).read()
+ base = f"http://127.0.0.1:{PROXY}"
+ wpsql(f"UPDATE wp_options SET option_value='{base}' WHERE option_name IN ('siteurl','home')")
+ podman("exec", "-i", WP_C, "sh", "-c",
+ "mkdir -p /var/www/html/wp-content/mu-plugins && cat > /var/www/html/wp-content/mu-plugins/apigen-test-copy.php",
+ input=MU_PLUGIN)
+ time.sleep(1)
+
+ def down(self) -> None:
+ podman("pod", "stop", POD, check=False)
+
+ def login(self, page: Any) -> None:
+ page.goto(self.base + "/wp-login.php")
+ page.fill("#user_login", "admin")
+ page.fill("#user_pass", ADMIN_PASS)
+ page.click("#wp-submit")
+ page.wait_for_load_state("load")
+
+ def expire_session(self, page: Any) -> None:
+ # WordPress keeps each login's session token in usermeta; deleting it
+ # ends every session on the server.
+ wpsql("DELETE FROM wp_usermeta WHERE meta_key = 'session_tokens'")
diff --git a/packages/flow/benchmark/api_generalization/common.py b/packages/flow/benchmark/api_generalization/common.py
new file mode 100644
index 0000000000..8f9183a9f4
--- /dev/null
+++ b/packages/flow/benchmark/api_generalization/common.py
@@ -0,0 +1,330 @@
+"""Shared pieces: the app adapter shape, the independent judge, the SQL read-back.
+
+An app adapter (``apps/.py``) gives the harness what it can't know
+about an app: how to start it, how a scripted person fills the form, how to
+read its records back, and the inputs to try. Everything the API step itself
+does comes from the frozen compiler and runtime, unchanged.
+
+Two read-backs are kept apart on purpose:
+
+- the **engine's record check** (``Effect`` list on the save step, read by
+ :class:`ExecSqlVerifier`) is what the run itself uses to confirm the save;
+- the **judge** (:func:`judge`) reads the whole record table before and after
+ each run with its own query, and decides whether the run left the app in
+ the expected state. A run is scored only by the judge.
+"""
+
+from __future__ import annotations
+
+import json
+import subprocess
+import time
+from dataclasses import dataclass, field
+from typing import Any, Callable, Optional
+
+from openadapt_flow.runtime.effects.sql import SqlRecordVerifier
+
+
+def sh(args: list[str], *, input: Optional[str] = None, timeout: float = 120, check: bool = True) -> str:
+ r = subprocess.run(args, input=input, capture_output=True, text=True, timeout=timeout)
+ if check and r.returncode != 0:
+ raise RuntimeError(f"{' '.join(args[:4])}... failed ({r.returncode}): {r.stderr[-800:]}")
+ return r.stdout
+
+
+def podman(*args: str, **kw: Any) -> str:
+ return sh(["podman", *args], **kw)
+
+
+class ExecSqlVerifier(SqlRecordVerifier):
+ """The engine's record check, read through ``podman exec`` (no DB driver on the host).
+
+ ``fetch`` runs the adapter's fixed read-only SELECT and returns rows as
+ dicts; any failure reads as unreadable (INDETERMINATE), as in the parent.
+ """
+
+ def __init__(self, fetch: Callable[[str], list[dict[str, Any]]], query: str, **kw: Any) -> None:
+ super().__init__(connect=lambda: None, query=query, **kw)
+ self._fetch = fetch
+
+ def _fetch_records(self) -> Optional[list[dict[str, Any]]]:
+ try:
+ rows = self._fetch(self.query)
+ except Exception: # noqa: BLE001 - an unreadable record system is INDETERMINATE
+ return None
+ return [{k: ("" if v is None else str(v)) for k, v in r.items()} for r in rows]
+
+
+@dataclass
+class Case:
+ """One input or condition for a workflow, and what the app should end up holding."""
+
+ id: str
+ kind: str # normal | blank | dup_values | changed_default | expired_session | fault_reject | fault_partial | fault_duplicate | validation
+ params: dict[str, str]
+ expect_new: int # records the run should leave (fault_duplicate: what the fixture causes)
+ expect_fields: dict[str, str] = field(default_factory=dict) # judge columns -> value on each new record
+ fault: Optional[str] = None # proxy mode
+ note: str = ""
+
+
+def judge(before: list[dict[str, Any]], after: list[dict[str, Any]], case: Case, key: str = "id") -> dict[str, Any]:
+ """Compare the full table before and after one run."""
+
+ b = {str(r[key]): r for r in before}
+ a = {str(r[key]): r for r in after}
+ new = [a[k] for k in a if k not in b]
+ gone = [k for k in b if k not in a]
+ changed = [k for k in b if k in a and json.dumps(b[k], sort_keys=True, default=str) != json.dumps(a[k], sort_keys=True, default=str)]
+ mism: list[str] = []
+ for r in new:
+ for col, want in case.expect_fields.items():
+ got = "" if r.get(col) is None else str(r.get(col))
+ if got != want:
+ mism.append(f"{col}: want {want!r} got {got!r}")
+ ok_state = len(new) == case.expect_new and not mism and not gone and not changed
+ return {
+ "new_records": len(new),
+ "field_mismatches": mism,
+ "collateral": {"deleted": gone, "changed": changed},
+ "state_ok": ok_state,
+ "new_ids": [str(r[key]) for r in new],
+ }
+
+
+def classify(case: Case, state: dict[str, Any], engine_success: Optional[bool], transaction_outcome: Optional[str]) -> dict[str, Any]:
+ """Score one run from the judge's view of the app, then the run's own claim.
+
+ - ``incorrect_write``: the app holds something the run should not have
+ caused: a wrong value, an extra record, a changed or deleted record.
+ In F3 the fixture's second delivery is not counted against the run.
+ - ``false_success``: the run reported success and the judge disagrees.
+ - ``correct``: the app holds what it should and the run's claim matches
+ (success exactly when the expected write happened, or
+ RECONCILIATION_REQUIRED after a lost response in F2). In F3 the app
+ may hold two records (the run must not report success) or one, when
+ the app itself refuses the second delivery (then the run's claim must
+ be true either way).
+ - ``stopped_after_right_write``: the right record was written but the run
+ did not report success (a safe stop that costs a person a look).
+ - ``missed_write``: nothing wrong was written but an expected record is
+ missing (an honest stop).
+ """
+
+ collateral = bool(state["collateral"]["deleted"] or state["collateral"]["changed"])
+ n = state["new_records"]
+ mism = bool(state["field_mismatches"])
+ if case.kind == "fault_duplicate":
+ right = n in (1, 2) and not mism and not collateral
+ incorrect = n > 2 or mism or collateral
+ correct = right and (not engine_success if n == 2 else True)
+ false_success = bool(engine_success) and not (right and n == 1)
+ return {"correct": correct, "incorrect_write": incorrect, "false_success": false_success,
+ "stopped_after_right_write": right and n == 1 and not engine_success, "missed_write": n == 0}
+ right = state["state_ok"]
+ should_succeed = case.expect_new >= 1
+ correct = right and bool(engine_success) == should_succeed
+ if case.kind == "fault_partial" and right and transaction_outcome == "RECONCILIATION_REQUIRED":
+ correct = True
+ incorrect = n > case.expect_new or mism or collateral
+ return {"correct": correct, "incorrect_write": incorrect,
+ "false_success": bool(engine_success) and not (right and should_succeed),
+ "stopped_after_right_write": right and should_succeed and not engine_success and not correct,
+ "missed_write": (not incorrect) and n < case.expect_new}
+
+
+class AppBase:
+ """Defaults shared by the adapters. Subclasses fill in the app-specific parts."""
+
+ name = ""
+ workflow = ""
+ upstream_port = 0
+ proxy_port = 0
+ form_path = ""
+ save_path_regex = ""
+ save_body_exclude = ""
+ save_body_require = ""
+ login_required = True
+ key = "id"
+
+ @property
+ def base(self) -> str:
+ return f"http://127.0.0.1:{self.proxy_port}"
+
+ # lifecycle ---------------------------------------------------------------
+ def up(self) -> None:
+ raise NotImplementedError
+
+ def down(self) -> None:
+ raise NotImplementedError
+
+ def wait_http(self, path: str = "/", timeout_s: float = 600) -> None:
+ import urllib.request
+
+ deadline = time.time() + timeout_s
+ while time.time() < deadline:
+ try:
+ with urllib.request.urlopen(f"http://127.0.0.1:{self.upstream_port}{path}", timeout=5) as r:
+ if r.status < 500:
+ return
+ except Exception: # noqa: BLE001
+ time.sleep(3)
+ raise RuntimeError(f"{self.name} did not answer on {path}")
+
+ # browser -----------------------------------------------------------------
+ def login(self, page: Any) -> None:
+ raise NotImplementedError
+
+ def open_form(self, page: Any) -> None:
+ page.goto(self.base + self.form_path)
+ page.wait_for_load_state("load")
+ page.wait_for_timeout(800)
+
+ def record(self, rec: Any, page: Any, values: dict[str, str]) -> None:
+ raise NotImplementedError
+
+ def expire_session(self, page: Any) -> None:
+ """End the signed-in session on the server (default: drop the browser's cookies)."""
+ page.context.clear_cookies()
+
+ def change_default(self) -> None:
+ raise NotImplementedError
+
+ def restore_default(self) -> None:
+ raise NotImplementedError
+
+ # records -----------------------------------------------------------------
+ def rows(self) -> list[dict[str, Any]]:
+ raise NotImplementedError
+
+ def verifier(self) -> Any:
+ raise NotImplementedError
+
+ def effects(self) -> list[Any]:
+ raise NotImplementedError
+
+ # inputs ----------------------------------------------------------------------
+ #: The parameter made unique per run (so repeated runs never collide with
+ #: an app's own duplicate rules), and its judge column.
+ unique_param = ""
+ normal_values: list[dict[str, str]] = []
+ blank_values: dict[str, str] = {}
+ dup_values: dict[str, str] = {}
+ invalid_values: dict[str, str] = {}
+ invalid_note = ""
+ #: Judge column whose form default ``change_default`` changes, and the
+ #: value a person's save holds after the change.
+ default_column = ""
+ changed_default_value = ""
+
+ def recording_values(self) -> dict[str, str]:
+ return dict(self.normal_values[0])
+
+ def expect_fields(self, values: dict[str, str]) -> dict[str, str]:
+ raise NotImplementedError
+
+ def unique(self, case: Case, tag: str) -> Case:
+ if case.kind == "validation":
+ return case # the invalid value must stay exactly as written
+ params = dict(case.params)
+ params[self.unique_param] = f"{params[self.unique_param]} {tag}"
+ fields = dict(case.expect_fields)
+ if fields:
+ col = self.unique_column()
+ fields[col] = self.expect_fields(params)[col]
+ return Case(case.id, case.kind, params, case.expect_new, fields, case.fault, case.note)
+
+ def unique_column(self) -> str:
+ cols = self.expect_fields({k: "\x00" if k == self.unique_param else "" for k in self.normal_values[0]})
+ return next(k for k, v in cols.items() if v == "\x00")
+
+ def cases(self) -> list[Case]:
+ n = self.normal_values
+ cases = [Case(f"N{i + 1}", "normal", v, 1, self.expect_fields(v)) for i, v in enumerate(n)]
+ cases.append(Case("B1", "blank", self.blank_values, 1, self.expect_fields(self.blank_values),
+ note="optional fields left blank"))
+ cases.append(Case("D1", "dup_values", self.dup_values, 1, self.expect_fields(self.dup_values),
+ note="the same value in several fields"))
+ if self.default_column:
+ ef = self.expect_fields(n[0]) | {self.default_column: self.changed_default_value}
+ cases.append(Case("C1", "changed_default", n[0], 1, ef,
+ note=f"the app's default for {self.default_column} changed after recording"))
+ if self.login_required:
+ cases.append(Case("S1", "expired_session", n[1], 0, {}, note="session ended on the server before the run"))
+ cases.append(Case("F1", "fault_reject", n[2], 0, {}, fault="reject", note="503 before the app sees the save"))
+ cases.append(Case("F2", "fault_partial", n[0], 1, self.expect_fields(n[0]), fault="partial",
+ note="the app commits, the response is lost (502)"))
+ cases.append(Case("F3", "fault_duplicate", n[1], 2, self.expect_fields(n[1]), fault="duplicate",
+ note="the save reaches the app twice"))
+ cases.append(Case("V1", "validation", self.invalid_values, 0, {}, note=self.invalid_note))
+ return cases
+
+
+def center(page: Any, rec: Any, selector: str) -> tuple[int, int]:
+ """The control's center, scrolling (as a recorded wheel event) until it is in view."""
+
+ loc = page.locator(selector).first
+ last_y = None
+ for _ in range(8):
+ box = loc.bounding_box()
+ if box is None:
+ raise RuntimeError(f"not rendered: {selector}")
+ h = page.viewport_size["height"]
+ cy = box["y"] + box["height"] / 2
+ if 20 <= cy <= h - 20 and (box["y"] >= 0 and box["y"] + box["height"] <= h or box["y"] == last_y):
+ return int(box["x"] + box["width"] / 2), int(cy)
+ last_y = box["y"]
+ page.mouse.move(640, 400) # the wheel scrolls what is under the pointer; a move is not an event
+ rec.scroll(0, 300 if cy > h / 2 else -300)
+ raise RuntimeError(f"could not scroll to {selector}")
+
+
+def rec_text(rec: Any, page: Any, selector: str, value: str, param: str, *, clear: bool = False) -> None:
+ rec.click(*center(page, rec, selector))
+ if clear:
+ rec.press("Control+a")
+ rec.type_text(value, param=param)
+
+
+def rec_select(rec: Any, page: Any, selector: str, label: str, param: str) -> None:
+ """A person picks an option by typing its label into the focused select.
+
+ No Enter: on a closed select, Enter submits the form in Chromium.
+ """
+
+ rec.click(*center(page, rec, selector))
+ rec.type_text(label, param=param)
+
+
+def rec_click(rec: Any, page: Any, selector: str) -> None:
+ rec.click(*center(page, rec, selector))
+
+
+def mysql_rows(container: str, user: str, password: str, db: str, query: str) -> list[dict[str, Any]]:
+ """Rows from MariaDB/MySQL through the container's own client (tab-separated with a header)."""
+
+ out = podman("exec", "-i", container, "mariadb", f"-u{user}", f"-p{password}", "-B", db, input=query)
+ lines = out.splitlines()
+ if not lines:
+ return []
+ cols = lines[0].split("\t")
+
+ def unesc(v: str) -> Any:
+ if v == "NULL":
+ return None
+ return v.replace("\\n", "\n").replace("\\t", "\t").replace("\\0", "\0").replace("\\\\", "\\")
+
+ return [dict(zip(cols, [unesc(v) for v in ln.split("\t")])) for ln in lines[1:]]
+
+
+def rec_select2_click(rec: Any, page: Any, select_id: str, label: str) -> None:
+ """A select2 widget: open it and click the option with this label (a fixed choice, not a parameter)."""
+
+ rec_click(rec, page, f"#select2-{select_id}-container")
+ page.wait_for_timeout(300)
+ opt = page.locator(".select2-results__option", has_text=label)
+ exact = [i for i in range(opt.count()) if opt.nth(i).inner_text().strip() == label]
+ if len(exact) != 1:
+ raise RuntimeError(f"select2 option {label!r}: {len(exact)} exact matches")
+ box = opt.nth(exact[0]).bounding_box()
+ rec.click(int(box["x"] + box["width"] / 2), int(box["y"] + box["height"] / 2))
diff --git a/packages/flow/benchmark/api_generalization/counterfactual.py b/packages/flow/benchmark/api_generalization/counterfactual.py
new file mode 100644
index 0000000000..4abfb5531f
--- /dev/null
+++ b/packages/flow/benchmark/api_generalization/counterfactual.py
@@ -0,0 +1,48 @@
+"""Counterfactual input edit: drop the clicked submit button's name=value from the captured save.
+
+Not a product result. The frozen compiler can't explain a submit button's
+own name and value (``_FormParser`` skips submit inputs), which refuses every
+form whose save button has a name. This copies a recording, removes that one
+pair from the captured save request, and asks the same frozen compiler again,
+to find the next thing that would block the API step. The compiler is not
+changed; only its input is.
+
+ python -m benchmark.api_generalization.counterfactual --app trac --recording --out --field submit
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import shutil
+from pathlib import Path
+
+
+def strip_submit(rec: Path, out: Path, field: str) -> int:
+ shutil.copytree(rec, out)
+ lines = (out / "network.jsonl").read_text().splitlines()
+ n = 0
+ fixed = [lines[0]]
+ for raw in lines[1:]:
+ d = json.loads(raw)
+ if d.get("method") == "POST" and d.get("form") is not None:
+ before = len(d["form"])
+ d["form"] = [p for p in d["form"] if p[0] != field]
+ n += before - len(d["form"])
+ fixed.append(json.dumps(d))
+ (out / "network.jsonl").write_text("\n".join(fixed) + "\n")
+ (out / "COUNTERFACTUAL.txt").write_text(f"removed {n} '{field}' pair(s) from captured POST forms\n")
+ return n
+
+
+def main() -> None:
+ ap = argparse.ArgumentParser()
+ ap.add_argument("--recording", required=True)
+ ap.add_argument("--out", required=True)
+ ap.add_argument("--field", required=True)
+ a = ap.parse_args()
+ print(json.dumps({"removed": strip_submit(Path(a.recording), Path(a.out), a.field)}))
+
+
+if __name__ == "__main__":
+ main()
diff --git a/packages/flow/benchmark/api_generalization/harness.py b/packages/flow/benchmark/api_generalization/harness.py
new file mode 100644
index 0000000000..1d0eac9cda
--- /dev/null
+++ b/packages/flow/benchmark/api_generalization/harness.py
@@ -0,0 +1,340 @@
+"""Record, compile, propose, admit and run one app's save workflow, two arms.
+
+ python -m benchmark.api_generalization.harness up --app redmine
+ python -m benchmark.api_generalization.harness record --app redmine --out
+ python -m benchmark.api_generalization.harness compile --app redmine --recording --out
+ python -m benchmark.api_generalization.harness matrix --app redmine --bundle --out
+ python -m benchmark.api_generalization.harness timing --app redmine --bundle --out --reps 10
+ python -m benchmark.api_generalization.harness down --app redmine
+
+``compile`` uses only the frozen compiler: ``compile_recording``, then the
+save step's record check (the adapter's ``effects``, as a person adds it),
+then ``propose_api_step`` (``openadapt-flow api propose``) and, when there is
+a proposal, ``admit_api_step`` under the name ``benchmark-fixture`` so the
+matrix can run unattended (``openadapt-flow api admit`` in use).
+
+Arms: ``gui`` is ``Replayer(api_steps=False)``; ``api`` is
+``Replayer(api_steps=True)``. Both use the same bundle, the same record
+check, the same proxy and the same judge.
+"""
+
+from __future__ import annotations
+
+import argparse
+import csv
+import importlib
+import json
+import os
+import shutil
+import subprocess
+import sys
+import time
+from pathlib import Path
+from typing import Any
+
+from benchmark.api_generalization.common import AppBase, Case, classify, judge
+from benchmark.api_generalization.proxy import serve
+
+HERE = Path(__file__).resolve().parent
+FLOW = HERE.parents[1]
+SETTLE = {"settle_timeout_s": 10.0, "settle_stable_frames": 3, "settle_interval_s": 0.3}
+VIEWPORT = {"width": 1280, "height": 800}
+
+
+def load_app(name: str) -> AppBase:
+ return importlib.import_module(f"benchmark.api_generalization.apps.{name}").App()
+
+
+def engine_sha() -> str:
+ return subprocess.run(["git", "-C", str(FLOW), "rev-parse", "HEAD"], capture_output=True, text=True).stdout.strip()
+
+
+def frozen_check() -> dict[str, Any]:
+ """The runtime and compiler must be the frozen commit's (no local changes)."""
+ frozen = os.environ.get("APIGEN_FROZEN", "")
+ diff = subprocess.run(
+ ["git", "-C", str(FLOW), "diff", "--stat", frozen or "HEAD", "--", "openadapt_flow", "../types"],
+ capture_output=True, text=True).stdout.strip()
+ return {"frozen": frozen, "head": engine_sha(), "engine_diff_vs_frozen": diff}
+
+
+def state_dir(app: AppBase) -> Path:
+ d = Path(os.environ.get("APIGEN_STATE", Path.home() / "oa/scratch/api-generalization")) / app.name
+ d.mkdir(parents=True, exist_ok=True)
+ return d
+
+
+def set_fault(app: AppBase, mode: str) -> None:
+ (state_dir(app) / "mode.json").write_text(json.dumps(
+ {"mode": mode, "path_regex": app.save_path_regex, "body_exclude": app.save_body_exclude,
+ "body_require": app.save_body_require}))
+
+
+def proxy_log_len(app: AppBase) -> int:
+ p = state_dir(app) / "requests.jsonl"
+ return len(p.read_text().splitlines()) if p.exists() else 0
+
+
+def proxy_log_since(app: AppBase, n: int) -> list[dict[str, Any]]:
+ p = state_dir(app) / "requests.jsonl"
+ return [json.loads(x) for x in p.read_text().splitlines()[n:]] if p.exists() else []
+
+
+def start_proxy(app: AppBase) -> None:
+ set_fault(app, "pass")
+ serve(app.proxy_port, app.upstream_port, state_dir(app))
+
+
+# -- record ------------------------------------------------------------------------
+
+
+def cmd_record(a: argparse.Namespace, app: AppBase) -> None:
+ from playwright.sync_api import sync_playwright
+
+ from openadapt_flow.backends.playwright_backend import PlaywrightBackend
+ from openadapt_flow.network_capture import NetworkCapture
+ from openadapt_flow.recorder import Recorder
+
+ out = Path(a.out)
+ if out.exists():
+ raise SystemExit(f"exists: {out}")
+ start_proxy(app)
+ values = app.recording_values()
+ before = app.rows()
+ pw = sync_playwright().start()
+ browser = pw.chromium.launch(headless=True)
+ try:
+ ctx = browser.new_context(viewport=VIEWPORT, device_scale_factor=1)
+ page = ctx.new_page()
+ page.goto(app.base + "/")
+ if app.login_required:
+ app.login(page)
+ capture = NetworkCapture(ctx).start()
+ app.open_form(page)
+ rec = Recorder(PlaywrightBackend(page), out, app_url=page.url, network_capture=capture, **SETTLE)
+ app.record(rec, page, values)
+ recording = rec.finish()
+ time.sleep(1.5)
+ after = app.rows()
+ case = Case(id="recording", kind="normal", params=values, expect_new=1,
+ expect_fields=app.expect_fields(values))
+ state = judge(before, after, case, app.key)
+ events = [json.loads(x) for x in (recording / "events.jsonl").read_text().splitlines() if x.strip()]
+ print(json.dumps({"recording": str(recording), "events": len(events), "exchanges": capture.count,
+ "judge": state}, indent=1))
+ if not state["state_ok"]:
+ raise SystemExit("the demonstration did not save the expected record; record again")
+ finally:
+ browser.close()
+ pw.stop()
+
+
+# -- compile -----------------------------------------------------------------------
+
+
+def cmd_compile(a: argparse.Namespace, app: AppBase) -> None:
+ from openadapt_flow.compiler import compile_recording
+ from openadapt_flow.compiler.api_proposal import (
+ admit_api_step,
+ describe_api_step,
+ propose_api_step,
+ )
+ from openadapt_flow.ir import Workflow
+
+ rec, bundle = Path(a.recording), Path(a.out)
+ t0 = time.time()
+ compile_recording(rec, bundle, name=f"{app.name}-{app.workflow}")
+ first = json.loads((bundle / "api_proposal.json").read_text()) if (bundle / "api_proposal.json").exists() else None
+ wf = Workflow.load(bundle)
+ # A person marks the fields that may be left blank as optional (compile
+ # makes every parameter required, and a run refuses "" for a required one).
+ optional = sorted(k for k, v in app.blank_values.items() if v == "" and k in wf.param_specs)
+ for k in optional:
+ wf.param_specs[k].required = False
+ save = wf.steps[-1]
+ save.effects = app.effects()
+ save.risk = "irreversible"
+ save.risk_explanation = f"the {app.workflow} save"
+ save.risk_review_required = False
+ wf, step, reasons = propose_api_step(wf, rec, replace=True)
+ result: dict[str, Any] = {"app": app.name, "workflow": app.workflow, "steps": len(wf.steps),
+ "params": sorted(wf.params), "optional_params": optional, "compile_proposal": first,
+ "proposed": step is not None, "reasons": reasons,
+ "compile_s": round(time.time() - t0, 1)} | frozen_check()
+ if step is not None:
+ wf, admitted = admit_api_step(wf, admitted_by="benchmark-fixture")
+ result["admitted"] = admitted
+ result["describe"] = describe_api_step(next(s for s in wf.steps if s.id == step.id))
+ result["binding"] = step.api_binding.web_form.model_dump(mode="json") if step.api_binding else None
+ wf.save(bundle)
+ (bundle / "apigen-compile.json").write_text(json.dumps(result, indent=1, default=str))
+ print(json.dumps({k: v for k, v in result.items() if k != "binding"}, indent=1, default=str))
+
+
+# -- run ---------------------------------------------------------------------------
+
+COLS = ["app", "workflow", "bundle_kind", "case", "kind", "arm", "rep", "correct", "incorrect_write", "false_success", "stopped_after_right_write", "missed_write",
+ "new_records", "field_mismatches", "collateral", "engine_success", "outcome", "transaction_outcome",
+ "save_actuation", "api_fallback", "halt", "save_posts", "fault", "injected", "error", "wall_s", "setup_s", "engine_s",
+ "load1", "start_local", "engine_sha"]
+
+
+def _load1() -> float:
+ return float(subprocess.run(["sysctl", "-n", "vm.loadavg"], capture_output=True, text=True).stdout.split()[1])
+
+
+def run_one(env: dict[str, Any], app: AppBase, case: Case, arm: str, bundle: Path, out: Path, rep: int = 1) -> dict[str, Any]:
+ from openadapt_flow.backends.playwright_backend import PlaywrightBackend
+ from openadapt_flow.ir import Workflow
+ from openadapt_flow.runtime import Replayer
+
+ out.mkdir(parents=True, exist_ok=True)
+ res: dict[str, Any] = {"app": app.name, "workflow": app.workflow, "case": case.id, "kind": case.kind,
+ "arm": arm, "rep": rep, "fault": case.fault, "error": None}
+ t0 = time.time()
+ ctx = env["browser"].new_context(viewport=VIEWPORT, device_scale_factor=1)
+ restore = False
+ try:
+ page = ctx.new_page()
+ page.goto(app.base + "/")
+ if app.login_required:
+ app.login(page)
+ if case.kind == "changed_default":
+ app.change_default()
+ restore = True
+ app.open_form(page)
+ if case.kind == "expired_session":
+ app.expire_session(page)
+ set_fault(app, case.fault or "pass")
+ before = app.rows()
+ n_log = proxy_log_len(app)
+ t_setup = time.time()
+ rep_ = Replayer(PlaywrightBackend(page), effect_verifier=app.verifier(), api_steps=(arm == "api")).run(
+ Workflow.load(bundle), params=dict(case.params), bundle_dir=bundle, run_dir=out / "engine")
+ t_task = time.time()
+ raw = rep_.model_dump(mode="json")
+ last = raw["results"][-1] if raw["results"] else {}
+ res.update(engine_success=raw.get("success"), outcome=str(raw.get("execution_outcome")),
+ transaction_outcome=raw.get("transaction_outcome"), save_actuation=last.get("actuation"),
+ api_fallback=next((r["api_fallback"] for r in raw["results"] if r.get("api_fallback")), None),
+ halt=next(((r["step_id"] + ": " + (r.get("error") or ""))[:240] for r in raw["results"] if r.get("error")), None),
+ step_errors=[(r["step_id"], (r.get("error") or "")[:300]) for r in raw["results"] if r.get("error")])
+ except Exception as exc: # noqa: BLE001
+ t_setup = locals().get("t_setup", time.time())
+ t_task = time.time()
+ res["error"] = f"{type(exc).__name__}: {exc}"[:500]
+ before = locals().get("before")
+ n_log = locals().get("n_log", proxy_log_len(app))
+ time.sleep(1.5)
+ set_fault(app, "pass")
+ after = app.rows()
+ if before is None:
+ before = after
+ res["error"] = (res["error"] or "") + " [no before-state]"
+ state = judge(before, after, case, app.key)
+ log = proxy_log_since(app, n_log)
+ posts = [r for r in log if r.get("save")]
+ res["injected"] = next((r["fault"] for r in log if r.get("fault") not in (None, "pass")), None)
+ res.update(state)
+ res["save_posts"] = len(posts)
+ res.update(classify(case, state, res.get("engine_success"), res.get("transaction_outcome")))
+ if restore:
+ app.restore_default()
+ ctx.close()
+ t_end = time.time()
+ res.update(wall_s=round(t_end - t0, 3), setup_s=round(t_setup - t0, 3), engine_s=round(t_task - t_setup, 3))
+ (out / "result.json").write_text(json.dumps(res, indent=1, default=str))
+ shutil.rmtree(out / "engine" / "steps", ignore_errors=True)
+ return res
+
+
+def _env() -> dict[str, Any]:
+ from playwright.sync_api import sync_playwright
+
+ pw = sync_playwright().start()
+ return {"pw": pw, "browser": pw.chromium.launch(headless=True)}
+
+
+def _write_row(csv_path: Path, row: dict[str, Any]) -> None:
+ new = not csv_path.exists()
+ with open(csv_path, "a", newline="") as fh:
+ w = csv.DictWriter(fh, fieldnames=COLS, extrasaction="ignore")
+ if new:
+ w.writeheader()
+ w.writerow({k: (json.dumps(v) if isinstance(v, (list, dict)) else v) for k, v in row.items()})
+
+
+def cmd_matrix(a: argparse.Namespace, app: AppBase) -> None:
+ out, bundle = Path(a.out), Path(a.bundle)
+ out.mkdir(parents=True, exist_ok=True)
+ (out / "engine.json").write_text(json.dumps(frozen_check(), indent=1))
+ start_proxy(app)
+ env = _env()
+ cases = [c for c in app.cases() if not a.only or c.id in a.only.split(",")]
+ arms = a.arms.split(",")
+ try:
+ for i, case in enumerate(cases):
+ for arm in (arms if i % 2 == 0 else list(reversed(arms))):
+ start = time.strftime("%H:%M:%S")
+ res = run_one(env, app, app.unique(case, f"{case.id}{arm[0]}{a.tag}"), arm, bundle,
+ out / f"{case.id}-{arm}")
+ row = res | {"load1": _load1(), "start_local": start, "engine_sha": engine_sha()[:8],
+ "bundle_kind": a.bundle_kind}
+ _write_row(out / "runs.csv", row)
+ print(json.dumps({k: row.get(k) for k in ("case", "arm", "correct", "incorrect_write", "false_success",
+ "new_records", "engine_success", "save_actuation",
+ "api_fallback", "save_posts", "engine_s", "error")}), flush=True)
+ finally:
+ env["browser"].close()
+ env["pw"].stop()
+
+
+def cmd_timing(a: argparse.Namespace, app: AppBase) -> None:
+ out, bundle = Path(a.out), Path(a.bundle)
+ out.mkdir(parents=True, exist_ok=True)
+ (out / "engine.json").write_text(json.dumps(frozen_check(), indent=1))
+ start_proxy(app)
+ env = _env()
+ normal = [c for c in app.cases() if c.kind == "normal"]
+ try:
+ for r in range(a.reps):
+ for arm in (["gui", "api"] if r % 2 == 0 else ["api", "gui"]):
+ case = app.unique(normal[r % len(normal)], f"t{r:02d}{arm[0]}")
+ start = time.strftime("%H:%M:%S")
+ res = run_one(env, app, case, arm, bundle, out / f"r{r:02d}-{arm}", rep=r + 1)
+ row = res | {"load1": _load1(), "start_local": start, "engine_sha": engine_sha()[:8],
+ "bundle_kind": a.bundle_kind}
+ _write_row(out / "runs.csv", row)
+ print(json.dumps({k: row.get(k) for k in ("rep", "arm", "correct", "save_actuation", "engine_s",
+ "wall_s", "load1", "error")}), flush=True)
+ finally:
+ env["browser"].close()
+ env["pw"].stop()
+
+
+def main() -> None:
+ ap = argparse.ArgumentParser()
+ ap.add_argument("cmd", choices=["up", "down", "record", "compile", "matrix", "timing", "rows"])
+ ap.add_argument("--app", required=True)
+ ap.add_argument("--out")
+ ap.add_argument("--recording")
+ ap.add_argument("--bundle")
+ ap.add_argument("--only", default="")
+ ap.add_argument("--arms", default="gui,api")
+ ap.add_argument("--reps", type=int, default=10)
+ ap.add_argument("--bundle-kind", default="shipped", choices=["shipped", "counterfactual"])
+ ap.add_argument("--tag", default="", help="suffix that keeps a re-run's values unique")
+ a = ap.parse_args()
+ app = load_app(a.app)
+ if a.cmd == "up":
+ app.up()
+ elif a.cmd == "down":
+ app.down()
+ elif a.cmd == "rows":
+ print(json.dumps(app.rows()[-5:], indent=1, default=str))
+ else:
+ {"record": cmd_record, "compile": cmd_compile, "matrix": cmd_matrix, "timing": cmd_timing}[a.cmd](a, app)
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/packages/flow/benchmark/api_generalization/proxy.py b/packages/flow/benchmark/api_generalization/proxy.py
new file mode 100644
index 0000000000..9657a48075
--- /dev/null
+++ b/packages/flow/benchmark/api_generalization/proxy.py
@@ -0,0 +1,149 @@
+"""A reverse proxy in front of each app that injects faults on the save request.
+
+Every request from the browser and from the API step goes through this
+proxy, so a fault hits both arms at the same place: the app's front door.
+The mode applies to the next save, once. A save is a POST whose path matches
+``path_regex``, whose body matches ``body_require`` when set (an endpoint
+shared by several actions) and does not match ``body_exclude`` (an app whose
+page script posts the same form for a preview):
+
+- ``pass``: forward unchanged.
+- ``reject``: answer 503 without forwarding (nothing is written).
+- ``partial``: forward, let the app commit, then answer 502 with an empty
+ body (the write happened, the response was lost).
+- ``duplicate``: forward the same request twice and answer with the second
+ response (an at-least-once retry by something between client and app).
+
+Each request is logged to ``log_path`` (one JSON line) so the harness can
+count how many times a save reached the app.
+
+ python -m benchmark.api_generalization.proxy --listen 9822 --upstream 9821 --state
+"""
+
+from __future__ import annotations
+
+import argparse
+import http.client
+import json
+import re
+import threading
+import time
+from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
+from pathlib import Path
+
+HOP = {"connection", "keep-alive", "proxy-connection", "transfer-encoding", "te", "trailer", "upgrade"}
+
+
+class Control:
+ """Mode file shared with the harness (``mode.json`` in the state dir)."""
+
+ def __init__(self, state: Path) -> None:
+ self.state = state
+ self.mode_path = state / "mode.json"
+ self.log_path = state / "requests.jsonl"
+ self.lock = threading.Lock()
+
+ def take(self, method: str, path: str, body: bytes) -> tuple[str, bool]:
+ """The fault for this request (consumed if it applies), and whether it is a save."""
+ with self.lock:
+ try:
+ mode = json.loads(self.mode_path.read_text())
+ except (OSError, ValueError):
+ return "pass", False
+ text = body.decode("latin-1")
+ is_save = (method == "POST" and bool(re.search(mode.get("path_regex", "$^"), path))
+ and not (mode.get("body_exclude") and re.search(mode["body_exclude"], text))
+ and not (mode.get("body_require") and not re.search(mode["body_require"], text)))
+ if mode.get("mode", "pass") == "pass" or not is_save:
+ return "pass", is_save
+ self.mode_path.write_text(json.dumps({**mode, "mode": "pass", "consumed": mode["mode"]}))
+ return str(mode["mode"]), True
+
+ def log(self, row: dict) -> None:
+ with self.lock, open(self.log_path, "a") as fh:
+ fh.write(json.dumps(row) + "\n")
+
+
+def make_handler(upstream_host: str, upstream_port: int, control: Control):
+ class Handler(BaseHTTPRequestHandler):
+ protocol_version = "HTTP/1.1"
+
+ def log_message(self, *args) -> None: # quiet
+ pass
+
+ def _forward(self, body: bytes) -> tuple[int, str, list[tuple[str, str]], bytes]:
+ conn = http.client.HTTPConnection(upstream_host, upstream_port, timeout=120)
+ headers = {k: v for k, v in self.headers.items() if k.lower() not in HOP}
+ headers["Connection"] = "close"
+ conn.request(self.command, self.path, body=body or None, headers=headers)
+ r = conn.getresponse()
+ data = r.read()
+ out = (r.status, r.reason, [(k, v) for k, v in r.getheaders() if k.lower() not in HOP], data)
+ conn.close()
+ return out
+
+ def _reply(self, status: int, reason: str, headers: list[tuple[str, str]], data: bytes) -> None:
+ self.send_response(status, reason)
+ for k, v in headers:
+ if k.lower() != "content-length":
+ self.send_header(k, v)
+ self.send_header("Content-Length", str(len(data)))
+ self.send_header("Connection", "close")
+ self.end_headers()
+ if self.command != "HEAD":
+ self.wfile.write(data)
+ self.close_connection = True
+
+ def _handle(self) -> None:
+ n = int(self.headers.get("Content-Length") or 0)
+ body = self.rfile.read(n) if n else b""
+ fault, is_save = control.take(self.command, self.path.split("?")[0], body)
+ t0 = time.time()
+ status = None
+ try:
+ if fault == "reject":
+ status = 503
+ self._reply(503, "Service Unavailable", [("Content-Type", "text/plain")], b"injected fault: reject")
+ elif fault == "partial":
+ status, *_ = self._forward(body)
+ self._reply(502, "Bad Gateway", [("Content-Type", "text/plain")], b"")
+ status = f"{status}->502"
+ elif fault == "duplicate":
+ first, *_ = self._forward(body)
+ st, reason, headers, data = self._forward(body)
+ self._reply(st, reason, headers, data)
+ status = f"{first}+{st}"
+ else:
+ st, reason, headers, data = self._forward(body)
+ self._reply(st, reason, headers, data)
+ status = st
+ finally:
+ control.log({"t": round(t0, 3), "method": self.command, "path": self.path.split("?")[0],
+ "save": is_save, "fault": fault, "status": status, "ms": round((time.time() - t0) * 1000)})
+
+ do_GET = do_POST = do_HEAD = do_PUT = do_PATCH = do_DELETE = do_OPTIONS = _handle
+
+ return Handler
+
+
+def serve(listen: int, upstream: int, state: Path, upstream_host: str = "127.0.0.1") -> ThreadingHTTPServer:
+ state.mkdir(parents=True, exist_ok=True)
+ srv = ThreadingHTTPServer(("127.0.0.1", listen), make_handler(upstream_host, upstream, Control(state)))
+ srv.daemon_threads = True
+ threading.Thread(target=srv.serve_forever, daemon=True).start()
+ return srv
+
+
+def main() -> None:
+ ap = argparse.ArgumentParser()
+ ap.add_argument("--listen", type=int, required=True)
+ ap.add_argument("--upstream", type=int, required=True)
+ ap.add_argument("--state", required=True)
+ a = ap.parse_args()
+ serve(a.listen, a.upstream, Path(a.state))
+ while True:
+ time.sleep(3600)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/packages/flow/benchmark/api_generalization/run.sh b/packages/flow/benchmark/api_generalization/run.sh
new file mode 100755
index 0000000000..ffb154a1af
--- /dev/null
+++ b/packages/flow/benchmark/api_generalization/run.sh
@@ -0,0 +1,8 @@
+#!/bin/sh
+# Run the harness with the repository's own packages on the path.
+# APIGEN_PY: a Python with playwright and openadapt-flow's dependencies.
+set -e
+HERE=$(cd "$(dirname "$0")" && pwd)
+FLOW=$(cd "$HERE/../.." && pwd)
+cd "$FLOW"
+PYTHONPATH="$FLOW/../types:$FLOW" exec "${APIGEN_PY:-python}" -m benchmark.api_generalization.harness "$@"
diff --git a/packages/flow/benchmark/openemr_throughput/dialog_identity_2026-10-28/README.md b/packages/flow/benchmark/openemr_throughput/dialog_identity_2026-10-28/README.md
new file mode 100644
index 0000000000..015a689d68
--- /dev/null
+++ b/packages/flow/benchmark/openemr_throughput/dialog_identity_2026-10-28/README.md
@@ -0,0 +1,45 @@
+# Identity at OpenEMR's duplicate dialog, before and after #1347
+
+Measured on 2026-10-28 on the pinned local OpenEMR 8.0.0.3 fixture, with
+synthetic patients only. One referral was recorded once with the #1347 engine,
+so the confirm click carries `dialog_region`. It was compiled twice with
+`scripts/openemr_local_demo.py`:
+- with `main` (490d0dc40) as the baseline;
+- with this branch, which reads the band on the dialog alone.
+
+The OCR band was the full-width strip around the button, so it read a label of
+the form behind the dialog. "Before" means `main` and "after" means this branch.
+
+## The identity check at the confirm step, form scrolled behind the dialog
+
+`scroll_identity.py` opens the real duplicate dialog with the New Patient form
+scrolled to several positions behind it. It runs the replayer's identity check
+for the confirm step of each bundle and saves nothing.
+
+| Path | Positions | `main` false stops | Branch false stops |
+|---|---|---|---|
+| OCR band (base Playwright backend, as in #1347) | 8 | 8 | 0 |
+| OpenEMR adapter's structured identity | 8 | 0 | 0 |
+
+- **Before.** On the OCR path, `main` read "Title: Cancel", "DOB: Cancel" or "License/ID: Cancel", whichever label sat beside the dialog, and refused every time. The recording had read "County:".
+- **After.** The branch read the dialog's own text and verified every time.
+- **Adapter path.** With the adapter, the structured tier decides, so neither bundle reads the band.
+
+Files: `scroll-ocr-tier.json` and `scroll-openemr-adapter.json`.
+
+## Fail closed on a listed duplicate
+
+`listed-duplicate.json`: the dialog for a patient who matches a seeded chart lists that chart. Both bundles refuse at both positions. The branch refuses because the dialog's listed row is not the empty result it recorded.
+
+## Worklist batches
+
+`batch-before-main.json` and `batch-after-branch.json` are throughput harness runs (`--items 26 --workers 1 --speed standard --backend openemr`).
+
+| Bundle | Correct | Wrong or extra charts | Stops |
+|---|---|---|---|
+| `main` | 26/26 | 0 | 0 |
+| Branch | 26/26 | 0 | 0 |
+
+The `engine_sha` in these files is the harness checkout, not the engine. The baseline ran `main`'s `openadapt_flow`.
+
+In these batches the form behind the dialog sat where it was recorded, so the baseline didn't hit the stop. The batches show that the branch holds no correct save. The scroll table above shows the stop itself.
diff --git a/packages/flow/benchmark/openemr_throughput/dialog_identity_2026-10-28/batch-after-branch.json b/packages/flow/benchmark/openemr_throughput/dialog_identity_2026-10-28/batch-after-branch.json
new file mode 100644
index 0000000000..bfa36a89c6
--- /dev/null
+++ b/packages/flow/benchmark/openemr_throughput/dialog_identity_2026-10-28/batch-after-branch.json
@@ -0,0 +1,61 @@
+{
+ "tag": "after-branch",
+ "workers": 1,
+ "items": 26,
+ "window": 0,
+ "halt_at": null,
+ "profile": false,
+ "engine_sha": "4bb59503add4816e015a877dffe8f685527645d8",
+ "speed_profile": "standard",
+ "native_fill": false,
+ "page_quiet_ms": 100,
+ "ocr_threads": null,
+ "engine_dirty": false,
+ "bundle_digest": "93f8127d4af367500fc86ddd0bafa0d27b6e5764818f8999ab5c4abc56ec2973",
+ "baseline": "a1b14e0031d1",
+ "seed_status": 201,
+ "setup_s": 6.03,
+ "started": "2026-10-28 20:24:40",
+ "processes": false,
+ "wall_s": 492.285,
+ "pool_wall_s": 492.28,
+ "sign_in_s": {
+ "oa_clerk1": 2.24
+ },
+ "counts": {
+ "completed": 26
+ },
+ "stop": null,
+ "items_run": 26,
+ "saved_correct": 26,
+ "wrong_or_extra_charts": 0,
+ "not_saved": 0,
+ "new_charts_total": 26,
+ "entries_per_hour": 190.1,
+ "written_after_failed_readback": {},
+ "per_worker": [
+ {
+ "name": "oa_clerk1",
+ "items": 26,
+ "states": {
+ "completed": 26
+ },
+ "busy_s": 489.9
+ }
+ ],
+ "load1_mean": 23.04,
+ "load1_max": 90.04,
+ "ncpu": 12,
+ "gate": {
+ "threshold": 40.0,
+ "waited_s": 15.0,
+ "gave_up": false,
+ "load_at_start": [
+ 8.98681640625,
+ 15.39794921875,
+ 37.7783203125
+ ]
+ },
+ "valid": true,
+ "ended": "2026-10-28 20:33:09"
+}
\ No newline at end of file
diff --git a/packages/flow/benchmark/openemr_throughput/dialog_identity_2026-10-28/batch-before-main.json b/packages/flow/benchmark/openemr_throughput/dialog_identity_2026-10-28/batch-before-main.json
new file mode 100644
index 0000000000..72c4802b2a
--- /dev/null
+++ b/packages/flow/benchmark/openemr_throughput/dialog_identity_2026-10-28/batch-before-main.json
@@ -0,0 +1,61 @@
+{
+ "tag": "before-main",
+ "workers": 1,
+ "items": 26,
+ "window": 0,
+ "halt_at": null,
+ "profile": false,
+ "engine_sha": "4bb59503add4816e015a877dffe8f685527645d8",
+ "speed_profile": "standard",
+ "native_fill": false,
+ "page_quiet_ms": 100,
+ "ocr_threads": null,
+ "engine_dirty": false,
+ "bundle_digest": "6750b6dc15e5ddf1fb836c10e52f6e0577caf3cba89d39e9103049b8d3f86aa8",
+ "baseline": "a1b14e0031d1",
+ "seed_status": 201,
+ "setup_s": 7.79,
+ "started": "2026-10-28 20:15:00",
+ "processes": false,
+ "wall_s": 540.018,
+ "pool_wall_s": 540.009,
+ "sign_in_s": {
+ "oa_clerk1": 2.79
+ },
+ "counts": {
+ "completed": 26
+ },
+ "stop": null,
+ "items_run": 26,
+ "saved_correct": 26,
+ "wrong_or_extra_charts": 0,
+ "not_saved": 0,
+ "new_charts_total": 26,
+ "entries_per_hour": 173.3,
+ "written_after_failed_readback": {},
+ "per_worker": [
+ {
+ "name": "oa_clerk1",
+ "items": 26,
+ "states": {
+ "completed": 26
+ },
+ "busy_s": 537.1
+ }
+ ],
+ "load1_mean": 10.65,
+ "load1_max": 16.03,
+ "ncpu": 12,
+ "gate": {
+ "threshold": 40.0,
+ "waited_s": 15.0,
+ "gave_up": false,
+ "load_at_start": [
+ 15.7275390625,
+ 52.67236328125,
+ 65.37109375
+ ]
+ },
+ "valid": true,
+ "ended": "2026-10-28 20:24:17"
+}
\ No newline at end of file
diff --git a/packages/flow/benchmark/openemr_throughput/dialog_identity_2026-10-28/listed-duplicate.json b/packages/flow/benchmark/openemr_throughput/dialog_identity_2026-10-28/listed-duplicate.json
new file mode 100644
index 0000000000..c86a87cfd0
--- /dev/null
+++ b/packages/flow/benchmark/openemr_throughput/dialog_identity_2026-10-28/listed-duplicate.json
@@ -0,0 +1,16 @@
+[
+ {
+ "scroll": 0,
+ "main": "mismatch",
+ "main_observed": "Who Cancel",
+ "branch": "mismatch",
+ "branch_observed": "Hits Name Preferred Name Birth Name DOB Birth Sex External ID Previous Names 2 D"
+ },
+ {
+ "scroll": 600,
+ "main": "mismatch",
+ "main_observed": "Cancel",
+ "branch": "mismatch",
+ "branch_observed": "Hits Name Preferred Name Birth Name DOB Birth Sex External ID Previous Names 2 D"
+ }
+]
\ No newline at end of file
diff --git a/packages/flow/benchmark/openemr_throughput/dialog_identity_2026-10-28/scroll-ocr-tier.json b/packages/flow/benchmark/openemr_throughput/dialog_identity_2026-10-28/scroll-ocr-tier.json
new file mode 100644
index 0000000000..0bdcfbcc03
--- /dev/null
+++ b/packages/flow/benchmark/openemr_throughput/dialog_identity_2026-10-28/scroll-ocr-tier.json
@@ -0,0 +1,58 @@
+[
+ {
+ "scroll": 0,
+ "main": "mismatch",
+ "main_observed": "Title: Cancel",
+ "branch": "verified",
+ "branch_observed": "Preferred Birth Birth External Previous Hits Name Name Name DOB Sex ID Names No "
+ },
+ {
+ "scroll": 150,
+ "main": "mismatch",
+ "main_observed": "DOB: Cancel",
+ "branch": "verified",
+ "branch_observed": "Preferred Birth Birth External Previous Hits Name Name Name DOB Sex ID Names No "
+ },
+ {
+ "scroll": 300,
+ "main": "mismatch",
+ "main_observed": "License/ID: Cancel",
+ "branch": "verified",
+ "branch_observed": "Preferred Birth Birth External Previous Hits Name Name Name DOB Sex ID Names No "
+ },
+ {
+ "scroll": 450,
+ "main": "mismatch",
+ "main_observed": "License/ID: Cancel",
+ "branch": "verified",
+ "branch_observed": "Preferred Birth Birth External Previous Hits Name Name Name DOB Sex ID Names No "
+ },
+ {
+ "scroll": 600,
+ "main": "mismatch",
+ "main_observed": "License/ID: Cancel",
+ "branch": "verified",
+ "branch_observed": "Preferred Birth Birth External Previous Hits Name Name Name DOB Sex ID Names No "
+ },
+ {
+ "scroll": 750,
+ "main": "mismatch",
+ "main_observed": "License/ID: Cancel",
+ "branch": "verified",
+ "branch_observed": "Preferred Birth Birth External Previous Hits Name Name Name DOB Sex ID Names No "
+ },
+ {
+ "scroll": 900,
+ "main": "mismatch",
+ "main_observed": "License/ID: Cancel",
+ "branch": "verified",
+ "branch_observed": "Preferred Birth Birth External Previous Hits Name Name Name DOB Sex ID Names No "
+ },
+ {
+ "scroll": 1100,
+ "main": "mismatch",
+ "main_observed": "License/ID: Cancel",
+ "branch": "verified",
+ "branch_observed": "Preferred Birth Birth External Previous Hits Name Name Name DOB Sex ID Names No "
+ }
+]
\ No newline at end of file
diff --git a/packages/flow/benchmark/openemr_throughput/dialog_identity_2026-10-28/scroll-openemr-adapter.json b/packages/flow/benchmark/openemr_throughput/dialog_identity_2026-10-28/scroll-openemr-adapter.json
new file mode 100644
index 0000000000..ed0658a267
--- /dev/null
+++ b/packages/flow/benchmark/openemr_throughput/dialog_identity_2026-10-28/scroll-openemr-adapter.json
@@ -0,0 +1,58 @@
+[
+ {
+ "scroll": 0,
+ "main": "verified",
+ "main_observed": "{\"form_path\":\"/interface/new/new_comprehensive.php\",\"label\":\"Confirm Create New ",
+ "branch": "verified",
+ "branch_observed": "{\"form_path\":\"/interface/new/new_comprehensive.php\",\"label\":\"Confirm Create New "
+ },
+ {
+ "scroll": 150,
+ "main": "verified",
+ "main_observed": "{\"form_path\":\"/interface/new/new_comprehensive.php\",\"label\":\"Confirm Create New ",
+ "branch": "verified",
+ "branch_observed": "{\"form_path\":\"/interface/new/new_comprehensive.php\",\"label\":\"Confirm Create New "
+ },
+ {
+ "scroll": 300,
+ "main": "verified",
+ "main_observed": "{\"form_path\":\"/interface/new/new_comprehensive.php\",\"label\":\"Confirm Create New ",
+ "branch": "verified",
+ "branch_observed": "{\"form_path\":\"/interface/new/new_comprehensive.php\",\"label\":\"Confirm Create New "
+ },
+ {
+ "scroll": 450,
+ "main": "verified",
+ "main_observed": "{\"form_path\":\"/interface/new/new_comprehensive.php\",\"label\":\"Confirm Create New ",
+ "branch": "verified",
+ "branch_observed": "{\"form_path\":\"/interface/new/new_comprehensive.php\",\"label\":\"Confirm Create New "
+ },
+ {
+ "scroll": 600,
+ "main": "verified",
+ "main_observed": "{\"form_path\":\"/interface/new/new_comprehensive.php\",\"label\":\"Confirm Create New ",
+ "branch": "verified",
+ "branch_observed": "{\"form_path\":\"/interface/new/new_comprehensive.php\",\"label\":\"Confirm Create New "
+ },
+ {
+ "scroll": 750,
+ "main": "verified",
+ "main_observed": "{\"form_path\":\"/interface/new/new_comprehensive.php\",\"label\":\"Confirm Create New ",
+ "branch": "verified",
+ "branch_observed": "{\"form_path\":\"/interface/new/new_comprehensive.php\",\"label\":\"Confirm Create New "
+ },
+ {
+ "scroll": 900,
+ "main": "verified",
+ "main_observed": "{\"form_path\":\"/interface/new/new_comprehensive.php\",\"label\":\"Confirm Create New ",
+ "branch": "verified",
+ "branch_observed": "{\"form_path\":\"/interface/new/new_comprehensive.php\",\"label\":\"Confirm Create New "
+ },
+ {
+ "scroll": 1100,
+ "main": "verified",
+ "main_observed": "{\"form_path\":\"/interface/new/new_comprehensive.php\",\"label\":\"Confirm Create New ",
+ "branch": "verified",
+ "branch_observed": "{\"form_path\":\"/interface/new/new_comprehensive.php\",\"label\":\"Confirm Create New "
+ }
+]
\ No newline at end of file
diff --git a/packages/flow/benchmark/openemr_throughput/dialog_identity_2026-10-28/scroll_identity.py b/packages/flow/benchmark/openemr_throughput/dialog_identity_2026-10-28/scroll_identity.py
new file mode 100644
index 0000000000..76ae9e44af
--- /dev/null
+++ b/packages/flow/benchmark/openemr_throughput/dialog_identity_2026-10-28/scroll_identity.py
@@ -0,0 +1,61 @@
+"""Identity verdict at OpenEMR's confirm step, form behind scrolled to N positions."""
+
+import json
+import os
+import sys
+from pathlib import Path
+
+from benchmark.openemr_throughput import harness
+from openadapt_flow.ir import Resolution, Workflow
+from openadapt_flow.runtime import Replayer
+
+demo = harness._demo_module()
+fx = harness.make_fixture(
+ Path(os.path.expanduser("~/oa/scratch/throughput/fixture/state"))
+)
+M = Path(os.path.expanduser("~/oa/scratch/real-app-edge-cases"))
+bundles = {
+ name: (M / f"bundle-{name}", Workflow.load(M / f"bundle-{name}"))
+ for name in ("main", "branch")
+}
+spec_cls = __import__(
+ "openadapt_flow.benchmark.openemr_local", fromlist=["SyntheticPatientSpec"]
+).SyntheticPatientSpec
+params = spec_cls().params()
+scrolls = [int(v) for v in sys.argv[1].split(",")]
+rows = []
+for y in scrolls:
+ backend, close = demo.OpenEMRPlaywrightBackend.launch(fx.ui_base_url, headless=True)
+ try:
+ demo._preauthenticate_browser(backend, fx)
+ page = backend.page
+ form = demo._form_frame(page)
+ form.locator("#form_fname").fill(os.environ.get("P_FNAME", "Wilhelmina"))
+ form.locator("#form_lname").fill(os.environ.get("P_LNAME", "Okonkwo-Hale"))
+ form.locator("#form_DOB").fill(os.environ.get("P_DOB", "1961-04-09"))
+ form.locator("#form_DOB").press("Escape")
+ form.locator("#form_sex").select_option(label="Female")
+ form.evaluate(f"() => window.scrollTo(0, {y})")
+ form.locator("#create").evaluate("el => el.click()")
+ point = demo._center(demo._confirm_create_locator(page))
+ png = backend.screenshot()
+ row = {"scroll": y}
+ for name, (bdir, wf) in bundles.items():
+ step = wf.steps[-1]
+ rep = Replayer(demo._BasePlaywrightBackend(page))
+ res = Resolution(
+ rung="structural", point=point, confidence=1.0, elapsed_ms=0.0
+ )
+ check = rep._verify_identity(step, res, png, params, wf, bdir)
+ row[name] = check.status
+ row[name + "_observed"] = check.observed[:80]
+ rows.append(row)
+ print(json.dumps(row), flush=True)
+ finally:
+ close()
+out = M / os.environ.get("PERTURB_OUT", "perturb.json")
+out.write_text(json.dumps(rows, indent=1))
+for name in ("main", "branch"):
+ print(
+ name, "false stops:", sum(r[name] != "verified" for r in rows), "of", len(rows)
+ )
diff --git a/packages/flow/benchmark/openemr_throughput/harness.py b/packages/flow/benchmark/openemr_throughput/harness.py
index fcf9a2e817..d67108689c 100644
--- a/packages/flow/benchmark/openemr_throughput/harness.py
+++ b/packages/flow/benchmark/openemr_throughput/harness.py
@@ -274,7 +274,29 @@ def __init__(self, page: Any) -> None:
self.page = page
def open_form(page: Any) -> None:
+ """Open the blank form, once more if a navigation raced the setup (#1348).
+
+ Sign-in or the previous item's chart can still be navigating the page
+ when a worker claims its next item; an ``evaluate`` then loses its
+ execution context. That is a setup race, not an engine failure: wait
+ for the page to load and run the setup again, once.
+ """
+ try:
+ _open_form(page)
+ except Exception as exc: # noqa: BLE001 - only the navigation race retries
+ if "Execution context was destroyed" not in str(exc):
+ raise
+ page.wait_for_load_state("load", timeout=60_000)
+ for frame in page.frames:
+ try:
+ frame.wait_for_load_state("load", timeout=60_000)
+ except Exception: # noqa: BLE001 - a frame may detach meanwhile
+ continue
+ _open_form(page)
+
+ def _open_form(page: Any) -> None:
"""Start each item where the recording started: no chart open, blank form."""
+ page.wait_for_load_state("load", timeout=60_000)
if page.evaluate(
"() => !!(window.app_view_model && app_view_model.application_data.patient())"
):
@@ -306,7 +328,14 @@ def run_item(page: Any, idx: int, row: dict[str, Any], item_dir: Path, pipeline:
if not session:
session.append(fixture.token_session("oracle"))
oracle = OpenEMRPatientOracle(fixture.api_base_url, session[0], spec)
- backend = demo._BasePlaywrightBackend(page)
+ # Replay with the backend the bundle was recorded with: a bundle the
+ # demo's ``record`` made carries the OpenEMR adapter's field identity.
+ backend_cls = (
+ demo.OpenEMRPlaywrightBackend
+ if opts.get("backend") == "openemr"
+ else demo._BasePlaywrightBackend
+ )
+ backend = backend_cls(page)
prof = None
if opts.get("profile"):
from benchmark.openemr_throughput.profiler import RunProfiler
@@ -398,6 +427,7 @@ def run(a: argparse.Namespace) -> dict[str, Any]:
"window": a.window, "profile": a.profile, "keep_pngs": a.keep_pngs,
"page_quiet_ms": a.page_quiet_ms, "ocr_threads": a.ocr_threads,
"speed": a.speed,
+ "backend": a.backend,
}
if a.processes:
from openadapt_flow.runtime.worklist_pool import ProcessWorker
@@ -495,6 +525,8 @@ def main() -> None:
r.add_argument("--window", type=int, default=0)
r.add_argument("--halt-at", type=int, default=None)
r.add_argument("--profile", action="store_true")
+ r.add_argument("--backend", choices=["base", "openemr"], default="base",
+ help="the browser backend the bundle was recorded with")
r.add_argument("--speed", choices=["standard", "fast"], default="standard",
help="named speed profile (openadapt_flow.runtime.speed)")
r.add_argument("--ocr-threads", type=int, default=None,
diff --git a/packages/flow/benchmark/phase_latency/__init__.py b/packages/flow/benchmark/phase_latency/__init__.py
new file mode 100644
index 0000000000..915081269d
--- /dev/null
+++ b/packages/flow/benchmark/phase_latency/__init__.py
@@ -0,0 +1 @@
+"""Generic numeric timing contracts; measurements remain private."""
diff --git a/packages/flow/benchmark/phase_latency/collector.py b/packages/flow/benchmark/phase_latency/collector.py
new file mode 100644
index 0000000000..e415208090
--- /dev/null
+++ b/packages/flow/benchmark/phase_latency/collector.py
@@ -0,0 +1,469 @@
+"""Numeric-only timing observations; no fixture control or artifact publishing.
+
+Step spans are exclusive. Outer intervals are inclusive and remain a separate
+basis. Uncovered time is unattributed, never inferred engine overhead.
+"""
+
+from __future__ import annotations
+
+import math
+import re
+import statistics
+import time
+from contextlib import contextmanager
+from dataclasses import asdict, dataclass
+from typing import Any, Callable, Iterator, Mapping, Sequence
+
+from openadapt_flow.runtime.step_timing import SPANS
+
+STEP_GROUPS = {
+ "settle_before": "settle",
+ "settle_after": "settle",
+ "identity": "identity",
+ "actuate": "actions",
+ "postcondition": "verification",
+ "readback": "verification",
+ "resolve": "resolution",
+ "screenshot": "capture_ocr",
+ "ocr": "capture_ocr",
+ "artifact_write": "evidence",
+ "other": "unattributed_step",
+}
+OUTER_PHASES = frozenset(
+ {
+ "navigation",
+ "session",
+ "authentication",
+ "admission",
+ "replay",
+ "oracle",
+ "finalization",
+ "engine_bookkeeping",
+ }
+)
+OUTCOMES = frozenset(
+ {"VERIFIED", "HALTED", "FAILED", "COMPLETED_UNVERIFIED", "ROLLED_BACK"}
+)
+
+
+def _number(value: Any) -> bool:
+ return (
+ isinstance(value, (int, float))
+ and not isinstance(value, bool)
+ and math.isfinite(value)
+ and value >= 0
+ )
+
+
+@dataclass(frozen=True, order=True)
+class Trial:
+ """Opaque app label and a preregistered matrix slot; no task values."""
+
+ app: str
+ mode: str
+ block: int
+ profiled: bool
+
+ def __post_init__(self) -> None:
+ if not re.fullmatch(r"app_[a-z0-9]+", self.app):
+ raise ValueError("use an opaque app label")
+ if (
+ self.mode not in {"cold", "warm"}
+ or type(self.block) is not int
+ or self.block < 1
+ or type(self.profiled) is not bool
+ ):
+ raise ValueError("invalid matrix slot")
+
+
+@dataclass(frozen=True)
+class Observation:
+ trial: Trial
+ attempt: int
+ source_head: str
+ wall_ms: float | None
+ status: str
+ outcome: str
+ oracle: str
+ step_count: int
+ measured_steps: int
+ step_exclusive_ms: dict[str, float]
+ step_issues: tuple[tuple[int, str], ...]
+ outer_inclusive_ms: dict[str, float]
+ outer_overlap_ms: float | None
+ unattributed_outer_ms: float | None
+ load_samples: tuple[dict[str, float], ...]
+ load_missing: bool
+ human_active_s: float | None
+ human_wait_s: float | None
+
+ @property
+ def verified(self) -> bool:
+ return (
+ self.status == "completed"
+ and self.outcome == "VERIFIED"
+ and self.oracle == "pass"
+ )
+
+ def to_dict(self) -> dict[str, Any]:
+ return {"schema": "openadapt.phase-observation/v1", **asdict(self)}
+
+
+def untimed_failure(
+ trial: Trial,
+ *,
+ attempt: int,
+ source_head: str,
+ status: str,
+ human_active_s: float | None = None,
+ human_wait_s: float | None = None,
+) -> Observation:
+ """Retain a started attempt even when setup or its clock produced no data."""
+ if (
+ type(attempt) is not int
+ or attempt < 1
+ or not re.fullmatch(r"[0-9a-f]{40}", source_head)
+ ):
+ raise ValueError("invalid attempt provenance")
+ if status not in {"setup_error", "timeout", "error"}:
+ raise ValueError("an untimed attempt cannot claim completion")
+ if any(v is not None and not _number(v) for v in (human_active_s, human_wait_s)):
+ raise ValueError("invalid human-time observation")
+ return Observation(
+ trial=trial,
+ attempt=attempt,
+ source_head=source_head,
+ wall_ms=None,
+ status=status,
+ outcome="unknown",
+ oracle="unrun",
+ step_count=0,
+ measured_steps=0,
+ step_exclusive_ms={},
+ step_issues=((-1, "missing_outer_clock"),),
+ outer_inclusive_ms={},
+ outer_overlap_ms=None,
+ unattributed_outer_ms=None,
+ load_samples=(),
+ load_missing=True,
+ human_active_s=human_active_s,
+ human_wait_s=human_wait_s,
+ )
+
+
+def _steps(
+ report: Mapping[str, Any] | None,
+) -> tuple[int, int, dict[str, float], tuple[tuple[int, str], ...]]:
+ results = report.get("results") if report is not None else None
+ if not isinstance(results, list) or not results:
+ return 0, 0, {}, ((-1, "missing_steps"),)
+ totals: dict[str, float] = {}
+ issues: list[tuple[int, str]] = []
+ measured = 0
+ known = set(SPANS) | {"other"}
+ for i, result in enumerate(results):
+ if not isinstance(result, Mapping) or not isinstance(
+ result.get("timings"), Mapping
+ ):
+ issues.append((i, "missing_timings"))
+ continue
+ spans = result["timings"]
+ elapsed = result.get("elapsed_ms")
+ if not _number(elapsed) or any(not _number(v) for v in spans.values()):
+ issues.append((i, "invalid_duration"))
+ continue
+ if any(k not in known or k not in STEP_GROUPS for k in spans):
+ issues.append((i, "unknown_span"))
+ continue
+ # StepTimer rounds each span to 0.1 ms. Never renormalize a mismatch.
+ if abs(sum(spans.values()) - elapsed) > 0.05 * len(spans) + 0.05:
+ issues.append((i, "span_elapsed_mismatch"))
+ continue
+ measured += 1
+ for span, duration in spans.items():
+ group = STEP_GROUPS[span]
+ totals[group] = totals.get(group, 0.0) + duration
+ return len(results), measured, totals, tuple(issues)
+
+
+class AttemptClock:
+ """Monotonic outer clock. A finished attempt cannot be reused."""
+
+ def __init__(self, *, clock: Callable[[], float] = time.perf_counter) -> None:
+ self._clock = clock
+ self._start = clock()
+ if (
+ isinstance(self._start, bool)
+ or not isinstance(self._start, (int, float))
+ or not math.isfinite(self._start)
+ ):
+ raise ValueError("invalid monotonic clock")
+ self._last = self._start
+ self._intervals: list[tuple[str, float, float]] = []
+ self._loads: list[dict[str, float]] = []
+ self._open = 0
+ self._finished = False
+
+ def _now(self) -> float:
+ if self._finished:
+ raise ValueError("attempt already finished")
+ now = self._clock()
+ if (
+ isinstance(now, bool)
+ or not isinstance(now, (int, float))
+ or not math.isfinite(now)
+ or now < self._last
+ ):
+ raise ValueError("clock did not remain monotonic")
+ self._last = now
+ return (now - self._start) * 1000.0
+
+ @contextmanager
+ def span(self, phase: str) -> Iterator[None]:
+ if phase not in OUTER_PHASES:
+ raise ValueError("unknown outer phase")
+ start = self._now()
+ self._open += 1
+ try:
+ yield
+ finally:
+ self._intervals.append((phase, start, self._now()))
+ self._open -= 1
+
+ def load(self, *, load1: float, cpu_percent: float, available_bytes: int) -> None:
+ if (
+ not all(_number(v) for v in (load1, cpu_percent, available_bytes))
+ or cpu_percent > 100
+ or type(available_bytes) is not int
+ ):
+ raise ValueError("invalid numeric resource observation")
+ self._loads.append(
+ {
+ "at_ms": self._now(),
+ "load1": load1,
+ "cpu_percent": cpu_percent,
+ "available_bytes": available_bytes,
+ }
+ )
+
+ def finish(
+ self,
+ trial: Trial,
+ *,
+ attempt: int,
+ source_head: str,
+ status: str,
+ oracle: str,
+ report: Mapping[str, Any] | None = None,
+ human_active_s: float | None = None,
+ human_wait_s: float | None = None,
+ ) -> Observation:
+ if self._open:
+ raise ValueError("outer phase still open")
+ if (
+ type(attempt) is not int
+ or attempt < 1
+ or not re.fullmatch(r"[0-9a-f]{40}", source_head)
+ ):
+ raise ValueError("invalid attempt provenance")
+ if status not in {
+ "completed",
+ "setup_error",
+ "timeout",
+ "error",
+ } or oracle not in {"pass", "fail", "unknown", "unrun"}:
+ raise ValueError("invalid attempt verdict")
+ if any(
+ v is not None and not _number(v) for v in (human_active_s, human_wait_s)
+ ):
+ raise ValueError("invalid human-time observation")
+ wall = self._now()
+ self._finished = True
+ inclusive: dict[str, float] = {}
+ union_end = 0.0
+ covered = 0.0
+ for phase, start, end in sorted(self._intervals, key=lambda row: row[1]):
+ inclusive[phase] = inclusive.get(phase, 0.0) + end - start
+ covered += max(0.0, end - max(start, union_end))
+ union_end = max(union_end, end)
+ step_count, measured, totals, issues = _steps(report)
+ outcome = report.get("execution_outcome") if report is not None else None
+ outcome = (
+ outcome if isinstance(outcome, str) and outcome in OUTCOMES else "unknown"
+ )
+ points = [0.0, *(row["at_ms"] for row in self._loads), wall]
+ missing_load = not self._loads or any(
+ b - a > 2000.0 for a, b in zip(points, points[1:])
+ )
+ return Observation(
+ trial,
+ attempt,
+ source_head,
+ wall,
+ status,
+ outcome,
+ oracle,
+ step_count,
+ measured,
+ totals,
+ issues,
+ inclusive,
+ max(0.0, sum(inclusive.values()) - covered),
+ max(0.0, wall - covered),
+ tuple(self._loads),
+ missing_load,
+ human_active_s,
+ human_wait_s,
+ )
+
+
+def analyze(plan: Sequence[Trial], rows: Sequence[Observation]) -> dict[str, Any]:
+ """Keep all attempts; pair only unretried, independently verified slots."""
+ planned = set(plan)
+ if len(planned) != len(plan):
+ raise ValueError("duplicate planned slot")
+ if len({r.attempt for r in rows}) != len(rows) or any(
+ r.trial not in planned for r in rows
+ ):
+ raise ValueError("duplicate attempt or unplanned observation")
+ if any(r.wall_ms is not None and not _number(r.wall_ms) for r in rows):
+ raise ValueError("invalid authoritative wall duration")
+ if len({r.source_head for r in rows}) > 1:
+ raise ValueError("profiling pairs require a single frozen source")
+ by_slot = {slot: [r for r in rows if r.trial == slot] for slot in plan}
+ pairs: list[dict[str, Any]] = []
+ pair_keys = {(t.app, t.mode, t.block) for t in plan}
+ for app, mode, block in sorted(pair_keys):
+ arms = [
+ by_slot.get(Trial(app, mode, block, state), []) for state in (False, True)
+ ]
+ if all(
+ len(a) == 1 and a[0].verified and a[0].wall_ms is not None for a in arms
+ ):
+ off, on = arms[0][0].wall_ms, arms[1][0].wall_ms
+ if off is not None and on is not None:
+ pairs.append(
+ {"app": app, "mode": mode, "block": block, "delta_ms": on - off}
+ )
+
+ def distribution(values: list[float | None]) -> dict[str, Any]:
+ measured_values = [v for v in values if v is not None]
+ return {
+ "n": len(measured_values),
+ "median_ms": statistics.median(measured_values)
+ if measured_values
+ else None,
+ "min_ms": min(measured_values) if measured_values else None,
+ "max_ms": max(measured_values) if measured_values else None,
+ }
+
+ groups: list[dict[str, Any]] = []
+ for app, mode, profiled in sorted({(t.app, t.mode, t.profiled) for t in plan}):
+ observed = [
+ r
+ for r in rows
+ if (r.trial.app, r.trial.mode, r.trial.profiled) == (app, mode, profiled)
+ ]
+ good = [r for r in observed if r.verified]
+ valid = [r for r in good if not r.step_issues]
+ costs: list[dict[str, Any]] = []
+ for phase in sorted(set(STEP_GROUPS.values()) - {"unattributed_step"}):
+ values = [
+ r.step_exclusive_ms[phase]
+ for r in valid
+ if phase in r.step_exclusive_ms
+ ]
+ costs.append(
+ {
+ "phase": phase,
+ "observed_median_ms": statistics.median(values) if values else None,
+ "covered_attempts": len(values),
+ "missing_verified_attempts": len(good) - len(values),
+ }
+ )
+ costs.sort(
+ key=lambda row: (
+ row["observed_median_ms"] is None,
+ -(row["observed_median_ms"] or 0),
+ )
+ )
+ outer_costs = []
+ for phase in sorted(OUTER_PHASES):
+ outer_values = [
+ r.outer_inclusive_ms[phase]
+ for r in good
+ if phase in r.outer_inclusive_ms
+ ]
+ outer_costs.append(
+ {
+ "phase": phase,
+ "observed_median_ms": statistics.median(outer_values)
+ if outer_values
+ else None,
+ "covered_attempts": len(outer_values),
+ "missing_verified_attempts": len(good) - len(outer_values),
+ }
+ )
+ groups.append(
+ {
+ "app": app,
+ "mode": mode,
+ "profiled": profiled,
+ "planned_slots": sum(
+ (t.app, t.mode, t.profiled) == (app, mode, profiled) for t in plan
+ ),
+ "attempts": len(observed),
+ "verified_attempts": len(good),
+ "all_attempt_wall": distribution([r.wall_ms for r in observed]),
+ "verified_wall": distribution([r.wall_ms for r in good]),
+ "phase_costs": costs,
+ "outer_phase_costs": outer_costs,
+ "outer_basis": "inclusive_not_additive_with_step_spans",
+ }
+ )
+
+ def human(field: str) -> dict[str, Any]:
+ values = [getattr(r, field) for r in rows]
+ known = sum(v for v in values if v is not None) / 60.0
+ unknown = sum(v is None for v in values)
+ return {
+ "known_minutes": known,
+ "total_minutes": None if unknown else known,
+ "unknown_attempts": unknown,
+ "scope": "recorded_attempts_only",
+ }
+
+ return {
+ "schema": "openadapt.phase-analysis/v1",
+ "planned_slots": len(plan),
+ "started_attempts": len(rows),
+ "unrun_slots": [asdict(t) for t in plan if not by_slot[t]],
+ "retried_slots": sum(len(v) > 1 for v in by_slot.values()),
+ "verified_attempts": sum(r.verified for r in rows),
+ "statuses": {
+ status: sum(r.status == status for r in rows)
+ for status in ("completed", "setup_error", "timeout", "error")
+ },
+ "outcomes": {
+ outcome: sum(r.outcome == outcome for r in rows)
+ for outcome in sorted(OUTCOMES | {"unknown"})
+ },
+ "oracle_results": {
+ value: sum(r.oracle == value for r in rows)
+ for value in ("pass", "fail", "unknown", "unrun")
+ },
+ "timing_incomplete_attempts": sum(bool(r.step_issues) for r in rows),
+ "wall_missing_attempts": sum(r.wall_ms is None for r in rows),
+ "outer_overlap_attempts": sum(
+ r.outer_overlap_ms is not None and r.outer_overlap_ms > 0 for r in rows
+ ),
+ "load_missing_attempts": sum(r.load_missing for r in rows),
+ "paired_slots": len(pair_keys),
+ "eligible_pairs": len(pairs),
+ "excluded_pairs": len(pair_keys) - len(pairs),
+ "paired_profiler_delta_ms": pairs,
+ "human_active": human("human_active_s"),
+ "human_wait": human("human_wait_s"),
+ "groups": groups,
+ "interpretation": "observed_exclusive_cost_not_achieved_saving",
+ }
diff --git a/packages/flow/deploy/on-prem/Dockerfile b/packages/flow/deploy/on-prem/Dockerfile
index 1e176cf75a..6fff41072d 100644
--- a/packages/flow/deploy/on-prem/Dockerfile
+++ b/packages/flow/deploy/on-prem/Dockerfile
@@ -1,37 +1,47 @@
-# openadapt-flow on-prem runner image — built OFFLINE from a local wheelhouse.
+# openadapt-flow on-prem runner image, built with no network from a signed
+# release archive (bin/build-release.sh).
#
-# Air-gapped hosts do not reach PyPI. Build this on a connected staging host (or
-# with a pre-populated ./wheels) and copy the resulting image tar into the
-# clinic via removable media (`docker save` / `docker load`).
+# Do not run `docker build` on this file directly. bin/build-image.sh checks the
+# archive's signature against the pinned vendor key, extracts it, lays out the
+# build context below and builds with `--network none`, so pip can only use
+# the release's own wheels (the ones pinned by uv.lock):
#
-# Prepare the wheelhouse off-site:
-# pip download 'openadapt-flow[privacy]' -d deploy/on-prem/wheels
-# Then build (context = repo root, per docker-compose.yml):
-# docker build -f deploy/on-prem/Dockerfile -t openadapt-flow-onprem:local .
+# release/ the extracted release: VERSION, wheels/, requirements.lock.txt
+# on-prem/ the runtime scripts, compose file, docs and this Dockerfile
+# (build-image.sh names each file; nothing else is copied)
#
-# [STUB] the ./wheels wheelhouse is NOT committed (it is large and version-
-# specific). Without it the build fails LOUDLY rather than reaching the network.
+# The base image must already be in the local image store (`docker load` it
+# on an air-gapped host). Its default is pinned by digest; the release must be
+# built for the same Python minor version and CPU architecture (see
+# build-release.sh). Move the result in with `docker save` / `docker load`.
-FROM python:3.11-slim
+ARG BASE_IMAGE=python:3.12-slim@sha256:b18b261e1b4974b4f321451ad5251ded85abb22c2b8b262da527dda1d031ab30
+FROM ${BASE_IMAGE}
-# Never let pip reach the internet from inside the image build.
ENV PIP_NO_INDEX=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1 \
+ PYTHONDONTWRITEBYTECODE=1 \
OPENADAPT_FLOW_SCRUB=on
-WORKDIR /opt/openadapt-flow
+# The same install as install.sh (lib-release.sh rel_build_release): only the
+# release's wheels, the exact version the archive declares, then a dependency
+# consistency check.
+COPY release /tmp/release
+RUN set -eu; \
+ version="$(cat /tmp/release/VERSION)"; \
+ python -m venv /opt/openadapt-flow/venv; \
+ /opt/openadapt-flow/venv/bin/pip install --no-index --only-binary=:all: \
+ --find-links /tmp/release/wheels "openadapt-flow[privacy]==${version}"; \
+ /opt/openadapt-flow/venv/bin/pip check; \
+ printf '%s\n' "$version" > /opt/openadapt-flow/VERSION; \
+ rm -rf /tmp/release
-# Copy the offline wheelhouse (staged off-site) and the deploy scripts.
-COPY deploy/on-prem/wheels /tmp/wheels
-COPY deploy/on-prem /opt/openadapt-flow/deploy/on-prem
+COPY on-prem /opt/openadapt-flow/deploy/on-prem
-# Install the engine + privacy extra from local wheels only.
-RUN pip install --no-index --find-links /tmp/wheels 'openadapt-flow[privacy]' \
- && rm -rf /tmp/wheels
+ENV PATH="/opt/openadapt-flow/venv/bin:/opt/openadapt-flow/deploy/on-prem/bin:${PATH}"
# Storage root is a bind-mounted, host-encrypted volume at runtime.
WORKDIR /srv/openadapt
-ENV PATH="/opt/openadapt-flow/deploy/on-prem:${PATH}"
-# Default: process the local job queue. Overridden by docker-compose command.
-CMD ["deploy/on-prem/bin/run-queue.sh", "watch"]
+# Absolute path: the working directory is the storage volume, not the scripts.
+CMD ["/opt/openadapt-flow/deploy/on-prem/bin/run-queue.sh", "watch"]
diff --git a/packages/flow/deploy/on-prem/README.md b/packages/flow/deploy/on-prem/README.md
index a9a36396a9..d0b2bc1607 100644
--- a/packages/flow/deploy/on-prem/README.md
+++ b/packages/flow/deploy/on-prem/README.md
@@ -21,9 +21,11 @@ install story) and **[COMPLIANCE.md](COMPLIANCE.md)** (posture + data-flow).
| `bin/verify-airgap.sh` | Asserts no outbound path: config/env/deployment URL scan, optional active egress probe, optional audit-chain walk. | REAL (best-effort; firewall is primary) |
| `bin/run-egress-blocked.sh` | Runs a command in a loopback-only Linux network namespace, refusing to start unless no other interface or route exists. CI runs the golden free path through it (`tests/e2e/test_egress_blocked_e2e.py`): VERIFIED, zero model calls, and no outbound attempt in strict no-outbound mode. | REAL (Linux, root) |
| `bin/test-update.sh` | Hermetic offline proof of signed update, refusal paths, legacy migration, data preservation, rollback/recovery, locks, and audit-chain serialization. | REAL (43 baseline assertions; 45 when local GPG key generation is available) |
+| `bin/build-release.sh` | Builds a release archive on a connected host from `uv.lock`: the exact pinned wheels, hash-checked, plus the `openadapt-flow` wheel, a `.sha256`, and an openssl `.sig` when given the key. | REAL |
+| `bin/test-offline-install.sh` | Builds two signed releases, then as root with egress blocked installs the real engine from the first, refuses a tampered second, updates to the signed second, rolls back, and runs the air-gap attestation. CI runs it with `bin/test-update.sh` (job `flow-onprem-offline`). | REAL (Linux, root) |
| `UPDATE.md` | Offline update + rollback runbook: build a signed release, apply it atomically, revert instantly. | REAL (doc) |
| `systemd/openadapt-flow-runner.service` + `.path` | Event-driven runner unit with kernel-level egress denial (`IPAddressDeny=any`). | REAL (Linux/systemd) |
-| `docker-compose.yml` + `Dockerfile` | Container alternative on an `internal:true` (egress-blocked) network; optional LAN-only VLM appliance profile. | REAL topology; STUB image build (needs offline `./wheels`) |
+| `docker-compose.yml` + `Dockerfile` + `bin/build-image.sh` | Container alternative on an `internal:true` (egress-blocked) network; optional LAN-only VLM appliance profile. `bin/build-image.sh` verifies a signed release and builds the image from its wheels with `--network none`, copying only the deploy files it names and refusing a base image that is not already loaded. `bin/test-container.sh` (CI, job `flow-onprem-offline`) builds it, refuses a tampered archive and an absent base image, checks the image holds only the named deploy files and a missing wheel fails the build, and runs the runner on the compose network with no route out. | REAL (Linux, Docker) |
| `COMPLIANCE.md` | Regulatory posture, data-flow diagram, what's encrypted, boundaries. | REAL (doc; non-legal) |
## 60-second install (systemd host)
diff --git a/packages/flow/deploy/on-prem/UPDATE.md b/packages/flow/deploy/on-prem/UPDATE.md
index 0e594e8395..9346e2c8a8 100644
--- a/packages/flow/deploy/on-prem/UPDATE.md
+++ b/packages/flow/deploy/on-prem/UPDATE.md
@@ -42,7 +42,23 @@ all survive across any number of updates.
## Build a signed release bundle (off-site, on a connected host)
The bundle is a tarball containing a `VERSION` file and an offline pip
-wheelhouse, plus a required detached signature and an optional checksum:
+wheelhouse, plus a required detached signature and an optional checksum.
+
+`bin/build-release.sh` builds it from a source checkout: it downloads exactly
+the wheels `packages/flow/uv.lock` pins, checks each against the lock's hashes,
+adds the `openadapt-flow` wheel, and writes the archive, its `.sha256`, and an
+openssl `.sig` when given the signing key. Run it on a host with the same OS,
+CPU architecture and Python minor version as the clinic box:
+
+```bash
+deploy/on-prem/bin/build-release.sh --out dist/ --sign-key vendor_priv.pem
+```
+
+CI builds two releases this way and installs, updates and rolls back the real
+engine from them with the network cut off (`bin/test-offline-install.sh`).
+
+To assemble one by hand instead (this resolves the newest matching
+dependencies rather than the locked ones):
```bash
# 1. Assemble the payload
diff --git a/packages/flow/deploy/on-prem/bin/audit-log.sh b/packages/flow/deploy/on-prem/bin/audit-log.sh
index 091add53c3..a963a59a01 100755
--- a/packages/flow/deploy/on-prem/bin/audit-log.sh
+++ b/packages/flow/deploy/on-prem/bin/audit-log.sh
@@ -68,21 +68,50 @@ chmod 0700 "$(dirname "$AUDIT_LOG")" 2>/dev/null || true
# Serialize the read-last-hash + append transaction. O_APPEND prevents byte
# interleaving, but without this lock two writers can both chain to the same
# parent and produce a structurally broken audit log.
+#
+# Ownership is the exclusive creation of the pid file, done by the shell itself
+# with noclobber (open(2) with O_CREAT|O_EXCL). The exit status of mkdir(1) is
+# not trusted for exclusion: the uutils mkdir that Ubuntu 26.04 ships reports
+# success to both callers when two processes race to create one directory.
lock_dir="${AUDIT_LOG}.lock"
+lock_file="$lock_dir/pid"
+break_file="${AUDIT_LOG}.lock-breaker"
lock_acquired=0
+
+# Create $1 holding this process's pid; fails if $1 already exists.
+create_exclusive() {
+ ( set -C; printf '%s\n' "$$" > "$1" ) 2>/dev/null
+}
+
+# Remove a lock whose owner has exited. Between reading the owner and removing
+# the lock, that owner can release it and another writer can take it, so the
+# removal happens only under a second exclusive lock that serializes breakers,
+# and only if the lock still names the same dead owner. Without this, a breaker
+# could delete a live writer's lock and let two writers chain to the same parent.
+break_stale_lock() {
+ local stale="$1" current=""
+ create_exclusive "$break_file" || return 0
+ current="$(cat "$lock_file" 2>/dev/null || true)"
+ if [[ "$current" == "$stale" ]] && ! kill -0 "$stale" 2>/dev/null; then
+ rm -f "$lock_file" 2>/dev/null || true
+ fi
+ rm -f "$break_file" 2>/dev/null || true
+}
+
for ((attempt = 0; attempt < 200; attempt++)); do
- if mkdir "$lock_dir" 2>/dev/null; then
- printf '%s\n' "$$" > "$lock_dir/pid"
- lock_acquired=1
- break
+ if [[ ! -L "$lock_dir" ]]; then
+ mkdir -p "$lock_dir" 2>/dev/null || true
+ if [[ -d "$lock_dir" && ! -L "$lock_dir" ]] && create_exclusive "$lock_file"; then
+ lock_acquired=1
+ break
+ fi
fi
owner=""
- if [[ -d "$lock_dir" && ! -L "$lock_dir" && -r "$lock_dir/pid" ]]; then
- owner="$(cat "$lock_dir/pid" 2>/dev/null || true)"
+ if [[ -d "$lock_dir" && ! -L "$lock_dir" && -f "$lock_file" && ! -L "$lock_file" ]]; then
+ owner="$(cat "$lock_file" 2>/dev/null || true)"
fi
if [[ -n "$owner" && "$owner" =~ ^[0-9]+$ ]] && ! kill -0 "$owner" 2>/dev/null; then
- rm -f "$lock_dir/pid" 2>/dev/null || true
- rmdir "$lock_dir" 2>/dev/null || true
+ break_stale_lock "$owner"
continue
fi
sleep 0.05
@@ -92,7 +121,7 @@ if [[ "$lock_acquired" -ne 1 ]]; then
exit 1
fi
release_audit_lock() {
- rm -f "$lock_dir/pid" 2>/dev/null || true
+ rm -f "$lock_file" 2>/dev/null || true
rmdir "$lock_dir" 2>/dev/null || true
}
trap release_audit_lock EXIT
diff --git a/packages/flow/deploy/on-prem/bin/build-image.sh b/packages/flow/deploy/on-prem/bin/build-image.sh
new file mode 100755
index 0000000000..97bfa330b2
--- /dev/null
+++ b/packages/flow/deploy/on-prem/bin/build-image.sh
@@ -0,0 +1,114 @@
+#!/usr/bin/env bash
+#
+# build-image.sh — build the on-prem runner image from a signed release
+# archive, with no network.
+#
+# Checks the archive against the pinned vendor key (the same check as
+# `install.sh --update`), extracts it with the bounded extractor, and runs
+# `docker build --network none` on ../Dockerfile, so the image holds exactly
+# the release's wheels. A missing wheel fails the build instead of reaching
+# PyPI. The base image must already be loaded locally; the script refuses
+# before building when it is not. Only the deploy files it names are copied
+# into the image.
+#
+# Usage:
+# build-image.sh --archive release-V.tar.gz --signature release-V.tar.gz.sig
+# --pubkey vendor.pub [--sig-tool openssl|gpg|minisign|signify]
+# [--checksum FILE] [--tag NAME:TAG] [--base IMAGE]
+# [--save image.tar]
+#
+# The default tag is openadapt-flow-onprem:. Run the image
+# with docker-compose.yml (OPENADAPT_FLOW_IMAGE=). Exit 0 on success.
+
+set -euo pipefail
+
+HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+ONPREM="$(cd "$HERE/.." && pwd)"
+ARCHIVE=""
+SIGNATURE=""
+PUBKEY=""
+SIG_TOOL=""
+CHECKSUM=""
+TAG=""
+BASE=""
+SAVE=""
+
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --archive) ARCHIVE="${2:-}"; shift 2 ;;
+ --signature) SIGNATURE="${2:-}"; shift 2 ;;
+ --pubkey) PUBKEY="${2:-}"; shift 2 ;;
+ --sig-tool) SIG_TOOL="${2:-}"; shift 2 ;;
+ --checksum) CHECKSUM="${2:-}"; shift 2 ;;
+ --tag) TAG="${2:-}"; shift 2 ;;
+ --base) BASE="${2:-}"; shift 2 ;;
+ --save) SAVE="${2:-}"; shift 2 ;;
+ -h|--help) grep '^#' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
+ *) echo "build-image.sh: unknown arg $1" >&2; exit 2 ;;
+ esac
+done
+[[ -n "$ARCHIVE" ]] || { echo "build-image.sh: --archive is required" >&2; exit 2; }
+command -v docker >/dev/null || { echo "build-image.sh: docker is required" >&2; exit 2; }
+
+# shellcheck source=bin/lib-release.sh
+source "$HERE/lib-release.sh"
+
+rel_verify_integrity "$ARCHIVE" "$CHECKSUM" "$SIGNATURE" "$PUBKEY" "$SIG_TOOL" || {
+ echo "build-image.sh: refusing to build from an unverified archive" >&2; exit 1
+}
+
+CTX="$(mktemp -d "${TMPDIR:-/tmp}/oa-build-image.XXXXXX")"
+trap 'rm -rf "$CTX"' EXIT
+SRC="$(rel_extract "$ARCHIVE" "$CTX/extract")" || {
+ echo "build-image.sh: release archive could not be extracted" >&2; exit 1
+}
+VERSION="$(rel_declared_version "$SRC" "$SRC/wheels")" || exit 1
+mv "$SRC" "$CTX/release"
+rm -rf "$CTX/extract"
+
+# Only the files the image runs or documents, named one by one, so nothing a
+# site keeps beside the scripts (keys, configs, notes) reaches the image.
+IMAGE_FILES=(
+ Dockerfile
+ docker-compose.yml
+ onprem.example.yaml
+ README.md
+ COMPLIANCE.md
+ UPDATE.md
+ bin/run-queue.sh
+ bin/audit-log.sh
+ bin/verify-airgap.sh
+ bin/run-egress-blocked.sh
+)
+for f in "${IMAGE_FILES[@]}"; do
+ if [[ ! -f "$ONPREM/$f" || -L "$ONPREM/$f" ]]; then
+ echo "build-image.sh: $ONPREM/$f is missing or not a regular file" >&2; exit 1
+ fi
+ mkdir -p "$CTX/on-prem/$(dirname "$f")"
+ cp -p "$ONPREM/$f" "$CTX/on-prem/$f"
+done
+
+# The base image must already be in the local image store. Otherwise docker
+# would resolve FROM over the network even with --network none and
+# --pull=false, so refuse before building.
+if [[ -z "$BASE" ]]; then
+ BASE="$(awk -F= '/^ARG BASE_IMAGE=/ {print $2; exit}' "$CTX/on-prem/Dockerfile")"
+fi
+[[ -n "$BASE" ]] || { echo "build-image.sh: no base image named" >&2; exit 1; }
+if ! docker image inspect "$BASE" >/dev/null 2>&1; then
+ echo "build-image.sh: base image $BASE is not in the local image store;" \
+ "docker load it first (the build never pulls)" >&2
+ exit 1
+fi
+
+TAG="${TAG:-openadapt-flow-onprem:$VERSION}"
+args=(build --network none --pull=false -f "$CTX/on-prem/Dockerfile" -t "$TAG"
+ --build-arg "BASE_IMAGE=$BASE")
+echo "== building $TAG from release $VERSION with no network =="
+docker "${args[@]}" "$CTX"
+
+if [[ -n "$SAVE" ]]; then
+ docker save -o "$SAVE" "$TAG"
+ echo "saved $TAG to $SAVE"
+fi
+echo "built $TAG"
diff --git a/packages/flow/deploy/on-prem/bin/build-release.sh b/packages/flow/deploy/on-prem/bin/build-release.sh
new file mode 100755
index 0000000000..5b62d7878f
--- /dev/null
+++ b/packages/flow/deploy/on-prem/bin/build-release.sh
@@ -0,0 +1,90 @@
+#!/usr/bin/env bash
+#
+# build-release.sh — build an offline release archive from the lock file.
+#
+# Run on a CONNECTED build host with the same OS, CPU architecture and Python
+# minor version as the air-gapped target. Produces, in --out:
+#
+# release-.tar.gz VERSION, wheels/, requirements.lock.txt
+# release-.tar.gz.sha256 checksum (corruption check only)
+# release-.tar.gz.sig openssl signature, when --sign-key is given
+#
+# Every third-party wheel is the exact version pinned in packages/flow/uv.lock
+# and is checked against the lock's hashes while downloading (pip
+# --require-hashes), so the archive holds what CI tested and nothing newer.
+# The openadapt-flow wheel is built from this checkout unless --flow-wheel
+# names one. Stage the archive with `install.sh --update` (see UPDATE.md), or
+# extract it and pass release/wheels to `install.sh --wheelhouse` for a first
+# install.
+#
+# Usage:
+# build-release.sh --out DIR [--flow-wheel FILE] [--sign-key PRIVATE.pem]
+# [--python PYTHON]
+#
+# Needs uv and the target Python (default python3). Exit 0 on success.
+
+set -euo pipefail
+
+HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+FLOW_DIR="$(cd "$HERE/../../.." && pwd)"
+OUT=""
+FLOW_WHEEL=""
+SIGN_KEY=""
+PYTHON="python3"
+
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --out) OUT="${2:-}"; shift 2 ;;
+ --flow-wheel) FLOW_WHEEL="${2:-}"; shift 2 ;;
+ --sign-key) SIGN_KEY="${2:-}"; shift 2 ;;
+ --python) PYTHON="${2:-}"; shift 2 ;;
+ -h|--help) grep '^#' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
+ *) echo "build-release.sh: unknown arg $1" >&2; exit 2 ;;
+ esac
+done
+[[ -n "$OUT" ]] || { echo "build-release.sh: --out DIR is required" >&2; exit 2; }
+command -v uv >/dev/null || { echo "build-release.sh: uv is required" >&2; exit 2; }
+[[ -f "$FLOW_DIR/uv.lock" ]] || { echo "build-release.sh: no uv.lock in $FLOW_DIR" >&2; exit 2; }
+if [[ -n "$SIGN_KEY" && ! -f "$SIGN_KEY" ]]; then
+ echo "build-release.sh: signing key $SIGN_KEY not found" >&2; exit 2
+fi
+
+# shellcheck source=bin/lib-release.sh
+source "$HERE/lib-release.sh"
+
+mkdir -p "$OUT"
+OUT="$(cd "$OUT" && pwd)"
+WORK="$(mktemp -d "${TMPDIR:-/tmp}/oa-build-release.XXXXXX")"
+trap 'rm -rf "$WORK"' EXIT
+mkdir -p "$WORK/release/wheels"
+
+if [[ -z "$FLOW_WHEEL" ]]; then
+ echo "== building the openadapt-flow wheel from $FLOW_DIR =="
+ (cd "$FLOW_DIR" && uv build --wheel --out-dir "$WORK/flow" >/dev/null)
+ FLOW_WHEEL="$(find "$WORK/flow" -maxdepth 1 -name 'openadapt_flow-*.whl' -print)"
+fi
+[[ -f "$FLOW_WHEEL" ]] || { echo "build-release.sh: flow wheel not found" >&2; exit 2; }
+cp "$FLOW_WHEEL" "$WORK/release/wheels/"
+VERSION="$(rel_wheelhouse_version "$WORK/release/wheels")"
+rel_validate_version "$VERSION"
+
+echo "== exporting the locked dependencies of openadapt-flow[privacy] $VERSION =="
+(cd "$FLOW_DIR" && uv export --frozen --no-dev --extra privacy --no-emit-project \
+ --format requirements-txt --no-header -o "$WORK/release/requirements.lock.txt" >/dev/null)
+
+echo "== downloading the locked wheels for $("$PYTHON" -c 'import platform, sys; print(sys.platform, platform.machine(), "py%d.%d" % sys.version_info[:2])') =="
+"$PYTHON" -m pip download --quiet --disable-pip-version-check \
+ --only-binary=:all: --no-deps --require-hashes \
+ -r "$WORK/release/requirements.lock.txt" -d "$WORK/release/wheels"
+
+printf '%s\n' "$VERSION" > "$WORK/release/VERSION"
+ARCHIVE="$OUT/release-$VERSION.tar.gz"
+COPYFILE_DISABLE=1 tar czf "$ARCHIVE" -C "$WORK" release
+sha256_of "$ARCHIVE" > "$ARCHIVE.sha256"
+if [[ -n "$SIGN_KEY" ]]; then
+ openssl dgst -sha256 -sign "$SIGN_KEY" -out "$ARCHIVE.sig" "$ARCHIVE"
+fi
+
+count="$(find "$WORK/release/wheels" -name '*.whl' | wc -l | tr -d ' ')"
+echo "built $ARCHIVE ($count wheels)"
+[[ -n "$SIGN_KEY" ]] && echo "signed $ARCHIVE.sig" || echo "unsigned: sign it before staging (UPDATE.md)"
diff --git a/packages/flow/deploy/on-prem/bin/lib-release.sh b/packages/flow/deploy/on-prem/bin/lib-release.sh
index 7676dc4900..295dd5c38b 100755
--- a/packages/flow/deploy/on-prem/bin/lib-release.sh
+++ b/packages/flow/deploy/on-prem/bin/lib-release.sh
@@ -344,32 +344,45 @@ rel_validate_release_dir() {
&& -x "$release_dir/venv/bin/openadapt-flow" ]]
}
+# Create $1 holding this process's pid; fails if $1 already exists. The shell
+# opens it with noclobber (O_CREAT|O_EXCL), so exclusion does not depend on
+# mkdir(1): the uutils mkdir that Ubuntu 26.04 ships reports success to both
+# callers when two processes race to create one directory.
+_rel_create_exclusive() {
+ ( set -C; printf '%s\n' "$$" > "$1" ) 2>/dev/null
+}
+
_rel_lock_acquire() {
- local root="$1" owner=""
+ local root="$1" owner="" current=""
local lock="$root/releases/.update.lock"
+ local breaker="$root/releases/.update.lock-breaker"
mkdir -p "$root/releases" || return 1
- if mkdir "$lock" 2>/dev/null; then
- printf '%s\n' "$$" > "$lock/pid" || {
- rmdir "$lock" 2>/dev/null || true
- return 1
- }
+ if [[ -L "$lock" ]]; then
+ _rel_err "release lock is a symlink; refusing: $lock"
+ return 1
+ fi
+ mkdir -p "$lock" 2>/dev/null || true
+ if [[ -d "$lock" && ! -L "$lock" ]] && _rel_create_exclusive "$lock/pid"; then
REL_LOCK_DIR="$lock"
return 0
fi
- if [[ -d "$lock" && ! -L "$lock" && -r "$lock/pid" ]]; then
+ if [[ -d "$lock" && ! -L "$lock" && -f "$lock/pid" && ! -L "$lock/pid" ]]; then
owner="$(cat "$lock/pid" 2>/dev/null || true)"
fi
if [[ "$owner" =~ ^[0-9]+$ ]] && kill -0 "$owner" 2>/dev/null; then
_rel_err "another update or rollback is active (pid $owner)"
return 1
fi
- if [[ -d "$lock" && ! -L "$lock" ]]; then
- rm -f "$lock/pid" 2>/dev/null || true
- if rmdir "$lock" 2>/dev/null && mkdir "$lock" 2>/dev/null; then
- printf '%s\n' "$$" > "$lock/pid" || {
- rmdir "$lock" 2>/dev/null || true
- return 1
- }
+ # The owner has exited (or left an unreadable pid). Remove its lock only
+ # under the breaker lock and only if it still names that owner, so a
+ # concurrent breaker cannot remove a lock another process has just taken.
+ if [[ -n "$owner" ]] && _rel_create_exclusive "$breaker"; then
+ current="$(cat "$lock/pid" 2>/dev/null || true)"
+ if [[ "$current" == "$owner" ]]; then
+ rm -f "$lock/pid" 2>/dev/null || true
+ fi
+ rm -f "$breaker" 2>/dev/null || true
+ if [[ -d "$lock" && ! -L "$lock" ]] && _rel_create_exclusive "$lock/pid"; then
REL_LOCK_DIR="$lock"
return 0
fi
diff --git a/packages/flow/deploy/on-prem/bin/test-container.sh b/packages/flow/deploy/on-prem/bin/test-container.sh
new file mode 100755
index 0000000000..98771592a2
--- /dev/null
+++ b/packages/flow/deploy/on-prem/bin/test-container.sh
@@ -0,0 +1,248 @@
+#!/usr/bin/env bash
+#
+# test-container.sh — build the on-prem runner image from a signed release
+# with no network, and run it on the compose file's internal network.
+#
+# test-container.sh DIR
+#
+# DIR is what `bin/test-offline-install.sh stage DIR` writes (a signed V1
+# release and the vendor public key). Needs Linux, Docker with compose, and a
+# connected host only to pull the pinned base image. Checks:
+# - build-image.sh refuses a tampered archive and builds no image;
+# - build-image.sh refuses a base image that is not loaded locally, before
+# building;
+# - the image build has no network: the same release with one dependency
+# wheel removed fails to build;
+# - the signed release builds from a deploy directory with site files left
+# beside the scripts, and the image holds only the named deploy files;
+# inside the image (no network) the engine
+# and privacy extra import from the image's venv at the release version;
+# - the default command resolves, and run-queue.sh files a job and writes
+# the audit log on a bind-mounted storage root; it refuses to start with
+# scrubbing off;
+# - docker-compose.yml needs the image named, never pulls, publishes no
+# ports, and its network is internal: a TCP connect to the internet that
+# works from the same image on Docker's default network fails from the
+# compose runner.
+#
+# CI runs it in packages.yml (flow on-prem offline install). Exit 0 when every
+# check passes.
+
+set -uo pipefail
+
+HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+ONPREM="$(cd "$HERE/.." && pwd)"
+DIR="${1:-}"
+if [[ -z "$DIR" || ! -f "$DIR/V1" ]]; then
+ grep '^#' "$0" | sed 's/^# \{0,1\}//'
+ exit 2
+fi
+DIR="$(cd "$DIR" && pwd)"
+command -v docker >/dev/null || { echo "test-container.sh: docker is required" >&2; exit 2; }
+
+PASS=0
+FAIL=0
+ok() { printf ' PASS %s\n' "$1"; PASS=$((PASS + 1)); }
+bad() { printf ' FAIL %s\n' "$1"; FAIL=$((FAIL + 1)); }
+check_eq() { if [[ "$2" == "$3" ]]; then ok "$1"; else bad "$1 (expected '$2', got '$3')"; fi; }
+
+v1="$(cat "$DIR/V1")"
+archive="$DIR/v1/release-$v1.tar.gz"
+sig="$archive.sig"
+pub="$DIR/keys/vendor.pub"
+base="$(awk -F= '/^ARG BASE_IMAGE=/ {print $2; exit}' "$ONPREM/Dockerfile")"
+tag="openadapt-flow-onprem-test:$v1"
+project="oa-onprem-test-$$"
+compose=(docker compose -p "$project" -f "$ONPREM/docker-compose.yml")
+work="$(mktemp -d "${TMPDIR:-/tmp}/oa-test-container.XXXXXX")"
+store="$work/storage"
+mkdir -p "$store"
+
+cleanup() {
+ OPENADAPT_FLOW_IMAGE="$tag" OPENADAPT_ONPREM_STORAGE="$store" \
+ "${compose[@]}" down --remove-orphans >/dev/null 2>&1 || true
+ # The container writes the storage root as root; empty it from inside.
+ docker run --rm --network none -v "$store:/s" "$tag" \
+ sh -c 'rm -rf /s/* /s/.[!.]*' >/dev/null 2>&1 || true
+ docker image rm -f "$tag" "$tag-tampered" "$tag-missing" "$tag-nobase" >/dev/null 2>&1 || true
+ rm -rf "$work"
+}
+trap cleanup EXIT
+
+echo "== pulling the pinned base image (connected; the build itself has no network) =="
+docker pull -q "$base" >/dev/null || { echo "test-container.sh: cannot pull $base" >&2; exit 1; }
+
+echo "== build-image.sh refuses a tampered archive =="
+cp "$archive" "$work/tampered.tar.gz"
+printf 'x' >> "$work/tampered.tar.gz"
+if bash "$HERE/build-image.sh" --archive "$work/tampered.tar.gz" --signature "$sig" \
+ --pubkey "$pub" --sig-tool openssl --tag "$tag-tampered" >"$work/tampered.log" 2>&1; then
+ bad "tampered archive refused"
+else
+ ok "tampered archive refused"
+fi
+if docker image inspect "$tag-tampered" >/dev/null 2>&1; then
+ bad "no image built from the tampered archive"
+else
+ ok "no image built from the tampered archive"
+fi
+
+echo "== build-image.sh refuses a base image that is not loaded locally =="
+absent="openadapt-flow-onprem-test-absent-base:$$"
+if bash "$HERE/build-image.sh" --archive "$archive" --signature "$sig" --pubkey "$pub" \
+ --sig-tool openssl --base "$absent" --tag "$tag-nobase" >"$work/nobase.log" 2>&1; then
+ bad "absent base image refused"
+elif grep -q "is not in the local image store" "$work/nobase.log" \
+ && ! grep -q "== building" "$work/nobase.log"; then
+ ok "absent base image refused before building"
+else
+ bad "absent base image refused before building (failed another way)"
+ tail -n 20 "$work/nobase.log"
+fi
+if docker image inspect "$tag-nobase" >/dev/null 2>&1; then
+ bad "no image built on an absent base"
+else
+ ok "no image built on an absent base"
+fi
+
+echo "== the image build has no network: a release missing one wheel fails =="
+mkdir -p "$work/missing/on-prem"
+tar xzf "$archive" -C "$work/missing"
+victim="$(find "$work/missing/release/wheels" -name 'presidio_analyzer-*.whl' -print | head -n 1)"
+if [[ -z "$victim" ]]; then
+ bad "release holds the presidio_analyzer wheel to remove"
+else
+ rm -f "$victim"
+ tar -C "$ONPREM" -cf - . | tar -C "$work/missing/on-prem" -xf -
+ # --progress=plain keeps pip's own error in the log; -q would drop it.
+ if docker build --network none --pull=false --progress=plain \
+ -f "$work/missing/on-prem/Dockerfile" \
+ -t "$tag-missing" "$work/missing" >"$work/missing.log" 2>&1; then
+ bad "build without $(basename "$victim") fails (it reached a package index?)"
+ else
+ if grep -q "presidio" "$work/missing.log"; then
+ ok "build without $(basename "$victim") fails on that package"
+ else
+ bad "build without $(basename "$victim") failed for another reason"
+ tail -n 20 "$work/missing.log"
+ fi
+ fi
+fi
+
+echo "== build the signed release from a deploy directory with site files beside it =="
+site="$work/site/on-prem"
+mkdir -p "$site"
+tar -C "$ONPREM" -cf - . | tar -C "$site" -xf -
+printf 'site secret\n' > "$site/bin/site-notes.txt"
+printf 'not a real key\n' > "$site/vendor.key"
+printf 'site: local\n' > "$site/onprem.yaml"
+mkdir -p "$site/storage"
+printf 'job\n' > "$site/storage/left.job"
+if bash "$site/bin/build-image.sh" --archive "$archive" --signature "$sig" --pubkey "$pub" \
+ --sig-tool openssl --tag "$tag" >"$work/build.log" 2>&1; then
+ ok "signed release $v1 builds with no network"
+else
+ bad "signed release $v1 builds with no network"
+ tail -n 40 "$work/build.log"
+ echo "== result: $PASS passed, $FAIL failed =="
+ exit 1
+fi
+
+run() { docker run --rm --network none "$@"; }
+
+check_eq "image: engine CLI reports $v1" "openadapt-flow $v1" \
+ "$(run "$tag" openadapt-flow --version 2>&1)"
+check_eq "image: engine and privacy extra import from the image venv, version $v1" "$v1" \
+ "$(run -w / "$tag" python -I -c '
+import importlib.metadata, os, sys
+import openadapt_flow, openadapt_privacy, presidio_analyzer
+here = os.path.realpath(openadapt_flow.__file__)
+if not here.startswith("/opt/openadapt-flow/venv/"):
+ sys.exit("openadapt_flow imported from outside the image venv: " + here)
+if openadapt_flow.__version__ != importlib.metadata.version("openadapt-flow"):
+ sys.exit("module and distribution versions differ")
+print(openadapt_flow.__version__)
+' 2>&1)"
+check_eq "image: holds only the named deploy files" \
+ "COMPLIANCE.md Dockerfile README.md UPDATE.md bin/audit-log.sh bin/run-egress-blocked.sh bin/run-queue.sh bin/verify-airgap.sh docker-compose.yml onprem.example.yaml" \
+ "$(run -w /opt/openadapt-flow/deploy/on-prem "$tag" find . ! -type d 2>&1 \
+ | sed 's|^\./||' | LC_ALL=C sort | tr '\n' ' ' | sed 's/ $//')"
+check_eq "image: VERSION file" "$v1" "$(run "$tag" cat /opt/openadapt-flow/VERSION 2>&1)"
+check_eq "image: default command is the absolute run-queue path" \
+ '["/opt/openadapt-flow/deploy/on-prem/bin/run-queue.sh","watch"]' \
+ "$(docker image inspect -f '{{json .Config.Cmd}}' "$tag")"
+
+echo "== run-queue.sh in the image, on a bind-mounted storage root =="
+mkdir -p "$store/jobs/inbox"
+printf 'params=none\n' > "$store/jobs/inbox/no-bundle.job"
+qenv=(-e OPENADAPT_ONPREM_ROOT=/srv/openadapt
+ -e OPENADAPT_ONPREM_AUDIT_LOG=/srv/openadapt/audit/audit.log
+ -v "$store:/srv/openadapt")
+if run "${qenv[@]}" "$tag" /opt/openadapt-flow/deploy/on-prem/bin/run-queue.sh once \
+ >"$work/queue.log" 2>&1; then
+ ok "run-queue.sh once exits 0"
+else
+ bad "run-queue.sh once exits 0"; cat "$work/queue.log"
+fi
+if [[ -f "$store/jobs/failed/no-bundle.job" ]]; then
+ ok "malformed job filed under failed/"
+else
+ bad "malformed job filed under failed/"
+fi
+# The runner is root in the image and audit-log.sh makes the audit directory
+# 0700, so read the log the way the deployment does: from inside the image.
+if run -v "$store:/srv/openadapt" "$tag" cat /srv/openadapt/audit/audit.log 2>/dev/null \
+ | grep -q '"job":"no-bundle"'; then
+ ok "audit log records the job"
+else
+ bad "audit log records the job"
+fi
+if run "${qenv[@]}" -e OPENADAPT_FLOW_SCRUB=off "$tag" \
+ /opt/openadapt-flow/deploy/on-prem/bin/run-queue.sh once >/dev/null 2>&1; then
+ bad "run-queue.sh refuses to start with scrubbing off"
+else
+ ok "run-queue.sh refuses to start with scrubbing off"
+fi
+
+echo "== docker-compose.yml =="
+if OPENADAPT_ONPREM_STORAGE="$store" "${compose[@]}" config >/dev/null 2>&1; then
+ bad "compose refuses to run without OPENADAPT_FLOW_IMAGE"
+else
+ ok "compose refuses to run without OPENADAPT_FLOW_IMAGE"
+fi
+export OPENADAPT_FLOW_IMAGE="$tag" OPENADAPT_ONPREM_STORAGE="$store"
+cfg="$("${compose[@]}" config --format json 2>"$work/config.err")"
+check_eq "compose: runner never pulls, publishes no ports, network internal" \
+ "never|none|True" \
+ "$(printf '%s' "$cfg" | python3 -c '
+import json, sys
+c = json.load(sys.stdin)
+r = c["services"]["runner"]
+nets = [c["networks"][n].get("internal", False) for n in r["networks"]]
+print("%s|%s|%s" % (r.get("pull_policy"), r.get("ports") or "none", all(nets) and bool(nets)))
+' 2>&1)"
+
+probe='import socket; socket.create_connection(("1.1.1.1", 443), timeout=5).close()'
+if docker run --rm "$tag" python -c "$probe" >/dev/null 2>&1; then
+ ok "control: the probe connects from this image on Docker's default network"
+ if "${compose[@]}" run --rm -T --no-deps runner python -c "$probe" >"$work/probe.log" 2>&1; then
+ bad "compose runner cannot connect to the internet"
+ else
+ ok "compose runner cannot connect to the internet"
+ fi
+else
+ bad "control: the probe connects from this image on Docker's default network (cannot tell a blocked network from no network)"
+fi
+
+"${compose[@]}" up -d runner >"$work/up.log" 2>&1 || { bad "compose up runner"; cat "$work/up.log"; }
+sleep 5
+state="$(docker inspect -f '{{.State.Status}}' "$("${compose[@]}" ps -q runner)" 2>/dev/null)"
+check_eq "compose: runner keeps watching the queue" "running" "$state"
+if "${compose[@]}" logs runner 2>&1 | grep -q "watching /srv/openadapt/jobs/inbox"; then
+ ok "compose: runner logs that it is watching the inbox"
+else
+ bad "compose: runner logs that it is watching the inbox"; "${compose[@]}" logs runner
+fi
+
+echo "== result: $PASS passed, $FAIL failed =="
+[[ "$FAIL" -eq 0 ]]
diff --git a/packages/flow/deploy/on-prem/bin/test-offline-install.sh b/packages/flow/deploy/on-prem/bin/test-offline-install.sh
new file mode 100755
index 0000000000..c3e6d391cd
--- /dev/null
+++ b/packages/flow/deploy/on-prem/bin/test-offline-install.sh
@@ -0,0 +1,210 @@
+#!/usr/bin/env bash
+#
+# test-offline-install.sh — install, upgrade and roll back the real engine on a
+# machine with no network.
+#
+# Unlike test-update.sh, which fakes the venv build, this installs the real
+# openadapt-flow[privacy] wheels pinned by uv.lock, so it proves the offline
+# release archive is complete: pip gets no index and the machine has no route
+# out, so any missing wheel fails the install.
+#
+# stage DIR on a connected host: build two signed releases (this checkout
+# as V1, the same source as V1.post1 as V2) and a throwaway
+# signing key, with bin/build-release.sh.
+# verify DIR as root, inside bin/run-egress-blocked.sh: first install from
+# V1's wheelhouse, refuse a tampered V2, update to the signed V2,
+# roll back to V1, then run the air-gap attestation with its
+# egress probe and audit-chain walk. Data must survive each step.
+#
+# CI runs both (packages.yml, flow on-prem offline install). By hand on Linux:
+# bin/test-offline-install.sh stage /tmp/oa-offline
+# sudo env -u SUDO_UID -u SUDO_GID "PATH=$PATH" \
+# bin/run-egress-blocked.sh bin/test-offline-install.sh verify /tmp/oa-offline
+#
+# Exit 0 when every check passes.
+
+set -uo pipefail
+
+HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+ONPREM="$(cd "$HERE/.." && pwd)"
+FLOW_DIR="$(cd "$ONPREM/../.." && pwd)"
+CMD="${1:-}"
+DIR="${2:-}"
+if [[ -z "$CMD" || -z "$DIR" || ( "$CMD" != stage && "$CMD" != verify ) ]]; then
+ grep '^#' "$0" | sed 's/^# \{0,1\}//'
+ exit 2
+fi
+
+stage() {
+ set -e
+ mkdir -p "$DIR/keys" "$DIR/v1" "$DIR/v2"
+ DIR="$(cd "$DIR" && pwd)"
+ openssl genrsa -out "$DIR/keys/vendor.pem" 2048 2>/dev/null
+ openssl rsa -in "$DIR/keys/vendor.pem" -pubout -out "$DIR/keys/vendor.pub" 2>/dev/null
+ bash "$HERE/build-release.sh" --out "$DIR/v1" --sign-key "$DIR/keys/vendor.pem" \
+ ${OPENADAPT_ONPREM_PY:+--python "$OPENADAPT_ONPREM_PY"}
+
+ # V2: the same source under a new version, built the same way.
+ local v1 src
+ v1="$(find "$DIR/v1" -maxdepth 1 -name 'release-*.tar.gz' -print)"
+ v1="$(basename "$v1" .tar.gz)"
+ v1="${v1#release-}"
+ src="$(mktemp -d "${TMPDIR:-/tmp}/oa-offline-src.XXXXXX")"
+ for item in pyproject.toml README.md LICENSE THIRD_PARTY_NOTICES.md \
+ public-artifacts.json openadapt_flow schemas; do
+ cp -R "$FLOW_DIR/$item" "$src/"
+ done
+ find "$src" -name __pycache__ -type d -prune -exec rm -rf {} +
+ sed -i.bak "s/^version = \"$v1\"$/version = \"$v1.post1\"/" "$src/pyproject.toml"
+ sed -i.bak "s/^__version__ = \"$v1\"$/__version__ = \"$v1.post1\"/" "$src/openadapt_flow/__init__.py"
+ rm -f "$src/pyproject.toml.bak" "$src/openadapt_flow/__init__.py.bak"
+ grep -q "^version = \"$v1.post1\"$" "$src/pyproject.toml"
+ (cd "$src" && uv build --wheel --out-dir "$src/dist" >/dev/null)
+ bash "$HERE/build-release.sh" --out "$DIR/v2" --sign-key "$DIR/keys/vendor.pem" \
+ --flow-wheel "$src/dist/openadapt_flow-$v1.post1-py3-none-any.whl" \
+ ${OPENADAPT_ONPREM_PY:+--python "$OPENADAPT_ONPREM_PY"}
+ rm -rf "$src"
+ # The private key never goes near the install.
+ rm -f "$DIR/keys/vendor.pem"
+ printf '%s\n' "$v1" > "$DIR/V1"
+ printf '%s\n' "$v1.post1" > "$DIR/V2"
+ echo "staged $v1 and $v1.post1 in $DIR"
+}
+
+PASS=0
+FAIL=0
+ok() { printf ' PASS %s\n' "$1"; PASS=$((PASS + 1)); }
+bad() { printf ' FAIL %s\n' "$1"; FAIL=$((FAIL + 1)); }
+check() { local name="$1"; shift; if "$@"; then ok "$name"; else bad "$name"; fi; }
+check_eq() { if [[ "$2" == "$3" ]]; then ok "$1"; else bad "$1 (expected '$2', got '$3')"; fi; }
+
+verify() {
+ DIR="$(cd "$DIR" && pwd)"
+ if [[ "$(id -u)" -ne 0 ]]; then
+ echo "test-offline-install.sh verify: run as root (install.sh needs it)" >&2; exit 2
+ fi
+ if [[ "${OPENADAPT_EGRESS_BLOCKED:-}" != netns ]]; then
+ echo "test-offline-install.sh verify: run inside bin/run-egress-blocked.sh" >&2; exit 2
+ fi
+ local v1 v2 py work root config rc
+ v1="$(cat "$DIR/V1")"; v2="$(cat "$DIR/V2")"
+ py="${OPENADAPT_ONPREM_PY:-python3}"
+ work="$(mktemp -d "${TMPDIR:-/tmp}/oa-offline-verify.XXXXXX")"
+ root="$work/srv"
+ config="$work/onprem.yaml"
+ cat > "$config" <&1)
+ }
+
+ echo "== offline install, upgrade and rollback ($v1 -> $v2 -> $v1), no network =="
+ check "the machine has no route out" "$py" -c '
+import socket, sys
+try:
+ socket.create_connection(("1.1.1.1", 443), timeout=3)
+except OSError:
+ sys.exit(0)
+sys.exit(1)'
+
+ echo "-- first install from the $v1 wheelhouse --"
+ mkdir -p "$work/media"
+ tar xzf "$DIR/v1/release-$v1.tar.gz" -C "$work/media"
+ bash "$ONPREM/install.sh" --config "$config" --wheelhouse "$work/media/release/wheels" \
+ > "$work/install.log" 2>&1
+ rc=$?
+ check_eq "install: exit 0" 0 "$rc"
+ [[ "$rc" -eq 0 ]] || tail -n 40 "$work/install.log"
+ check_eq "install: current -> $v1" "$root/releases/$v1" "$(readlink "$root/current")"
+ check_eq "install: engine and privacy extra import, version $v1" "$v1" "$(engine_version)"
+ check_eq "install: engine CLI reports $v1" "openadapt-flow $v1" \
+ "$(cd / && "$root/current/venv/bin/openadapt-flow" --version 2>&1)"
+
+ mkdir -p "$root/bundles" "$root/jobs/inbox"
+ printf 'bundle\n' > "$root/bundles/keepme.txt"
+ printf 'job\n' > "$root/jobs/inbox/queued-0001.job"
+ local bundle_sha job_sha
+ bundle_sha="$(shasum -a 256 "$root/bundles/keepme.txt" | awk '{print $1}')"
+ job_sha="$(shasum -a 256 "$root/jobs/inbox/queued-0001.job" | awk '{print $1}')"
+ cp "$DIR/keys/vendor.pub" "$root/keys/vendor.pub"
+
+ local arc="$DIR/v2/release-$v2.tar.gz"
+ echo "-- a tampered $v2 archive is refused --"
+ cp "$arc" "$work/media/tampered.tar.gz"
+ printf 'x' >> "$work/media/tampered.tar.gz"
+ bash "$ONPREM/install.sh" --update --config "$config" --release "$work/media/tampered.tar.gz" \
+ --signature "$arc.sig" > "$work/tampered.log" 2>&1
+ rc=$?
+ check "tampered: update refused" test "$rc" -ne 0
+ check_eq "tampered: current still $v1" "$root/releases/$v1" "$(readlink "$root/current")"
+ check "tampered: nothing built for $v2" test ! -e "$root/releases/$v2"
+
+ echo "-- signed update to $v2 --"
+ bash "$ONPREM/install.sh" --update --config "$config" --release "$arc" \
+ --checksum "$arc.sha256" --signature "$arc.sig" > "$work/update.log" 2>&1
+ rc=$?
+ check_eq "update: exit 0" 0 "$rc"
+ [[ "$rc" -eq 0 ]] || tail -n 40 "$work/update.log"
+ check_eq "update: current -> $v2" "$root/releases/$v2" "$(readlink "$root/current")"
+ check_eq "update: previous -> $v1" "$root/releases/$v1" "$(readlink "$root/previous")"
+ check_eq "update: engine version $v2" "$v2" "$(engine_version)"
+ check_eq "update: bundle unchanged" "$bundle_sha" "$(shasum -a 256 "$root/bundles/keepme.txt" | awk '{print $1}')"
+ check_eq "update: queued job unchanged" "$job_sha" "$(shasum -a 256 "$root/jobs/inbox/queued-0001.job" | awk '{print $1}')"
+
+ echo "-- rollback to $v1 --"
+ bash "$ONPREM/install.sh" --rollback --config "$config" > "$work/rollback.log" 2>&1
+ rc=$?
+ check_eq "rollback: exit 0" 0 "$rc"
+ [[ "$rc" -eq 0 ]] || tail -n 40 "$work/rollback.log"
+ check_eq "rollback: current -> $v1" "$root/releases/$v1" "$(readlink "$root/current")"
+ check_eq "rollback: engine version $v1" "$v1" "$(engine_version)"
+ check_eq "rollback: bundle unchanged" "$bundle_sha" "$(shasum -a 256 "$root/bundles/keepme.txt" | awk '{print $1}')"
+
+ echo "-- air-gap attestation --"
+ OPENADAPT_FLOW_SCRUB=on bash "$HERE/verify-airgap.sh" --config "$config" --probe --audit \
+ > "$work/airgap.log" 2>&1
+ rc=$?
+ check_eq "attestation: exit 0" 0 "$rc"
+ check "attestation: egress probe could not reach the internet" \
+ grep -q "outbound egress is blocked" "$work/airgap.log"
+ check "attestation: audit chain intact" grep -q "audit chain intact" "$work/airgap.log"
+ [[ "$rc" -eq 0 ]] || cat "$work/airgap.log"
+
+ rm -rf "$work"
+ echo
+ echo "== result: $PASS passed, $FAIL failed =="
+ [[ "$FAIL" -eq 0 ]]
+}
+
+"$CMD"
diff --git a/packages/flow/deploy/on-prem/bin/test-update.sh b/packages/flow/deploy/on-prem/bin/test-update.sh
index c2778b4602..cb16fdecba 100755
--- a/packages/flow/deploy/on-prem/bin/test-update.sh
+++ b/packages/flow/deploy/on-prem/bin/test-update.sh
@@ -244,6 +244,28 @@ assert "concurrent update lock is refused" test "$?" -ne 0
rm -f "$ROOT/releases/.update.lock/pid"
rmdir "$ROOT/releases/.update.lock"
+# Ubuntu 26.04 ships uutils mkdir, which reports success to both callers when
+# two processes race to create one directory. Model that with a mkdir shim that
+# never reports EEXIST: the locks must still exclude, so they cannot rest on
+# the exit status of mkdir(1).
+EEXIST_SHIM="$WORK/eexist-shim"
+mkdir -p "$EEXIST_SHIM"
+REAL_MKDIR="$(command -v mkdir)"
+cat > "$EEXIST_SHIM/mkdir" </dev/null || true
+exit 0
+SHIM_EOF
+chmod +x "$EEXIST_SHIM/mkdir"
+mkdir "$ROOT/releases/.update.lock"
+printf '%s\n' "$$" > "$ROOT/releases/.update.lock/pid"
+PATH="$EEXIST_SHIM:$PATH" apply_release "$ROOT" "$ARC6" >/dev/null 2>&1
+assert "update lock holds when mkdir(1) never reports EEXIST" test "$?" -ne 0
+assert_eq "a refused update leaves the live owner's lock" "$$" \
+ "$(cat "$ROOT/releases/.update.lock/pid" 2>/dev/null || echo none)"
+rm -f "$ROOT/releases/.update.lock/pid"
+rmdir "$ROOT/releases/.update.lock"
+
chmod -x "$ROOT/releases/2.0.0/venv/bin/openadapt-flow"
rel_do_rollback "$ROOT" "" >/dev/null 2>&1
assert "incomplete rollback target is refused" test "$?" -ne 0
@@ -290,7 +312,64 @@ if grep -q 'audit chain intact' <<< "$audit_output"; then
pass "concurrent audit writers preserve one hash chain"
else
fail "concurrent audit writers preserve one hash chain"
+ printf '%s\n' "$audit_output" | sed 's/^/ /'
+fi
+
+SHIM_LOG="$WORK/eexist-audit/audit.log"
+for index in 1 2 3 4 5 6 7 8 9 10 11 12; do
+ OPENADAPT_ONPREM_AUDIT_LOG="$SHIM_LOG" PATH="$EEXIST_SHIM:$PATH" \
+ bash "$OPENADAPT_ONPREM_AUDIT_BIN" started "shim-$index" --note test >/dev/null &
+done
+wait
+audit_output="$(OPENADAPT_ONPREM_AUDIT_LOG="$SHIM_LOG" \
+ bash "$HERE/verify-airgap.sh" --config "$WORK/missing.yaml" --audit 2>&1 || true)"
+if grep -q 'audit chain intact across 12 record' <<< "$audit_output"; then
+ pass "audit writers stay serialized when mkdir(1) never reports EEXIST"
+else
+ fail "audit writers stay serialized when mkdir(1) never reports EEXIST"
+ printf '%s\n' "$audit_output" | sed 's/^/ /'
+fi
+
+# A writer that finds a lock owned by an exited process may break it. Force the
+# race where, between that writer reading the dead owner and breaking the
+# lock, the owner releases and a live writer takes the lock: a shimmed cat
+# returns the dead owner once, then hands the lock to a live process. The
+# breaker must leave the live writer's lock alone.
+RACE="$WORK/stale-race"
+mkdir -p "$RACE/shim"
+RACE_LOG="$RACE/audit.log"
+bash -c 'exit 0' &
+dead_pid=$!
+wait "$dead_pid"
+sleep 30 &
+live_pid=$!
+mkdir "$RACE_LOG.lock"
+printf '%s\n' "$dead_pid" > "$RACE_LOG.lock/pid"
+REAL_CAT="$(command -v cat)"
+cat > "$RACE/shim/cat" < "$RACE/handed-over"
+ printf '%s\n' "$live_pid" > "$RACE_LOG.lock/pid"
+ exit 0
fi
+exec "$REAL_CAT" "\$@"
+SHIM_EOF
+chmod +x "$RACE/shim/cat"
+OPENADAPT_ONPREM_AUDIT_LOG="$RACE_LOG" PATH="$RACE/shim:$PATH" \
+ bash "$OPENADAPT_ONPREM_AUDIT_BIN" started stale-race --note test >/dev/null 2>&1 &
+race_writer=$!
+sleep 2
+race_owner="$(cat "$RACE_LOG.lock/pid" 2>/dev/null || echo none)"
+assert_eq "stale-lock breaker leaves a live writer's lock alone" "$live_pid" "$race_owner"
+rm -f "$RACE_LOG.lock/pid"
+rmdir "$RACE_LOG.lock" 2>/dev/null || true
+kill "$live_pid" 2>/dev/null || true
+wait "$live_pid" 2>/dev/null || true
+wait "$race_writer"
+assert_eq "writer appends once the live lock is released" 0 "$?"
+assert_eq "writer appends exactly one record" 1 "$(wc -l < "$RACE_LOG" | tr -d ' ')"
if ! grep -q 'identity evidence' "$OPENADAPT_ONPREM_AUDIT_LOG"; then
pass "release audit remains PHI-free"
else
diff --git a/packages/flow/deploy/on-prem/docker-compose.yml b/packages/flow/deploy/on-prem/docker-compose.yml
index 850f158b9f..9da3be2e5b 100644
--- a/packages/flow/deploy/on-prem/docker-compose.yml
+++ b/packages/flow/deploy/on-prem/docker-compose.yml
@@ -8,28 +8,31 @@
# * the `clinic-lan` network is `internal: true` -> Docker installs no default
# gateway, so containers on it CANNOT reach the internet (only each other).
# * no ports are published to the host by the runner (nothing inbound either).
-# * the image is built from a LOCAL wheelhouse (see Dockerfile) — no PyPI.
+# * the image is built with no network from a signed release archive
+# (bin/build-image.sh, see Dockerfile) and never pulled: pull_policy never.
#
# What is REAL vs STUB:
# [REAL] the compose topology, internal network, volumes, fail-closed env.
# [REAL] `runner` runs the shipped `openadapt-flow run` via bin/run-queue.sh.
-# [STUB] the images must be BUILT OFFLINE first (Dockerfile needs a staged
-# ./wheels wheelhouse); this file does not fetch anything.
+# [REAL] the image: build it first with bin/build-image.sh (or `docker load`
+# one built elsewhere) and name it in OPENADAPT_FLOW_IMAGE. CI builds
+# it with no network and runs it on this file's internal network
+# (bin/test-container.sh). This file does not build or fetch anything.
# [STUB] `vlm` profile needs a real GPU box + a locally-staged model; the
# appliance is opt-in and LAN-only even here.
#
-# Validate without building: docker compose -f docker-compose.yml config
+# Every command needs the image name, e.g.
+# export OPENADAPT_FLOW_IMAGE=openadapt-flow-onprem:
+# Validate: docker compose -f docker-compose.yml config
# Bring up the runner: docker compose up -d runner
# With the VLM appliance: docker compose --profile vlm up -d
services:
runner:
- build:
- # Repo root is the build context so the engine source is available to the
- # offline wheelhouse install. See ./Dockerfile.
- context: ../..
- dockerfile: deploy/on-prem/Dockerfile
- image: openadapt-flow-onprem:local
+ # Built by bin/build-image.sh from a signed release. No default: running
+ # an unnamed or stale version is worse than refusing to start.
+ image: ${OPENADAPT_FLOW_IMAGE:?set OPENADAPT_FLOW_IMAGE to the image bin/build-image.sh built}
+ pull_policy: never
restart: unless-stopped
# Fail-closed PHI scrubbing + local storage/audit wiring. NO cloud keys.
environment:
@@ -45,7 +48,7 @@ services:
- type: bind
source: ${OPENADAPT_ONPREM_STORAGE:-./storage}
target: /srv/openadapt
- command: ["bin/run-queue.sh", "watch"]
+ command: ["/opt/openadapt-flow/deploy/on-prem/bin/run-queue.sh", "watch"]
networks:
- clinic-lan
# No `ports:` — nothing is published to the host or beyond.
@@ -55,10 +58,8 @@ services:
# docs/deployment/ON_PREM_VLM.md. Enabled only with `--profile vlm`.
vlm-appliance:
profiles: ["vlm"]
- build:
- context: ../..
- dockerfile: deploy/on-prem/Dockerfile
- image: openadapt-flow-onprem:local
+ image: ${OPENADAPT_FLOW_IMAGE:?set OPENADAPT_FLOW_IMAGE to the image bin/build-image.sh built}
+ pull_policy: never
restart: unless-stopped
environment:
VLM_BACKEND: "vllm"
diff --git a/packages/flow/docs/API_TIER.md b/packages/flow/docs/API_TIER.md
index 3428796a6f..6dc43dc74f 100644
--- a/packages/flow/docs/API_TIER.md
+++ b/packages/flow/docs/API_TIER.md
@@ -1,10 +1,13 @@
# API steps from a recording's network traffic
-**Scope: a single HTML form submission.** An API step repeats one
-`application/x-www-form-urlencoded` POST of one HTML form, read again from
-the live page, in place of the clicks and keystrokes that filled it. It is
-not a general API client: JSON or script-built requests and multi-page
-server state stay on the GUI. Other writes during the replaced steps are a
+**Scope: a single HTML form submission.** An API step repeats one POST of
+one HTML form, read again from the live page, in place of the clicks and
+keystrokes that filled it. The POST is `application/x-www-form-urlencoded`
+or `multipart/form-data` with no file attached, and either the browser
+submits the form or the page's script sends the form's fields to another
+endpoint (see [Saves the page's script sends](#saves-the-pages-script-sends)).
+It is not a general API client: JSON and GraphQL bodies, uploads and
+multi-page server state stay on the GUI. Other writes during the replaced steps are a
declared sequence (below): the page's own background requests are listed
and left to the page, and a write a step caused is sent in its recorded
place before the save and read back. A form whose script
@@ -74,6 +77,29 @@ every field it sent:
| `select_label` | A select a recorded step typed an option's label into | The option with that label in the live form; refuse if it isn't exactly one |
| `session` | A CSRF or session value the server issued | Read from the live form in this session |
| `form_default` | The form's own default | Must still equal the recorded default |
+| `submitter` | The `name=value` the browser adds for the submit button the person clicked (`commit=Create`) | The live form must have exactly one enabled submit button that sends that value |
+| `empty_file` | A file input sent empty in a multipart save | The live form must have exactly the recorded file inputs; the API step never attaches a file |
+
+The save is sent with the encoding it was recorded with. A multipart save
+is encoded the way Chromium encodes a form with no file (each field a part,
+an empty file input a part with `filename=""`, line breaks as CRLF), with a
+boundary derived from the fields, so the same request always has the same
+bytes. A multipart save that attached a file isn't captured
+(`body_omitted: "multipart"`), so it isn't proposed. A form with no `action`
+posts to its own page and is matched as such.
+
+The submit button's pair is explained only by exactly one button whose value
+is written in the page. An `` without a `value` sends the
+browser's own label, which the page doesn't state, so such a pair isn't
+explained. When the recording doesn't name the clicked control by `#id` or
+`[name]`, the button that sends the pair is the one clicked when the page's
+script runs; a recorded click on another control refuses the proposal.
+
+The save's success signal is the address it sent the browser to. A record's
+own address (`/ticket/17`, `/ipam/ip-addresses/3/`) is kept whole with each
+number generalized, since the number alone matches almost any URL. A query
+value that is a long hash or nonce (a comment's `moderation-hash`) must be
+present, with any value.
A field is bound by provenance: which recorded step typed into it (the
field the click before the typing targeted), never by its value matching a
@@ -118,6 +144,22 @@ it sends the save. On-screen checks of steps that made no request (layout
and region checks) are screen-only. They are listed for the person who
admits the step and aren't repeated.
+## Saves the page's script sends
+
+Some pages don't submit the form. Their script reads the form's fields and
+posts them to another endpoint (WordPress's category form posts to
+`admin-ajax.php`, not to its `action`). The compiler takes the form whose
+controls name every field the request sent, on the last captured page
+before the save; two such forms refuse the proposal. The binding names the
+form's own action (`form_action`), and the request's endpoint.
+
+Such a save always runs the page's script first (below): the scratch page's
+blocked request to the endpoint is the save compared, and it gets an empty
+`200` so the script carries on. The API step sends only when the script
+sends exactly the body the API step built. A page that no longer has script
+keeps the save on the GUI. There is no redirect to read, so success is the
+recorded status and then the record check.
+
## Other writes in the span
Every other write sent while the replaced steps ran is declared in the
@@ -439,9 +481,13 @@ recording.
- Web only. A desktop app, an RDP session or a Citrix session has no page
requests to capture (`record --capture-network` refuses those backends).
-- One form save, posted as `application/x-www-form-urlencoded`. JSON bodies
- built by script, multipart uploads, signed or computed fields, and saves
- over a websocket aren't proposed.
+- One form save, URL-encoded or multipart with no file. JSON and GraphQL
+ bodies, file uploads, signed or computed fields, and saves over a
+ websocket aren't proposed. Step writes in the span must be URL-encoded.
+- A field the server fills in on every page load (a page-load time, a
+ per-record code, a date defaulting to today) is a form default that
+ differs on the next load, so the API step doesn't run and the GUI steps
+ do.
- The server may enforce less than the page. On OpenEMR, the duplicate
check and the required fields run only in the browser. The API step
carries over the checks it can evaluate (pre-checks, native HTML
diff --git a/packages/flow/docs/ENSURE_RECORD.md b/packages/flow/docs/ENSURE_RECORD.md
index 908ce1f2fd..f24905ee01 100644
--- a/packages/flow/docs/ENSURE_RECORD.md
+++ b/packages/flow/docs/ENSURE_RECORD.md
@@ -24,6 +24,23 @@ The outcome is one of three:
The agent's own report is not evidence. A save that landed while the agent
reported a timeout is judged like any other save.
+### The read-back is the last version written
+
+When `_history` is read, the record judged must be the newest version in the
+window, matched by `meta.versionId`. It is `needs_review` when the read-back
+is an older version, or a version saved after the window closed (by anyone),
+or carries no `versionId`; when any version in the window has no
+`versionId` or time; or when two versions share the newest time. Without
+`_history` there is nothing to bind to, and the other checks decide as
+before.
+
+The evidence lists each version in the window as `versions`: its id and its
+author, oldest first, and never its body. The author is `meta.author` where
+the server returns it. Where it doesn't (Medplum 5.2), and the audit trail
+logged exactly one timed create or update per version, the n-th logged write
+names the n-th version. Otherwise the author is `null`: unknown, not
+guessed.
+
### When the agent corrects its own record
An agent that reads its create back may find a wrong value (a tool changed
diff --git a/packages/flow/docs/GENERATE_CONTRACTS.md b/packages/flow/docs/GENERATE_CONTRACTS.md
index 34f5a331d2..719f38a1af 100644
--- a/packages/flow/docs/GENERATE_CONTRACTS.md
+++ b/packages/flow/docs/GENERATE_CONTRACTS.md
@@ -292,9 +292,12 @@ is a log from its name.
**Changes the app makes to existing rows.** A column of an existing row that
the demonstrations changed with no task value is excused only on that row:
-the target itself, a row with the same key in every demonstration (a counter
-row), or the row the task's values name through a key of the main record
-(the account a contact belongs to). It is never excused by name on other
+the target itself, the row the task's values name through a key of the main
+record (the account a contact belongs to), or a row with the same key in
+every demonstration (a counter row). A row the task's values name is named
+by them even when every demonstration happened to touch the same one (both
+demonstrations were in one project), so a correct write in another project
+is not stopped. It is never excused by name on other
rows or tables. Time stamps set during the write, counters that went up and
columns set to the acting user are proposed; anything else is listed and
excused only if the person adds it (`columns_add`). A changed row the
diff --git a/packages/flow/docs/NETWORK_CAPTURE.md b/packages/flow/docs/NETWORK_CAPTURE.md
index 35b8f84b18..9f0cb9f380 100644
--- a/packages/flow/docs/NETWORK_CAPTURE.md
+++ b/packages/flow/docs/NETWORK_CAPTURE.md
@@ -38,9 +38,10 @@ order the browser finished them:
| `status` | yes | HTTP status, or `null` when the request failed |
| `failed` | no | `true` when the request failed (no response) |
| `request_type` | no | Request content type, without parameters |
-| `form` | no | For `application/x-www-form-urlencoded` bodies: `[[name, value], ...]` in the order sent, duplicates kept |
+| `form` | no | For `application/x-www-form-urlencoded` bodies, and `multipart/form-data` bodies with no file attached: `[[name, value], ...]` in the order sent, duplicates kept |
+| `multipart_files` | no | For a kept multipart body: the names of its file inputs that were sent empty (`[name, ""]` in `form`) |
| `body` | no | Any other text body, as text |
-| `body_omitted` | no | `"multipart"` when a multipart body was not kept |
+| `body_omitted` | no | `"multipart"` when a multipart body was not kept: a file was attached, or the body isn't plain form data |
| `request_truncated` | no | `true` when the body was cut at 256 KiB |
| `response_type` | no | Response content type, without parameters |
| `location` | no | The redirect target, when the response has one |
diff --git a/packages/flow/docs/ON_PREM.md b/packages/flow/docs/ON_PREM.md
index fc909f21d5..cb4d2db6f6 100644
--- a/packages/flow/docs/ON_PREM.md
+++ b/packages/flow/docs/ON_PREM.md
@@ -189,8 +189,10 @@ sudo systemctl enable --now openadapt-flow-runner.path
OPENADAPT_FLOW_SCRUB=on ./bin/verify-airgap.sh --config onprem.yaml --probe
```
-Containers instead of systemd: `docker compose -f docker-compose.yml config`
-(validate), then `docker compose up -d runner`. See
+Containers instead of systemd: build the image from a signed release with
+`bin/build-image.sh` (no network; `docker save` / `docker load` it across),
+set `OPENADAPT_FLOW_IMAGE` to its tag, then `docker compose -f
+docker-compose.yml config` (validate) and `docker compose up -d runner`. See
[`deploy/on-prem/README.md`](../deploy/on-prem/README.md) and
[`deploy/on-prem/UPDATE.md`](../deploy/on-prem/UPDATE.md) for the runnable
package and update/rollback runbook, and
diff --git a/packages/flow/docs/QUALIFY_A_WORKFLOW.md b/packages/flow/docs/QUALIFY_A_WORKFLOW.md
index 280bd367ec..5577f5d6c2 100644
--- a/packages/flow/docs/QUALIFY_A_WORKFLOW.md
+++ b/packages/flow/docs/QUALIFY_A_WORKFLOW.md
@@ -113,7 +113,11 @@ What each part does:
`auth: {bearer_env: NAME}`, `{header: X-Api-Key, value_env: NAME}` or
`{basic_env: NAME}`, with the secret in the named environment variable.
`kind: sql`, `fhir` and `graphql` take the same keys as the effects section
- of a deployment file ([EFFECT_KIT.md](EFFECT_KIT.md)). An on-screen check is
+ of a deployment file ([EFFECT_KIT.md](EFFECT_KIT.md)). For `kind: rest`,
+ `fhir` or `graphql`, `session: module:function` can name a hook that
+ returns an authenticated client instead. `qualify` calls it again for each
+ trial's read-back, so a token that expires during a long campaign is
+ renewed. An on-screen check is
refused: it cannot tell a real write from a screen that only looks right.
- **effects**: the save's checks as an effect contract list, the same
entries an `effect_contract.yaml` holds:
@@ -167,7 +171,9 @@ What each part does:
lists inputs to suffix per campaign, so reruns against a database that was
not reset do not collide.
- **campaign**: the id written into the report, trials per case, the cases
- (all six by default) and the policy `--certify` uses when given no name.
+ (all six by default) and the policy `--certify` uses when given no name
+ (`clinical-write` by default; `record-write` is the same gate for a system
+ of record that is not clinical).
- **business_rules** (optional): rules the application itself enforces, which
the workflow must not click through. The six cases inject faults; a
business-rule case injects nothing. After the reset, `seed` (one of
diff --git a/packages/flow/docs/TUTORIAL.md b/packages/flow/docs/TUTORIAL.md
index 32b8dbec2a..63a18b54a9 100644
--- a/packages/flow/docs/TUTORIAL.md
+++ b/packages/flow/docs/TUTORIAL.md
@@ -125,8 +125,8 @@ openadapt-flow compile rec --out bundle --name my-task # compile it
openadapt-flow lint bundle # expected: finds demo gaps
openadapt-flow certify bundle --policy permissive # smoke-policy pass
openadapt-flow certify bundle --policy clinical-write # expected: strict refusal
-openadapt-flow replay bundle # replay: local, $0
-openadapt-flow replay bundle --drift theme \
+openadapt-flow replay bundle --demo-app # replay: local, $0
+openadapt-flow replay bundle --demo-app --drift theme \
--save-healed-to healed # deterministic repair
openadapt-flow visualize bundle -o graph.html # see what compiled
```
diff --git a/packages/flow/openadapt_flow/__main__.py b/packages/flow/openadapt_flow/__main__.py
index 8f1aae14be..bd65924ff0 100644
--- a/packages/flow/openadapt_flow/__main__.py
+++ b/packages/flow/openadapt_flow/__main__.py
@@ -2985,12 +2985,29 @@ def _cmd_replay(args: argparse.Namespace) -> int:
headed = args.headed or cfg.backend.headed
url = args.url or cfg.backend.url
- refuse_unqualified_skill(bundle, target_url=url)
- if url and args.drift:
+ demo_app = bool(getattr(args, "demo_app", False))
+ if demo_app and (
+ url or backend_cfg.browser_cdp_endpoint or backend_cfg.browser_page_url
+ ):
+ raise SystemExit(
+ "--demo-app serves its own app; drop --url, backend.url or the "
+ "attached browser."
+ )
+ if args.drift and not demo_app:
+ raise SystemExit(
+ "--drift only applies to the bundled MockMed demo app (--demo-app); "
+ "drift your own app for real."
+ )
+ if (
+ url is None
+ and not demo_app
+ and not (backend_cfg.browser_cdp_endpoint or backend_cfg.browser_page_url)
+ ):
raise SystemExit(
- "--drift only applies to the bundled MockMed demo app; "
- "omit --url to use it (drift your own app for real)."
+ "replay needs a target: pass --url, set backend.url in --config, or "
+ "pass --demo-app to replay against the bundled MockMed demo app."
)
+ refuse_unqualified_skill(bundle, target_url=url)
held_backend: list = []
hold_s = min(max(float(getattr(args, "hold_on_halt", 0.0) or 0.0), 0.0), 1800.0)
@@ -3076,13 +3093,13 @@ def run_on_page(page, *, entry_url: Optional[str]):
)
stop = None
- if url is None:
+ if url is None and demo_app:
from openadapt_flow.mockmed.server import serve
url, stop = serve(port=0)
url = _with_drift(url, args.drift)
drift_note = f" (drift: {args.drift})" if args.drift else ""
- print(f"No --url given; replaying against bundled MockMed{drift_note}")
+ print(f"--demo-app: replaying against bundled MockMed{drift_note}")
video_dir = getattr(args, "record_video", None)
from playwright.sync_api import sync_playwright
@@ -9795,22 +9812,30 @@ def build_parser() -> argparse.ArgumentParser:
p = sub.add_parser(
"replay",
help=(
- "Replay a bundle (serves the bundled MockMed demo app when "
- "no --url is given)"
+ "Replay a bundle against --url, the config's backend.url, or "
+ "with --demo-app the bundled demo app"
),
)
p.add_argument("bundle", help="Workflow bundle directory")
p.add_argument(
"--url",
default=None,
- help=("URL of the target app (default: serve the bundled MockMed demo app)"),
+ help="URL of the target app",
+ )
+ p.add_argument(
+ "--demo-app",
+ action="store_true",
+ help=(
+ "Serve the bundled MockMed demo app and replay against it (for a "
+ "bundle recorded on it, such as the tutorial's)"
+ ),
)
p.add_argument(
"--drift",
default=None,
help=(
"Comma-separated MockMed drift modes (theme,move,rename,modal) "
- "to demonstrate bounded drift resolution; only valid without --url"
+ "to demonstrate bounded drift resolution; only with --demo-app"
),
)
p.add_argument(
diff --git a/packages/flow/openadapt_flow/api.py b/packages/flow/openadapt_flow/api.py
index 94e6cae9d0..5ccc4f6abc 100644
--- a/packages/flow/openadapt_flow/api.py
+++ b/packages/flow/openadapt_flow/api.py
@@ -78,16 +78,20 @@ def replay(
*,
params: Optional[Mapping[str, str]] = None,
run_dir: str | Path = "run",
+ demo_app: bool = False,
) -> RunReport:
"""Rehearse ``bundle`` against ``url`` and return the run report.
- Without ``url`` it serves the bundled MockMed demo app, as the CLI does.
+ ``demo_app=True`` (and no ``url``) serves the bundled demo app instead, as
+ ``--demo-app`` does on the CLI. With neither, the replay is refused.
A halted run is not an exception: check ``report.transaction_outcome``.
"""
argv = ["replay", str(bundle), "--run-dir", str(run_dir)]
if url:
argv += ["--url", url]
+ if demo_app:
+ argv.append("--demo-app")
for key, value in (params or {}).items():
argv += ["--param", f"{key}={value}"]
from openadapt_flow.__main__ import main
diff --git a/packages/flow/openadapt_flow/backends/playwright_backend.py b/packages/flow/openadapt_flow/backends/playwright_backend.py
index 90e629b093..6efeb3f683 100644
--- a/packages/flow/openadapt_flow/backends/playwright_backend.py
+++ b/packages/flow/openadapt_flow/backends/playwright_backend.py
@@ -1344,7 +1344,12 @@ def _fill_iso_date(target: Any, text: str) -> bool:
? Array.from(document.getElementsByTagName(level.tagName)).filter(
(candidate) => !tag || candidate.getElementsByTagName(tag).length > 0)
: Array.from(document.querySelectorAll('tr, [role="row"], li, [role="listitem"]'));
- const pool = new Set(siblings.concat(pageWide));
+ const dialog = el.closest(
+ 'dialog:modal, [aria-modal="true"], [role="alertdialog"], .modal.show'
+ );
+ const pool = new Set(siblings.concat(pageWide).filter(
+ (candidate) => !dialog || dialog.contains(candidate)
+ ));
const result = {
record_key: recordKey(row, el),
incomplete: pool.size > cap,
@@ -1675,6 +1680,82 @@ def _fill_iso_date(target: Any, text: str) -> bool:
return null;
}"""
+# The active modal dialog holding the element under a viewport point. Walks the
+# hit-tested chain from the top document down through same-origin frames; the
+# first dialog met is the outermost one, and hit testing already follows the
+# stacking order, so a dialog painted over another wins. The box is the
+# dialog's first element (from the dialog down toward the hit) that does not
+# cover the viewport, so a Bootstrap ``.modal`` overlay yields its
+# ``.modal-dialog``. Answers ``{status: "dialog", box}``, ``{status: "none"}``,
+# or ``{status: "unknown"}`` when a frame on the chain is cross-origin or
+# transformed and the chain cannot be followed.
+_DIALOG_AT_JS = r"""([x, y]) => {
+ // Modal dialogs only: a popover or date picker with role="dialog" leaves
+ // the page behind live, and its rows stay identity evidence.
+ const SELECTOR = 'dialog:modal, [aria-modal="true"], [role="alertdialog"], ' +
+ '.modal.show';
+ const visible = (el, win) => {
+ const style = win.getComputedStyle(el);
+ if (style.display === 'none' || style.visibility === 'hidden') return false;
+ if (parseFloat(style.opacity) < 0.05) return false;
+ const r = el.getBoundingClientRect();
+ return r.width > 0 && r.height > 0;
+ };
+ let doc = document;
+ let win = window;
+ let ox = 0, oy = 0;
+ let px = x, py = y;
+ for (let depth = 0; depth < 9; depth++) {
+ const hit = doc.elementFromPoint(px, py);
+ if (!hit) return {status: 'none'};
+ const dialog = hit.closest(SELECTOR);
+ if (dialog && visible(dialog, win)) {
+ const chain = [];
+ for (let node = hit; node; node = node.parentElement) {
+ chain.push(node);
+ if (node === dialog) break;
+ }
+ const vw = win.innerWidth, vh = win.innerHeight;
+ let box = null;
+ for (let i = chain.length - 1; i >= 0; i--) {
+ const r = chain[i].getBoundingClientRect();
+ if (r.width <= 0 || r.height <= 0) continue;
+ if (r.width >= 0.95 * vw && r.height >= 0.95 * vh) continue;
+ box = r;
+ break;
+ }
+ if (!box) box = dialog.getBoundingClientRect();
+ const left = Math.max(0, box.left), top = Math.max(0, box.top);
+ const right = Math.min(vw, box.right), bottom = Math.min(vh, box.bottom);
+ if (right <= left || bottom <= top) return {status: 'unknown'};
+ return {status: 'dialog', box: [
+ Math.floor(left + ox), Math.floor(top + oy),
+ Math.ceil(right - left), Math.ceil(bottom - top),
+ ]};
+ }
+ const tag = hit.localName;
+ if (tag !== 'iframe' && tag !== 'frame') return {status: 'none'};
+ let child;
+ try { child = hit.contentDocument; } catch (_) { child = null; }
+ if (!child || !hit.contentWindow) return {status: 'unknown'};
+ const r = hit.getBoundingClientRect();
+ if (!(hit.offsetWidth > 0 && hit.offsetHeight > 0)) return {status: 'unknown'};
+ if (Math.abs(r.width / hit.offsetWidth - 1) > 0.01 ||
+ Math.abs(r.height / hit.offsetHeight - 1) > 0.01) {
+ return {status: 'unknown'};
+ }
+ const style = win.getComputedStyle(hit);
+ const dx = r.left + hit.clientLeft + (parseFloat(style.paddingLeft) || 0);
+ const dy = r.top + hit.clientTop + (parseFloat(style.paddingTop) || 0);
+ ox += dx; oy += dy;
+ px -= dx; py -= dy;
+ doc = child;
+ win = hit.contentWindow;
+ }
+ return {status: 'unknown'};
+}"""
+
+
_PROJECT_FRAME_POINT_JS = r"""(el, point) => {
const [px, py] = point;
if (document.elementFromPoint(px, py) !== el) return null;
@@ -2367,6 +2448,43 @@ def structured_text_at(self, x: int, y: int) -> Optional[str]:
return None
return result or None
+ def dialog_region_at(
+ self, x: int, y: int
+ ) -> tuple[str, Optional[tuple[int, int, int, int]]]:
+ """The box of the active dialog holding viewport pixel (x, y).
+
+ Returns ``("dialog", (x, y, w, h))`` when the element under the point
+ sits in an open modal dialog (a modal ``dialog``, ``aria-modal``, an
+ ``alertdialog``, or a shown Bootstrap modal), the outermost one along the frame
+ chain; ``("none", None)`` when the chain was followed and no dialog
+ holds the point; ``("unknown", None)`` when it could not be followed
+ (a cross-origin or transformed frame, an evaluation error). Callers
+ that need a dialog treat ``unknown`` as a failed check. Never raises.
+ """
+ try:
+ result = self.page.evaluate(_DIALOG_AT_JS, [int(x), int(y)])
+ except Exception:
+ return ("unknown", None)
+ if not isinstance(result, dict):
+ return ("unknown", None)
+ status = result.get("status")
+ if status == "none":
+ return ("none", None)
+ box = result.get("box")
+ if (
+ status == "dialog"
+ and isinstance(box, list)
+ and len(box) == 4
+ and all(isinstance(v, (int, float)) for v in box)
+ and box[2] > 0
+ and box[3] > 0
+ ):
+ bx, by, bw, bh = (int(v) for v in box)
+ if not (bx <= int(x) < bx + bw and by <= int(y) < by + bh):
+ return ("unknown", None)
+ return ("dialog", (bx, by, bw, bh))
+ return ("unknown", None)
+
def identity_candidates_at(self, x: int, y: int) -> Optional[dict[str, Any]]:
"""Census of the rows that show the same record identity as (x, y).
diff --git a/packages/flow/openadapt_flow/cli_api.py b/packages/flow/openadapt_flow/cli_api.py
index 51abfc878a..df7396e610 100644
--- a/packages/flow/openadapt_flow/cli_api.py
+++ b/packages/flow/openadapt_flow/cli_api.py
@@ -84,7 +84,7 @@ def cmd_api_show(args: argparse.Namespace) -> int:
reasons: list[str] = []
sidecar = bundle / "api_proposal.json"
if sidecar.is_file():
- reasons = json.loads(sidecar.read_text()).get("reasons") or []
+ reasons = json.loads(sidecar.read_text(encoding="utf-8")).get("reasons") or []
if args.as_json:
rows = []
for s in found:
@@ -144,7 +144,8 @@ def cmd_api_propose(args: argparse.Namespace) -> int:
},
indent=2,
)
- + "\n"
+ + "\n",
+ encoding="utf-8",
)
if step is None:
print(f"No API step proposed: {'; '.join(reasons)}.")
diff --git a/packages/flow/openadapt_flow/cli_config.py b/packages/flow/openadapt_flow/cli_config.py
index 7d06618085..b5521372e7 100644
--- a/packages/flow/openadapt_flow/cli_config.py
+++ b/packages/flow/openadapt_flow/cli_config.py
@@ -49,7 +49,7 @@ def bundle_digest(bundle: Path) -> str:
manifest_path = bundle / "manifest.json"
if manifest_path.is_file():
try:
- manifest = json.loads(manifest_path.read_text())
+ manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
except json.JSONDecodeError as exc:
raise SystemExit(f"{manifest_path} is not valid JSON: {exc}") from exc
digest = manifest.get("content_digest")
@@ -63,7 +63,7 @@ def bundle_surface(bundle: Path) -> Optional[str]:
payload = _require_bundle(bundle)
try:
- raw = json.loads(payload.read_text())
+ raw = json.loads(payload.read_text(encoding="utf-8"))
except json.JSONDecodeError as exc:
raise SystemExit(f"{payload} is not valid JSON: {exc}") from exc
surface = raw.get("surface")
@@ -75,7 +75,7 @@ def bundle_surface(bundle: Path) -> Optional[str]:
def bundle_name(bundle: Path) -> str:
payload = _require_bundle(bundle)
try:
- raw = json.loads(payload.read_text())
+ raw = json.loads(payload.read_text(encoding="utf-8"))
except json.JSONDecodeError:
return bundle.name
name = raw.get("name")
@@ -192,7 +192,7 @@ def init_deploy_config(
draft = build_draft(bundle, backend=backend)
text = render_draft(draft)
out.parent.mkdir(parents=True, exist_ok=True)
- out.write_text(text)
+ out.write_text(text, encoding="utf-8")
# Draft must parse, and must be refused by the governed loader.
parsed = load_deployment(out, allow_unresolved=True)
if parsed.bundle_digest != draft["bundle_digest"]:
diff --git a/packages/flow/openadapt_flow/cli_undo.py b/packages/flow/openadapt_flow/cli_undo.py
index 3159281628..a2a518098e 100644
--- a/packages/flow/openadapt_flow/cli_undo.py
+++ b/packages/flow/openadapt_flow/cli_undo.py
@@ -178,7 +178,7 @@ def register(
p.add_argument(
"--url",
default=None,
- help="URL of the target app (default: the bundled MockMed demo app)",
+ help="URL of the target app (required unless the config names one)",
)
p.add_argument("--headed", action="store_true", help="Run the browser headed")
for add in add_run_flags:
@@ -535,9 +535,8 @@ def _cmd_undo_accept(args: argparse.Namespace) -> int:
def _cmd_undo_run(args: argparse.Namespace) -> int:
run_dir = Path(args.run_dir)
workflow = Workflow.load(args.bundle)
- # Without a target, replay would start the bundled MockMed demo app: the
- # undo would act on the demo, leave the real record alone and use up the
- # approval. Refuse before claiming it.
+ # Without a target, replay has nowhere to act: the undo would use up the
+ # approval and change nothing. Refuse before claiming it.
problem = args._undo_target_problem(args, workflow)
if problem:
print(
diff --git a/packages/flow/openadapt_flow/cli_variants.py b/packages/flow/openadapt_flow/cli_variants.py
index a846cc17ff..c61ae4eeb0 100644
--- a/packages/flow/openadapt_flow/cli_variants.py
+++ b/packages/flow/openadapt_flow/cli_variants.py
@@ -128,8 +128,8 @@ def first_screen_probe(
``--first-screen PNG`` wins. Otherwise the probe reads one frame through
the same backend selection ``run`` uses: the attached signed-in browser
tab, the native or remote-display window, or a freshly launched browser
- at ``--url`` (the bundled MockMed app when no URL is given). It only
- reads; it never clicks or types.
+ at ``--url``. With no target it captures nothing. It only reads; it
+ never clicks or types.
"""
def capture() -> Optional[bytes]:
@@ -148,7 +148,6 @@ def _probe() -> Optional[bytes]:
_attached_web_page,
_deployment_sections,
_resolve_backend_config,
- _with_drift,
)
from openadapt_flow.admitted_composition import resolve_pointer
from openadapt_flow.backends.factory import _normalize_kind, build_backend
@@ -172,15 +171,7 @@ def _probe() -> Optional[bytes]:
with _attached_web_page(backend_cfg, url=url, workflow=workflow) as page:
return build_backend(backend_cfg, page=page).screenshot()
if url is None:
- from openadapt_flow.mockmed.server import serve
-
- served, stop = serve(port=0)
- try:
- return _launched_web_screen(
- _with_drift(served, getattr(args, "drift", None))
- )
- finally:
- stop()
+ return None
return _launched_web_screen(url)
return capture
diff --git a/packages/flow/openadapt_flow/compiler/api_proposal.py b/packages/flow/openadapt_flow/compiler/api_proposal.py
index d01fbdf688..b892c41168 100644
--- a/packages/flow/openadapt_flow/compiler/api_proposal.py
+++ b/packages/flow/openadapt_flow/compiler/api_proposal.py
@@ -54,7 +54,7 @@
import urllib.parse as up
from datetime import datetime, timezone
from pathlib import Path
-from typing import Any, Optional
+from typing import Any, Literal, Optional
from openadapt_flow import network_capture as nc
from openadapt_flow import sealed_set as _sealed
@@ -73,8 +73,10 @@
from openadapt_flow.runtime.actuators.web_form import (
client_rules,
folded_checks_digest,
- form_controls,
+ form_parts,
+ forms_of,
page_scripts,
+ submitter_button,
web_form_effects,
)
@@ -271,16 +273,95 @@ def _success_pattern(line: dict[str, Any]) -> Optional[str]:
target = m.group(1) if m else None
if not target:
return None
- path = up.urlsplit(target).path.rsplit("/", 1)[-1]
- query = up.urlsplit(target).query
- pieces = [re.escape(path)]
- for name, value in up.parse_qsl(query, keep_blank_values=True):
- pieces.append(
- re.escape(name) + "=" + (r"\d+" if value.isdigit() else re.escape(value))
- )
+ path = up.urlsplit(target).path
+ leaf = path.rsplit("/", 1)[-1]
+ if leaf.isdigit() or not leaf:
+ # A record's own address (``/ticket/17``, ``/ipam/ip-addresses/3/``):
+ # the record number alone matches almost anything, so the path is
+ # kept, with each number generalized.
+ pieces = [
+ "/".join(
+ r"\d+" if seg.isdigit() else re.escape(seg) for seg in path.split("/")
+ )
+ ]
+ else:
+ pieces = [re.escape(leaf)]
+ for name, value in up.parse_qsl(up.urlsplit(target).query, keep_blank_values=True):
+ if value.isdigit():
+ pattern = r"\d+"
+ elif _is_token(value):
+ # A value the server makes per record (a hash, a nonce): the
+ # name must be there, the value can't be known in advance.
+ pattern = r"[^\s]+"
+ else:
+ pattern = re.escape(value)
+ pieces.append(re.escape(name) + "=" + pattern)
return r".*?".join(pieces)
+def _is_token(value: str) -> bool:
+ """A long run of letters and digits mixing both, such as a hash."""
+
+ return (
+ len(value) >= 16
+ and re.fullmatch(r"[A-Za-z0-9_-]+", value) is not None
+ and re.search(r"\d", value) is not None
+ and re.search(r"[A-Za-z]", value) is not None
+ )
+
+
+def _button_selector(button: dict[str, Any]) -> str:
+ """A selector for one submit button of the form being evaluated."""
+
+ if button.get("id"):
+ return "#" + button["id"]
+ sel = f"form[data-openadapt-eval] [name={json.dumps(button['name'])}]"
+ if button.get("value") is not None and button.get("value") != "":
+ sel += f"[value={json.dumps(button['value'])}]"
+ return sel
+
+
+def _script_form(
+ lines: list[dict[str, Any]], post: dict[str, Any]
+) -> tuple[Optional[dict[str, Any]], Optional[dict[str, Any]], Optional[str]]:
+ """The form whose fields a page script sent to another endpoint.
+
+ The last captured page, before the save, whose form names every field the
+ save sent (as a control, an empty file input or a submit button). Two
+ such forms on that page are ambiguous.
+ """
+
+ names = {n for n, _ in post["form"]}
+ best: tuple[Optional[dict[str, Any]], Optional[dict[str, Any]], Optional[str]] = (
+ None,
+ None,
+ None,
+ )
+ for ln in lines:
+ if float(ln["t"]) >= float(post["t"]):
+ break
+ if not (
+ ln["method"] == "GET"
+ and ln.get("status") == 200
+ and ln.get("resource_type") == "document"
+ and "