diff --git a/.github/workflows/nightly-live.yaml b/.github/workflows/nightly-live.yaml index a8748a0..1e86a99 100644 --- a/.github/workflows/nightly-live.yaml +++ b/.github/workflows/nightly-live.yaml @@ -1,213 +1,42 @@ -name: Nightly live +# Hardware conformance CI gate. +# +# Runs the real-device conformance and preflight suites from temp/mhs_exp/. +# Trigger: manual dispatch or the "hardware-conformance" label on a PR. +# NOT part of every-PR CI -- it requires device access and is opt-in. -# The only lane that talks to a real provider. It exists to catch what replay -# structurally cannot: a provider changing its behavior or its payload shape. -# Everything else runs offline, so a red build here never blocks a merge — it -# tells us the recorded truth has drifted from the real one. +name: Hardware Conformance on: - schedule: - # 02:30 UTC daily. - - cron: '30 2 * * *' workflow_dispatch: - inputs: - rerecord: - description: 'Capture fresh provider traffic and open a PR with it' - type: boolean - default: false - # Opt-in per pull request via the `ci:live` label. Unlike the schedule, this - # path has a diff, so it runs only the journeys the change could plausibly - # break — each live journey costs real tokens and real minutes. pull_request: - types: [labeled, synchronize, reopened] - -concurrency: - group: nightly-live-${{ github.event.pull_request.number || 'schedule' }} - cancel-in-progress: false - -permissions: - contents: read + types: [labeled] jobs: - # ── L3: real provider ────────────────────────────────────────────────── - live: - # On a pull request, only with the `ci:live` label — never automatically, so a - # fork PR cannot spend tokens. + hardware-conformance: + # Only run when manually dispatched or the label is present. if: >- - github.event_name != 'pull_request' || - contains(github.event.pull_request.labels.*.name, 'ci:live') - runs-on: ubuntu-latest - timeout-minutes: 40 - # Credentials live as secrets on this environment, so only jobs that declare - # it can read them. Deliberately *without* required-reviewer or - # deployment-branch rules: reviewers would leave the nightly cron waiting for - # a human, and restricting branches to main would reject every `ci:live` run - # (a pull_request ref is refs/pull/N/merge). The real gates are that fork PRs - # never receive secrets, that applying the label needs write access, and that - # each journey caps its own calls and tokens. - environment: live-llm - steps: - - uses: actions/checkout@v4 - with: - # Journey selection needs history to find the merge base. - fetch-depth: 0 - - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: '3.12' - - - name: Install uv - uses: astral-sh/setup-uv@v4 - with: - enable-cache: true - - - name: Install dependencies - run: uv sync --all-extras - - - name: Decide which journeys to run - id: pick - # A scheduled run has no diff and takes every live-capable journey. A - # labelled pull request takes only the journeys whose declared - # SUBJECT_PATHS the change touches. Journeys with LIVE_SIGNAL = False - # (control plane, lifecycle) are excluded either way, and R4 additionally - # refuses to run live because it asserts on injected failures. - env: - BASE_REF: ${{ github.base_ref }} - run: | - if [ -n "${BASE_REF}" ]; then - JOURNEYS=$(uv run python tools/impact.py --base "origin/${BASE_REF}" --live-journeys) - else - JOURNEYS=$(uv run python tools/impact.py --live-journeys) - fi - printf 'selected journeys:\n%s\n' "${JOURNEYS}" - echo "journeys=$(echo ${JOURNEYS} | tr '\n' ' ')" >> "$GITHUB_OUTPUT" - - - name: Journeys against the real provider - if: steps.pick.outputs.journeys != '' - env: - LEAPFLOW_TEST_LLM_MODE: live - LEAPFLOW_LLM_API_KEY: ${{ secrets.LEAPFLOW_LLM_API_KEY }} - LEAPFLOW_LLM_BASE_URL: ${{ secrets.LEAPFLOW_LLM_BASE_URL }} - # A cheap model keeps the lane affordable; the journeys assert - # invariants, not prose quality. Each journey also enforces its own - # provider-call *and* token ceilings, so neither a non-converging turn - # nor prompt growth can run up a bill. - LEAPFLOW_LLM_MODEL: ${{ secrets.LEAPFLOW_LLM_CHEAP_MODEL }} - JOURNEYS: ${{ steps.pick.outputs.journeys }} - run: uv run pytest ${JOURNEYS} -q -m e2e --tb=short - - - name: Daemon logs on failure - if: failure() - run: | - find /tmp -maxdepth 6 -name 'leapd.log' -newermt '-40 minutes' 2>/dev/null | while read -r log; do - echo "===== $log =====" - tail -n 200 "$log" - done - - # ── Re-record: refresh recorded truth and propose it as a diff ────────── - # Manual only. Recorded traffic is a reviewed artefact: a bot silently updating - # what the mock layer asserts against would defeat the point of recording it. - # Recording writes to recordings/ and never touches the replay store, so this - # job cannot break the offline lanes. - rerecord: - if: github.event_name == 'workflow_dispatch' && inputs.rerecord == true + github.event_name == 'workflow_dispatch' || + (github.event_name == 'pull_request' && + contains(github.event.pull_request.labels.*.name, 'hardware-conformance')) runs-on: ubuntu-latest - timeout-minutes: 40 - environment: live-llm - permissions: - contents: write - pull-requests: write - steps: - - uses: actions/checkout@v4 - - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: '3.12' - - - name: Install uv - uses: astral-sh/setup-uv@v4 - with: - enable-cache: true - - - name: Install dependencies - run: uv sync --all-extras - - - name: Capture real provider traffic - env: - LEAPFLOW_TEST_LLM_MODE: record - LEAPFLOW_LLM_API_KEY: ${{ secrets.LEAPFLOW_LLM_API_KEY }} - LEAPFLOW_LLM_BASE_URL: ${{ secrets.LEAPFLOW_LLM_BASE_URL }} - LEAPFLOW_LLM_MODEL: ${{ secrets.LEAPFLOW_LLM_CHEAP_MODEL }} - run: uv run pytest tests/journeys -q -m e2e --tb=short - - - name: Derive mock-layer response shapes from the new traffic - run: uv run python tools/sync_fixtures.py + timeout-minutes: 30 - - name: Confirm the offline lanes still pass - env: - LEAPFLOW_TEST_LLM_MODE: replay - run: uv run pytest tests/journeys tests/regression -q -m "e2e or invariant" -n 4 - - - name: Open a pull request with the refreshed traffic - uses: peter-evans/create-pull-request@v6 - with: - branch: chore/rerecord-provider-traffic - title: 'chore(tests): refresh recorded provider traffic' - body: | - Captured fresh provider traffic and re-derived the response shapes the - mock layer checks against. - - Review the diff in `tests/_fixtures/llm_responses/response_shapes.json` - first: a change there means a provider altered its payload shape, and - some parser may now be reading a field that no longer exists. - commit-message: 'chore(tests): refresh recorded provider traffic' - add-paths: | - tests/_fixtures/recordings/** - tests/_fixtures/llm_responses/** - - # ── Refresh the impact map from a full green run ──────────────────────── - impact-map: - # Never on a pull request: the map is a repository artefact refreshed from a - # full green run, not something a PR should regenerate. - if: github.event_name != 'pull_request' - runs-on: ubuntu-latest - timeout-minutes: 60 - permissions: - contents: write - pull-requests: write steps: - uses: actions/checkout@v4 - name: Set up Python uses: actions/setup-python@v5 with: - python-version: '3.12' + python-version: "3.12" - - name: Install uv - uses: astral-sh/setup-uv@v4 - with: - enable-cache: true - - - name: Install dependencies - run: uv sync --all-extras - - - name: Rebuild the coverage-derived impact map - env: - LEAPFLOW_TEST_LLM_MODE: replay - run: uv run python tools/impact.py --build-map + - name: Install project and experiment drivers + run: | + pip install -e . + pip install -e temp/mhs_exp/drivers/leapflow_host + pip install -e temp/mhs_exp/drivers/leapflow_bench - - name: Open a pull request with the refreshed map - uses: peter-evans/create-pull-request@v6 - with: - branch: chore/refresh-impact-map - title: 'chore(tests): refresh the coverage-derived impact map' - body: | - Regenerated `tests/.impact/coverage_map.json` from a full green run. + - name: Run conformance suite + run: python temp/mhs_exp/scripts/conformance.py - This map is what lets the pull-request lane scope the mock layer to - the change while still seeing runtime coupling through EventBus and - Protocol indirection. - commit-message: 'chore(tests): refresh the coverage-derived impact map' - add-paths: tests/.impact/coverage_map.json + - name: Run preflight checks + run: python temp/mhs_exp/scripts/preflight.py diff --git a/AGENTS.md b/AGENTS.md index e2090cc..acd6be6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,13 +8,15 @@ This document is the LeapFlow engineering collaboration contract. It is not only 2. **Context Pipeline as Core** — Signal → Filter (SNR) → Compress (intent-preserving) → Store (multi-layer) → Retrieve (goal-dependent) → Decide. Every feature and every external signal source, including IM collaboration events, must map to this pipeline before it can drive action. -3. **Progressive Trust** — Never auto-execute on first encounter. Earn autonomy through repeated success: DRAFT → CANDIDATE → VERIFIED → PRODUCTION. +3. **Everything Is a Plugin** — Capability is composed, not built in. Tools, LLM backends, platform adapters, signal sources, and vision processors all arrive behind a `runtime_checkable` Protocol and are discovered, injected, and disposed by the same machinery. A capability that can only exist by editing core is a design failure; the answer is a new Protocol, not a special case. -4. **Occam's Razor** — The simplest correct solution wins. Reject complexity that doesn't directly serve user value. Every abstraction must pay for itself. +4. **Progressive Trust** — Never auto-execute on first encounter. Autonomy is earned through repeated observed success and lost the same way: DRAFT → CANDIDATE → VERIFIED → PRODUCTION on consecutive successes, demotion on consecutive failures, permanent freeze on an internal defect. Trust is per plugin, persisted, and the only legitimate source of an approval exemption. -5. **LLM-Native Design** — Design for LLM reasoning first. Protocols over classes. Declarative over imperative. Context over configuration. +5. **Occam's Razor** — The simplest correct solution wins. Reject complexity that doesn't directly serve user value. Every abstraction must pay for itself. -6. **User-Centric Reliability** — User experience is part of correctness. Every change must keep common paths easy, predictable, recoverable, and must not degrade adjacent workflows. +6. **LLM-Native Design** — Design for LLM reasoning first. Protocols over classes. Declarative over imperative. Context over configuration. + +7. **User-Centric Reliability** — User experience is part of correctness. Every change must keep common paths easy, predictable, recoverable, and must not degrade adjacent workflows. ## Code Quality Requirements @@ -31,7 +33,7 @@ This document is the LeapFlow engineering collaboration contract. It is not only ## Architecture Principles -- **System Boundary Awareness**: LeapFlow is a multi-entry, multi-module runtime. Changes must account for the affected path across CLI/TUI, leapd, engine, skills/tools, LLM, storage, memory, gateway, hub, and platform adapters. +- **System Boundary Awareness**: LeapFlow is a multi-entry, multi-module runtime. Changes must account for the affected path across CLI/TUI, leapd, engine, plugins, skills/tools, LLM, storage, memory, gateway, hub, and platform adapters. - **TUI as the Primary User Entry**: The interactive TUI is the default product surface. Preserve streaming feedback, command queue behavior, approval prompts, status bar accuracy, long-input robustness, history, and session continuity. - **Concurrent TUI Instances Are a Supported Scenario (MANDATORY)**: several TUIs in *different workspaces*, sharing one leapd and one profile, is a normal way to use LeapFlow — not an edge case. Each instance must remain fully usable and must see only its own session, conversation, context usage, and turn state. A change to session routing, `status()`, stream metadata, the client lease, or anything the status bar renders is not verified until it has been exercised with two instances in two workspaces at the same time. One instance degrading another is a release blocker, not a limitation to document. - **TUI Command Clarity**: Global task-control commands stay short and unambiguous (`/cancel`, `/skip`, `/pause`, `/resume`, `/queue`, `/drop`); teach-mode controls must use the `/teach ...` namespace and should not keep bare compatibility aliases during early iteration. @@ -65,6 +67,24 @@ This document is the LeapFlow engineering collaboration contract. It is not only - **Budget-Constrained Recovery**: Turn-level deadlines, per-category limits, and a global recovery budget prevent infinite retry loops. Every recovery action has an explicit cost; exhaustion triggers a clean halt or user escalation. - **Recovery Strategy as Protocol**: Recovery strategies implement a `RecoveryStrategy` Protocol (`can_apply` + `decide`), registered by priority, composable, and extensible without modifying the coordinator. +## Plugin and Extension Rules + +The plugin subsystem is not a feature area — it is how the product is composed. Every capability the agent has, and every capability it can acquire at runtime, enters through it, so a mistake here changes what the agent is able to do rather than how well it does it. + +- **`leapflow.plugins` owns extension mechanics; `leapflow.tools` owns tool behaviour (MANDATORY)**: contracts (`protocol.py`), discovery/DI/assembly (`registry.py`), fiber lifecycle (`scoped_registry.py`), isolation (`sandbox/`), and distribution (`marketplace/`) live in the plugin package and nowhere else. The dependency direction is one-way and executable: plugin core must never import a tool module, and `tool_plugins/` is the single layer allowed to wrap one (`tests/test_architecture_contracts.py`). The relocated `leapflow.tools.{plugins,protocol,plugin_registry,scoped_registry,marketplace,sandbox}` paths must stay physically absent — a compatibility shim would split the registry's single source of truth and let two divergent registries coexist. +- **`ToolMetadata` is the single source of truth for a tool**: one declaration produces the provider schema, the handler mapping, and the PCD/capability metadata. `x_leapflow` is mandatory and must carry `category` and `risk_level`; a mutating tool declares `mutates_state` plus its approval/idempotency metadata; capability tags are declared, not inferred. Never hand-write a second schema, a parallel handler table, or a capability list beside it — the disclosure layer reads declared metadata first, and substring inference is a deprecated fallback that logs a warning. +- **Tool names are one global namespace, arbitrated first-wins and never silently**: the incumbent keeps the name and the challenger is recorded as a `CapabilityConflict` surfaced through `plugin_list`. Rejection is deliberately non-fatal so one colliding plugin cannot break assembly for every other plugin. Never overwrite a live handler or emit a duplicate schema to claim a name another plugin already owns. +- **Dependencies arrive late, through `bind_runtime`, and absence degrades rather than crashes**: plugins declare names in `dependencies` and receive matching services injected in provider→consumer topological order, independent of discovery order. A plugin module must not import a runtime service at module level, and must not perform I/O, network calls, or state mutation at import time — every module is required to be importable standalone. A handler whose dependency was never bound returns a structured refusal; it does not raise. +- **Lifecycle is a fiber, and cleanup is a scope (MANDATORY)**: every plugin instance lives under a `PluginFiber` (`PENDING → LOADING → ACTIVE → UNLOADING → DISPOSED`, with a `LOADING → FAILED → LOADING` retry path) whose `EffectScope` disposes registered effects LIFO, children before parents, idempotently and exception-safely. Anything a plugin registers process-globally — an interceptor on `registry.tool_pipeline`, an EventBus subscription, a background task — must be registered as an effect on that scope in the same change, or disable and reload leak it. Illegal transitions raise `IllegalStateTransition` instead of being silently corrected. +- **Hot-reload is safe because handlers are snapshotted per turn, not because reload is atomic**: each turn copies `dict(registry.tool_handlers)` at its start, so an in-flight turn finishes against the handlers it began with while `notify_mutation()`'s version bump invalidates the catalog cache for turns that start later. Reload re-imports through `importlib.util.spec_from_file_location` using the source path recorded as `__leapflow_plugin_path__` — never by mutating global `sys.path`, and never in a way that requires the plugin to be importable from the process's import path. +- **Plugin governance is cold-path (MANDATORY)**: fiber state, trust ledgers, usage statistics, health producers, advisors, proposal queues, and marketplace work must add no per-turn cost to the hot path. Trust is flushed to DuckDB only on level transitions (plus a final `atexit` flush), and usage samples stay in bounded deques. A governance feature that measurably slows an ordinary turn is a defect in the feature, not a cost to accept. +- **Plugin mutation is uniformly HIGH risk and never permanently granted**: any action whose `metadata.platform == "plugin_management"` is forced to `RiskLevel.HIGH` with `allow_permanent=False` in `security/risk.py` — defense-in-depth that holds even when caller metadata is wrong. Install, reload, rollback, enable, disable, and remove each build an `ActionDescriptor` and go through `ApprovalOrchestrator` per invocation. The single exemption is `plugin_reload` at `PRODUCTION` trust, which is earned evidence rather than a configured bypass. With no gate installed (in-process CLI binds none), every mutation is denied: code that can rewrite the agent's own composition must never be installable through an unguarded path. +- **Self-evolution is a governed pipeline, not a code-writing shortcut**: capability gap → proposal → generate → validate (syntax → structure → import/Protocol conformance) → compatibility assessment → approval → write → sandbox smoke → register at DRAFT → behavior tests → probation → trust accrual → verify, with quarantine and rollback as the failure path. An `INCOMPATIBLE` verdict is rejected before any file write; a failure at any later stage rolls back the fiber, the `sys.modules` entry, and the written file. Each next action comes from `AdaptiveEvolutionPolicy` reading structured requirement, risk, trust, and status — never from natural-language intent — and the autonomy level is configuration, so raising it is a deliberate operator decision rather than a code path. +- **Untrusted code is isolated before it is trusted**: `requires_sandbox` defaults to `True`; sandboxed plugins run in a subprocess over JSON-RPC with a bounded invoke timeout and receive no host-side runtime dependencies. Marketplace artifacts are verified by SHA-256 checksum and, when trusted pubkeys are configured, by Ed25519 signature over the canonical `name|version|entry_point|checksum_sha256` payload. Validation re-runs on the install path even for marketplace code that was already checked. +- **Plugins are process-global; sessions are not**: the registry is a daemon-wide singleton, so install, reload, disable, and remove change the capability set for every connected client at its next turn, and trust accrues from all of them. Any change to plugin state must be assessed against the concurrent-TUI contract — per-turn snapshots are the only isolation, and there is deliberately no per-workspace plugin set. +- **Self-capability answers come from the live registry, never from documentation**: when LeapFlow reports what it supports — plugins, self-evolution, hot reload, version management — the evidence is `plugin_list`'s live `capability_report` or an equivalent runtime registry read. If runtime introspection fails, state that the running state could not be verified; never infer a capability from README, design docs, or memory. +- **The plugin contract is published, so it changes with the code**: `docs/plugins/third_party_plugin_development.md` (interfaces, deployment, security model) and `docs/plugins/plugin_lifecycle_management.md` (lifecycle, governance matrix, enforcement status) are third-party-facing specifications whose tables state what the code does *today*. A change to a Protocol, a lifecycle transition, an approval rule, a config key, or an injectable dependency name updates them in the same change — and never promotes a roadmap entry to ENFORCED ahead of the wiring. + ## Path Tree, Configuration, and Secrets Rules - **Path tree is a product contract**: every LeapFlow-managed path must be declared by `PathLayout`, `ProfileLayout`, `CacheLayout`, or a child layout object. Runtime code must consume layout APIs, never assemble managed paths with ad-hoc string joins. @@ -108,6 +128,8 @@ Every capability that changes the world outside the current turn — shell execu - Preserve security and audit paths: dangerous actions, file writes, outbound messages, credentials, and path access must flow through the existing policy, approval, redaction, and audit mechanisms. - Preserve gateway safety boundaries: inbound credentials stay in CredentialVault; outbound send/write/execute actions go through ApprovalGate; bot self-messages and duplicate events are filtered before routing; platform-specific metadata must remain in `metadata` escape hatches instead of polluting core message types. - Keep App Connector governance thin: platform core should consume normalized contracts and failures, while app-specific auth scopes, CLI/SDK error parsing, vendor recovery steps, and command templates remain in action packs, adapters, or backend-specific helpers. If a new platform requires changing gateway core business rules, first refactor toward a protocol hook or app-side classifier. +- To add a built-in plugin, add its module path to `_BUILTIN_PLUGIN_MODULES` in `plugins/tool_plugins/__init__.py` and expose a module-level `plugin` instance; that wrapper module is the only place allowed to import the tool implementation it exposes. +- Tool handlers are `async`, accept the parameters their schema declares, and return a structured `dict`. Expected failures come back as `{"ok": False, "error": ...}`, which records an ordinary trust-affecting failure; raising instead reserves the signal for a genuine internal defect. Permanent trust freezing is a `hard_failure` recorded through `LifecycleGovernor`, not something an escaping exception should trigger by accident. - Maintain backward-compatible migrations for persistent state, configuration, skills, trajectories, sessions, and profile data. - Write unit tests before or alongside the implementation - Integrate via EventBus events, not direct function calls between modules @@ -128,6 +150,7 @@ Every capability that changes the world outside the current turn — shell execu - Passing tests and a clean lint run are NOT a substitute for confirmation. Slash commands are the primary user-facing control plane; correctness of the visible behavior is only established by a human check. - State the pending confirmation explicitly in the handoff, and name the behavior a human should exercise to verify it. - **Human confirmation for approval-path changes**: any change to what reaches the approval chain — a newly gated capability, a new `ActionKind`, an `ApprovalDecision`/scope/bypass change, or gate registration — requires exercising the real prompt by hand in *both* in-process and daemon mode before it is considered ready. Gates are process-global and injected twice, so a green suite proves at most that one of the two wirings works; every approval defect recorded in this document passed its tests. +- **Deep review for plugin composition changes**: a change to a Protocol signature, discovery, fiber lifecycle, trust thresholds, sandbox policy, or marketplace verification alters what the agent can load and execute at runtime. Exercise it against a real registry (register → publish → reload → dispose) rather than a fake, and re-check the concurrent-client and cold-path implications before considering it complete. - **Design goal check**: Verify that the implementation actually achieves the intended design goal and is not just a local patch. - **Optimality check**: Evaluate whether the solution is the simplest robust design, avoids unnecessary abstractions, and fits the existing architecture. - **Regression impact check**: Inspect affected modules and user journeys for logic bugs, degraded UX, broken compatibility, slower feedback, weaker diagnostics, or worse failure recovery. @@ -168,7 +191,7 @@ Each journey also declares two cost ceilings, both enforced at the proxy and rep - **Mock at boundaries only**: mock external I/O (network, disk), never internal logic - **A test may not fabricate the wiring it claims to cover**: building an object with `object.__new__` and assigning the private attributes the code reads cannot detect a wrong attribute *name* — the test simply agrees with the typo. Calibration tests did exactly that and stayed green while every real turn raised `AttributeError`. Any test whose stated purpose is wiring must construct the real object and drive the production path. - **Multi-client behavior needs multi-client tests**: session routing, `status()`, stream metadata, and client-lease changes require two sessions in two workspaces asserting that neither sees the other's identity, usage, or turn state. Single-session tests cannot observe cross-client leakage, which is why a leak shipped with a green suite. -- **Change-scoped validation**: Run the most specific relevant tests first, then broaden only as needed: CLI/TUI changes require CLI/TUI tests; leapd changes require daemon RPC/lifecycle tests; storage or memory changes require persistence tests; gateway, IM, event-source, or approval changes require connector lifecycle, event normalization, routing, idempotency, self-message filtering, security/approval, and failure-recovery tests; skills, learning, perception, and copilot changes require their lifecycle or pipeline tests. +- **Change-scoped validation**: Run the most specific relevant tests first, then broaden only as needed: CLI/TUI changes require CLI/TUI tests; leapd changes require daemon RPC/lifecycle tests; storage or memory changes require persistence tests; gateway, IM, event-source, or approval changes require connector lifecycle, event normalization, routing, idempotency, self-message filtering, security/approval, and failure-recovery tests; plugin contract, registry, lifecycle, sandbox, marketplace, or trust changes require the plugin reload, scoped-registry, fiber/effect-scope, sandbox, marketplace-signing, trust-learning, and architecture-contract tests; skills, learning, perception, and copilot changes require their lifecycle or pipeline tests. - **Recovery strategy isolation**: Each `RecoveryStrategy` must be testable in isolation — verify `can_apply` predicates, `decide` outputs, and side-effect-state gating independently of the coordinator and other strategies. - **Budget boundary tests**: Verify that recovery budgets exhaust correctly (per-category, per-turn, deadline), that exhaustion produces a deterministic halt decision, and that cost accounting is exact. @@ -196,6 +219,12 @@ Each journey also declares two cost ceilings, both enforced at the proxy and rep - Treating a missing, unbound, or raising approval gate as permission to proceed - Putting a secret, token, or config value into `ApprovalRequest.detail` — it is rendered to the user *and* persisted to the audit log - Extending `ApprovalDecision` without updating the daemon normalizer, TUI modal, and RPC in the same change +- Importing a tool implementation from plugin core, or re-creating a `leapflow.tools` shim for a relocated plugin-subsystem module +- Module-level I/O, network calls, or runtime-service imports in a plugin module; dependencies arrive through `bind_runtime` +- Registering a process-global interceptor, subscription, or background task without a matching cleanup effect on the plugin's `EffectScope` +- Reloading a plugin by injecting into `sys.path` instead of a file-backed import spec, or overwriting a live handler to claim a tool name another plugin owns +- Adding per-turn cost for plugin governance (trust, stats, health, advisor, proposals) — governance is cold-path +- Answering a question about LeapFlow's own capabilities from documentation or memory instead of a live registry read - Bare `except:` clauses — always specify the exception type - `# TODO: implement` stubs — implement or don't commit diff --git a/docs/plugins/hardware_init_calibration.md b/docs/plugins/hardware_init_calibration.md new file mode 100644 index 0000000..4e1fe77 --- /dev/null +++ b/docs/plugins/hardware_init_calibration.md @@ -0,0 +1,519 @@ +# Declaring Device Readiness: Init, Homing, and Calibration + +> **Audience**: Device declaration authors and hardware app-pack developers. +> **Authoritative source**: Derived from production code at +> `src/leapflow/hardware/context.py` (declarative protocol), +> `src/leapflow/hardware/tools.py` (write path, `_write` / `_not_ready` / dry-run), +> `src/leapflow/hardware/registry.py` (admission, V7 unverified policy), and +> `src/leapflow/security/permission_failures.py` (`build_readiness_failure`). +> **Scope**: This document covers **one** pattern — declaring that a device must +> reach a ready state (homed / initialized / calibrated) before a channel can be +> commanded, and how that declaration is enforced fail-closed. It does **not** +> describe a device state machine, calibration procedures, or persisted +> calibration data; those are deliberately out of scope (see §6). + +--- + +## 1. What "readiness" means in the Hardware Context Protocol + +A device is often unsafe or meaningless to command until it has completed an +initialization routine: a robot arm must be **homed** before its joints know +where they are; a depth camera must be **calibrated** before a captured frame +has any metric meaning. The Hardware Context Protocol (HCP) expresses this as a +**declared precondition on a writable channel**, not as procedural code and not +as a new field. + +The key architectural fact — enforced by +`tests/test_architecture_contracts.py` — is that `context.py` carries **no +transport, vendor, or upstream-standard concept**. Readiness is therefore +declared with the two primitives that already exist: + +- [`Envelope.requires_interlocks`](../../src/leapflow/hardware/context.py) — a + tuple of interlock ids that must hold before a write to that channel is + permitted. +- [`Interlock`](../../src/leapflow/hardware/context.py) — a deterministic + channel comparison (`channel_id` + `operator` + `value`) that points at a + **readiness channel** reporting whether the device has finished its init / + homing / calibration routine. + +There is **no** `DevicePhase`, `CalibrationState`, or `Procedure` type in the +current implementation. Readiness is nothing more than "a readable channel says +the device is ready, and a writable channel refuses to be commanded until it +does." + +--- + +## 2. How readiness is enforced (the write path) + +When a model calls `hw_actuate` / `hw_configure` / `hw_dispense`, the handler +`HardwareTools._write` (`src/leapflow/hardware/tools.py`) runs a fixed sequence +of feasibility checks **before** any approval prompt is shown, honoring the +platform rule that *feasibility precedes consent*: + +1. resolve device + channel, +2. channel is writable, +3. effect class matches the tool, +4. `hw_describe` was called first (when `require_describe_before_write`), +5. value lies inside the declared envelope, +6. rate limit (`max_rate`) is respected, +7. device is reachable, +8. **evaluate `requires_interlocks`** via `_failed_interlocks`, +9. build the `ActionDescriptor`, +10. (dry-run stops here — see §4), +11. **if any readiness interlock is unmet → hard stop `_not_ready`** (this + section), +12. approval gate, +13. execute against the transport. + +### 2.1 `not_ready` is a fail-closed hard stop + +If step 8 finds any unmet interlock, `_write` returns +`_not_ready(...)` **before consent is sought**. That refusal is built by the +single shared authority +[`build_readiness_failure`](../../src/leapflow/security/permission_failures.py), +so the engine and TUI report it identically. The payload is: + +```json +{ + "ok": false, + "device_id": "robot_arm_r1", + "channel_id": "joint_shoulder", + "failure_code": "not_ready", + "failure_class": "device_not_ready", + "blocks_approval": true, + "retryable": true, + "recoverability": "ready_state_required", + "error": "robot_arm_r1.joint_shoulder is not ready to command: 'homed' requires homed_state == True. Bring robot_arm_r1 to its declared ready state -- run its initialization / homing / calibration routine so every precondition above holds, confirm it by reading the source channel back, then re-issue the same command. No approval was requested because the command cannot succeed until the device is ready.", + "repair": { + "kind": "device_readiness", + "device_id": "robot_arm_r1", + "channel_id": "joint_shoulder", + "unmet": [ + { + "interlock_id": "homed", + "channel_id": "homed_state", + "operator": "eq", + "value": true, + "description": "The arm must complete homing before any joint is commanded.", + "declared": true + } + ] + }, + "side_effect_state": "none" +} +``` + +Properties that matter: + +- **`blocks_approval: true`** makes it a hard stop under + `is_permission_hard_stop_payload`: the turn surfaces the deterministic repair + instruction instead of giving the LLM another chance to retry or invent a way + around it. No approval prompt is ever shown. +- **`retryable: true`** because the *identical* command becomes feasible once + the preconditions hold — the fix is to make the device ready, not to change + the command. +- **`side_effect_state: "none"`** — nothing reached the device. +- The **`error`** prose and the machine-readable **`repair.unmet`** array carry + the same information, so both a human and an automated caller know exactly + which precondition failed and on which source channel to confirm it. + +### 2.2 Readiness fails closed on every uncertainty + +`_failed_interlocks` treats a missing interlock, an unreadable source channel, +or a source read that raises **all** as unsatisfied: "cannot check" and "not +satisfied" carry the same consequence. An interlock named on a channel's +`requires_interlocks` but absent from the device's `interlocks` list is reported +with `"declared": false`, because the repair differs (fix the declaration, not +the device). The risk classifier keeps its own interlock hardline as +defense-in-depth for any descriptor built outside this path. + +--- + +## 3. Authorizing calibration with `verified_by` + +Readiness (§2) answers "has the device finished its routine?" A separate +question is "does a human vouch for this device declaration at all?" — which is +what authorizes writes in the first place. That is +[`ContextProvenance.verified_by`](../../src/leapflow/hardware/context.py) and +the **V7 admission rule** in `registry.py`. + +- `ContextProvenance.is_verified` is simply `bool(verified_by.strip())`. +- Under the default policy `unverified_context_policy = "deny_write"` + (`HardwareSettings`), an **unverified** context has **every writable channel + demoted to read-only** at admission time (rule V7). A subsequent write then + fails with `channel_not_writable`, not `not_ready` — the two are distinct + causes. + +Verification is stored **out of band** from the declaration on purpose: the +person who confirms a device must not edit the file they are confirming, or the +confirmation would be self-attested. The YAML provider reads a sibling +`verified.json` mapping `device_id → verifier` and stamps +`provenance.verified_by` on load (`YamlContextProvider._apply_verification`). + +```json +// verified.json (sibling of the devices directory) +{ + "depth_cam_d1": "alice@lab (intrinsics+hand-eye checked 2026-08-30)" +} +``` + +For calibration-bearing devices this doubles as the **calibration +authorization**: a device whose captured data is only trustworthy after +calibration should ship **unverified**, so its writable channels stay demoted +until a human records — in `verified.json` — that calibration was performed and +checked. Set `verified_by` and the writable channels are admitted; leave it +empty and they are not. + +--- + +## 4. Previewing with `dry_run` + +Every write tool accepts `dry_run: true` +(`hw_actuate` / `hw_configure` / `hw_dispense`). A dry run executes **all** +feasibility checks in §2 (resolution, writability, effect class, describe, +envelope, rate, reachability, **and interlocks**), builds the approval +descriptor, and then **stops without seeking consent or touching the device**. +It is safe against an irreversible channel because nothing is written; the +result is a `WriteOutcome` with `preview: true` and `side_effect_state: "none"`. + +The returned `plan` reports the command that *would* be issued together with the +outcome of every pre-consent check — including readiness: + +```json +{ + "device_id": "robot_arm_r1", + "channel_id": "joint_shoulder", + "ok": false, + "side_effect_state": "none", + "preview": true, + "failure_code": "interlocks_unsatisfied", + "error": "Interlocks ['homed'] are not satisfied for robot_arm_r1.joint_shoulder, so the real command would be refused. Restore the interlock conditions before commanding it.", + "plan": { + "value_in_envelope": true, + "interlocks_satisfied": false, + "interlocks_failed": ["homed"] + } +} +``` + +`plan.ok` is `true` only when the value is inside the envelope **and** every +interlock holds — the same two conditions that would otherwise let it reach +approval. This makes `dry_run` the recommended way to confirm both intent and +readiness before committing an irreversible physical effect. + +> **Dispense note:** `effect=dispense` is treated as an irreversible external +> output regardless of the channel's `reversible` flag — a substance that has +> left the device cannot be un-dispensed. Consequently, dispense writes +> **never** receive session-level or profile-level reusable consent +> (`allow_permanent` is always `false`); each dispense command is confirmed +> individually. + +--- + +## 5. Complete examples + +Device declarations are YAML files whose structure mirrors +`HardwareContext.to_dict()` / `from_mapping`. `hc_version` must be `hc.v0`. +`Interlock.operator` is one of `eq` / `ne` / `lt` / `le` / `gt` / `ge` +(default `eq`); `value` defaults to `true`. + +### 5.1 Robot arm homing + +A `homed_state` readiness channel reports whether homing has completed; a motion +channel declares `requires_interlocks: [homed]`, so a joint cannot be commanded +until the arm is homed. + +```yaml +hc_version: hc.v0 +device_id: robot_arm_r1 +display_name: Bench robot arm R1 +vendor: ExampleRobotics +model: RA-6 +location: bench-3 +halt_supported: true +notes: >- + Six-axis arm. Joints must be homed before any motion command; homing establishes + the absolute joint origin the motion channels are expressed against. + +transport: + kind: cli + # Homing sequence, DH parameters, and the homing routine itself live here in the + # transport/app-pack layer -- never in the HCP core declaration. See section 6. + config: + endpoint: "robotctl" + home_command: "home --all" + +channels: + # Readiness channel: readable boolean the interlock points at. + - channel_id: homed_state + direction: read + quantity: homing_state + effect: read + description: True once the arm has completed its homing routine. + + # Motion channel: refuses to be commanded until 'homed' holds. + - channel_id: joint_shoulder + direction: readwrite + quantity: angle + unit: deg + effect: actuate + verify_after_write: true + envelope: + declared: true + min_value: -170.0 + max_value: 170.0 + max_rate: 45.0 + quantization: 0.01 + settling_time_s: 0.5 + reversible: true + requires_interlocks: + - homed + description: Shoulder joint angle. Homing must complete before commanding it. + +interlocks: + - interlock_id: homed + channel_id: homed_state + operator: eq + value: true + description: The arm must complete homing before any joint is commanded. +``` + +Commanding `joint_shoulder` while `homed_state` reads `false` returns the +`not_ready` hard stop from §2.1; once homing completes and `homed_state` reads +`true`, the identical command proceeds to the approval gate. + +### 5.2 Depth camera calibration + +A `calibrated` readiness channel gates a `capture` channel, and the device is +declared **unverified** so its writable channel stays demoted until a human +records the calibration in `verified.json` (§3). + +```yaml +hc_version: hc.v0 +device_id: depth_cam_d1 +display_name: Depth camera D1 +vendor: ExampleVision +model: DC-2 +location: cell-1 +halt_supported: false +notes: >- + Structured-light depth camera. A captured frame is only metrically meaningful + after intrinsic + extrinsic (hand-eye) calibration has been performed and a human + has recorded it. Ships unverified: the capture channel is admitted only once a + verifier is recorded out of band. + +transport: + kind: cli + # Intrinsic/extrinsic/hand-eye calibration algorithms, the camera-matrix format, + # and convergence criteria all live here -- not in the HCP core. See section 6. + config: + endpoint: "depthcamctl" + calibrate_command: "calibrate --hand-eye" + +provenance: + source: declared + # verified_by is intentionally empty here. It is stamped out of band from + # verified.json (device_id -> verifier) so the confirmation is not self-attested; + # until then, V7 admission demotes 'capture' to read-only. + verified_by: "" + +channels: + # Readiness channel: readable boolean the interlock points at. + - channel_id: calibrated + direction: read + quantity: calibration_state + effect: read + description: True once intrinsic + hand-eye calibration has completed. + + # Capture channel: refuses to fire until calibration holds, and is admitted + # writable only once the device is verified. + - channel_id: capture + direction: readwrite + quantity: frame_request + effect: actuate + envelope: + declared: true + reversible: true + requires_interlocks: + - calibrated + description: Trigger a depth-frame capture. Requires completed calibration. + +interlocks: + - interlock_id: calibrated + channel_id: calibrated + operator: eq + value: true + description: Calibration must complete before a capture is trusted. +``` + +Two independent gates apply here: + +- **Authorization (V7 / `verified_by`)** — until `verified.json` records a + verifier for `depth_cam_d1`, `capture` is demoted to read-only at admission; + commanding it fails `channel_not_writable`. +- **Readiness (`requires_interlocks`)** — once verified, `capture` still refuses + to fire with `not_ready` until `calibrated` reads `true`. + +### 5.3 Temperature controller with tolerance and first-order settling (macOS host) + +A real-device declaration (macOS host driver) demonstrating the `tolerance` and +`settling_model` fields added in Stage C (G-1 / G-2 protocol revisions). + +`tolerance` declares the **absolute precision** of a channel. When `tolerance > 0`, +`normalized_delta` divides by `tolerance` instead of the envelope span — so a +tight-tolerance channel on a wide envelope reports error faithfully instead of +appearing misleadingly small (see §8 Q15 in the research document). + +`settling_model: first_order` + `settling_tau_s` expresses a first-order +exponential settling behavior. The effective settling time is `5 * tau` (99 % +convergence), replacing the fixed `settling_time_s` wait for channels where a +scalar step-time is inadequate. When both `settling_time_s` and `settling_tau_s` +are declared, the system takes `max(settling_time_s, 5 * settling_tau_s)`. + +```yaml +hc_version: hc.v0 +device_id: temp_ctrl_t1 +display_name: Peltier temperature controller T1 +vendor: ExampleThermal +model: PTC-200 +location: bench-1 +halt_supported: true +notes: >- + Peltier-based temperature controller with first-order thermal response. The + heatsink sensor has 0.5 °C absolute precision (tolerance), and the PID loop + settles exponentially with τ ≈ 2 s to the setpoint. Declaration via the + leapflow_host macOS driver. + +transport: + kind: leapflow_host + config: + endpoint: "localhost:9710" + bus: i2c + device_address: 0x48 + +provenance: + source: declared + verified_by: "operator@lab (PID tuned, tolerance verified 2026-08-28)" + +channels: + # Readiness channel: PID loop reports stable + - channel_id: pid_stable + direction: read + quantity: controller_state + effect: read + description: True once the PID loop has achieved stable regulation. + + # Temperature setpoint: first-order settling, tolerance-normalised + - channel_id: setpoint + direction: readwrite + quantity: temperature + unit: degC + effect: configure + verify_after_write: true + envelope: + declared: true + min_value: 4.0 + max_value: 85.0 + max_rate: 5.0 + quantization: 0.1 + tolerance: 0.5 + settling_model: first_order + settling_tau_s: 2.0 + settling_time_s: 3.0 + reversible: true + requires_interlocks: + - pid_ready + description: >- + Temperature setpoint in °C. tolerance=0.5 means normalized_delta divides + by 0.5 (not by span 81); settling uses 5τ = 10 s (> settling_time_s 3 s, + so effective = 10 s). PID must be stable before commanding. + + # Heatsink readback: read-only sensor with tolerance for observation scoring + - channel_id: heatsink_temp + direction: read + quantity: temperature + unit: degC + effect: read + envelope: + declared: true + min_value: -10.0 + max_value: 100.0 + tolerance: 0.5 + description: >- + Heatsink temperature readback. tolerance=0.5 is used for + observation scoring: a 0.3 °C deviation scores 0.3/0.5 = 0.6 + instead of 0.3/110 ≈ 0.003. + +interlocks: + - interlock_id: pid_ready + channel_id: pid_stable + operator: eq + value: true + description: PID controller must be stable before setpoint changes. +``` + +Key points demonstrated: + +- **`tolerance: 0.5`** on `setpoint` and `heatsink_temp` — `normalized_delta` + divides by 0.5 instead of `max_value − min_value`. A 0.3 °C error scores + 0.6, not 0.003. +- **`settling_model: first_order`** + **`settling_tau_s: 2.0`** — the effective + settling wait is `max(settling_time_s, 5 × settling_tau_s)` = `max(3, 10)` = + 10 s. An observation arriving before 10 s after the command is not scored. +- **`settling_model` defaults to `"step"`** and **`settling_tau_s` defaults to + `0.0`**: existing declarations without these fields behave exactly as before. + +--- + +## 6. Boundary: what belongs to the driver / app-pack, not the HCP core + +This is a direct application of AGENTS.md's **Platform vs App Business +Boundary**. The HCP core (`hardware/context.py` and the write path) owns only: + +- the **readiness declaration** (`requires_interlocks` + `Interlock` pointing at + a readiness channel), +- the **fail-closed gate** (`not_ready` before consent; unverified → V7 + demotion), +- **observability** of the refusal (the shared `build_readiness_failure` + payload; `preview` plans). + +Everything about *how* a device becomes ready is **third-party / app-pack** +concern, declared through `transport.config` and implemented in the +driver/transport, never in the HCP core declaration: + +| Belongs to driver / transport / app-pack | Not in HCP core | +|---|---| +| The homing motion sequence and its ordering | — | +| Camera intrinsic / extrinsic / hand-eye calibration algorithms | — | +| DH-parameter tables, camera-matrix / distortion formats | — | +| Convergence criteria and tolerances for a calibration run | — | +| The command that actually runs the routine (e.g. `home --all`) | — | + +The HCP core neither runs these routines nor understands their formats. It only +observes, through a declared readiness channel, whether they have *finished*, +and refuses writes until they have. + +`CalibrationStore` (`hardware/calibration_store.py`) belongs to the +**storage / governance layer**: it persists versioned calibration results +(parameters, matrices, poses) to the profile's `instrument.duckdb` and surfaces +`last_calibrated_at` through `hw_describe`, but it does not know what a +calibration *is* — the algorithms, matrix formats, convergence criteria, and +hand-eye procedures remain driver / app-pack concerns, consistent with the +boundary above. + +--- + +## 7. Out of scope (subsequent evolution) + +This document covers **only** the readiness-gating pattern that ships today. + +| Capability | Status | +|---|---| +| Persisted calibration data (parameters, matrices, poses) | **Implemented — IC-7** (`CalibrationStore` in `instrument.duckdb`, versioned; `hw_describe` outputs `last_calibrated_at`) | +| A full device state machine (`DevicePhase` / `CalibrationState`) | Not implemented — future (IC-5) | +| Multi-step `Procedure` orchestration for init/homing/calibration | Not implemented — future (IC-8) | +| Reference frames / pose representation | Not implemented — future (IC-10) | + +Today, "readiness" is exactly a readable channel plus an interlock, backed by +versioned calibration storage. There is no state-machine type and no procedure +runner behind these primitives yet. diff --git a/docs/plugins/hardware_peripherals_board.md b/docs/plugins/hardware_peripherals_board.md new file mode 100644 index 0000000..f619baf --- /dev/null +++ b/docs/plugins/hardware_peripherals_board.md @@ -0,0 +1,422 @@ +# Peripherals on LeapBoard: Discovery, Preview, and Settings + +> **Audience**: Driver and app-pack developers adding a peripheral, and operators +> deciding what a profile should expose. +> **Authoritative source**: Derived from production code at +> `src/leapflow/hardware/providers/` (discovery), `src/leapflow/hardware/transports/` +> (capture and control), `src/leapflow/hardware/context.py` (declared facts), +> `src/leapflow/hardware/preview.py` (preview lease), +> `src/leapflow/hardware/risk.py` (privacy classification), and +> `src/leapflow/dashboard/` (board data plane and view). +> **Scope**: how a peripheral becomes visible, previewable and settable on LeapBoard, +> and what a third party must implement to add one. It does **not** describe device +> readiness or calibration procedures — see +> [`hardware_init_calibration.md`](hardware_init_calibration.md). + +--- + +## 1. The claim this document makes + +**Adding a peripheral requires no board code.** A new device appears on LeapBoard with +live values, a trace, a preview and controls because every panel is derived from the +*declaration* — not from a per-device view, an icon table, or a type switch. + +That is a contract, and it is testable. The board asks two questions of each channel, +both answered by declared fields: + +| Declared | Board renders | +|---|---| +| `representation: scalar` + `sample_rate_hz > 0` | value, trend, sparkline, envelope band | +| `representation: state` | value as-is | +| `representation: frame` | **preview panel** (`MediaPreview`) | +| `direction: readwrite` / `write` | **control** whose widget comes from the envelope | +| `privacy: environment` / `personal` | consent notice, and the read is gated | + +Nothing consults `device_class`. It is a free-form grouping label used for section +headings and nothing else, deliberately not an enum: the moment a device *type* decides +what is permitted, every new peripheral needs a core edit and an unrecognised one gets a +wrong default. + +--- + +## 2. Adding a peripheral: five steps + +### 2.1 Implement a `HardwareContextProvider` + +```python +class MyScannerProvider: + kind = "my_scanner" + + def discover(self) -> tuple[HardwareContext, ...]: + ... +``` + +`discover()` **must not connect to a device**. Discovery has to work with the hardware +powered off, and it runs during daemon boot — so it must also not block. Two rules follow +that are easy to get wrong: + +- **No device I/O, and no slow subprocess.** Reading a mount table, a sysfs node or an + in-process counter is fine. Opening a camera is not: on macOS that raises a system + permission dialog, and a background process cannot explain why one appeared. +- **Enumerate metadata only.** `leapflow.hardware.media` lists AVFoundation inputs by + parsing ffmpeg's own `-list_devices` output precisely because it never opens one. + +### 2.2 Implement a `HardwareTransport` + +Six methods: `open`, `close`, `read`, `write`, `probe`, `halt`. See +`src/leapflow/hardware/transport.py`. + +If the device produces images, additionally satisfy `FrameTransport`: + +```python +async def read_frame( + self, channel_id: str, *, max_width: int = 0, quality: int = 0 +) -> FrameReading: ... +``` + +This is a **side protocol, not a seventh core method**. Capability is discovered with +`isinstance(transport, FrameTransport)`, so most drivers never grow a method they cannot +implement. A device declaring a `frame` channel whose transport does not satisfy it is +refused on first preview with `failure_code="transport_not_frame_capable"` — a named +degradation rather than an `AttributeError`. + +`FrameReading` is deliberately **not** a `Reading`. Readings are appended to raw NDJSON +segments and downsampled into DuckDB windows; a frame has no mean and no bound, and a few +hundred kilobytes per sample would turn the segment writer into a disk filler with a +schedule. + +### 2.3 Register both + +Three ways, in ascending order of independence: + +```python +# In-tree: one row in the factory table. +_PROVIDERS["my_scanner"] = "my_pkg.provider:build_provider" + +# From a plugin, scoped so a hot reload cannot leave a stale factory behind. +scope.effect(register_provider("my_scanner", "my_pkg.provider:build_provider")) +scope.effect(register_transport("my_rig", "my_pkg.driver:build_transport")) + +# Out-of-tree: `pip install` is enough. +[project.entry-points."leapflow.hardware.providers"] +my_scanner = "my_pkg.provider:build_provider" +[project.entry-points."leapflow.hardware.transports"] +my_rig = "my_pkg.driver:build_transport" +``` + +Built-in names win over entry points: an installed package must not be able to hijack +`yaml`, `host` or `media` and change where a profile's device knowledge comes from. + +### 2.4 Declare the channels + +```yaml +channels: + - channel_id: frame + direction: read + quantity: image_frame + representation: frame # -> preview panel + media_type: image/jpeg + privacy: environment # -> consent gate + sample_rate_hz: 2.0 # capture *ceiling*, not a sampling cadence + - channel_id: exposure_us + direction: readwrite + effect: configure # -> hw_configure owns it + unit: us + envelope: + declared: true # -> slider, min..max, step from quantization + min_value: 100.0 + max_value: 33000.0 + quantization: 100.0 + reversible: true +``` + +Two field semantics are load-bearing and non-obvious: + +- **`sample_rate_hz` on a media channel is a capture ceiling.** `Channel.is_streaming` + is false for media, so no sampling loop is built and nothing is written to the reading + store. The preview path reads it as the fastest it may ask the device for frames. +- **The envelope *is* the widget specification.** `allowed_values` → select; + `min_value` + `max_value` → slider stepped by `quantization`; neither → plain field. + +### 2.5 Nothing else + +`leap hw scan` (or the daemon's rediscovery interval) admits it, and it appears on the +fleet board grouped by `device_class`, with a device page carrying whatever its channels +declared. + +--- + +## 3. Privacy: why a read can need consent + +`HardwareEffect` classifies what a **write** changes. It cannot express the difference +between a thermometer and a webcam: both are `effect: read`, with no envelope and nothing +to actuate, and one of them discloses the room. + +`PrivacyTier` is the declared fact that can: + +| Tier | Meaning | Read is | +|---|---|---| +| `none` (default) | discloses nothing about the surroundings | free | +| `environment` | observes the space around the machine (camera, microphone) | gated, MEDIUM | +| `personal` | observes the person using it (screen, location) | gated, HIGH | + +Consequences, all enforced in code: + +- **`allow_permanent=False`.** A standing, unexpirable grant to observe somebody's room + is not consent — it is the absence of it. A session-scoped grant still spares them a + prompt per frame. +- **Refusal precedes the transport.** `HardwareTools._consent_for_read` returns before + `registry.transport()` is called, because opening the device is what raises the + platform dialog. A refused read must never get that far. +- **Fail closed, three ways.** No gate installed (the in-process CLI binds none), a gate + that raises, and a gate that denies all produce `failure_code="consent_required"` with + a message naming the next step. + +### 3.1 Where consent is actually given + +A browser cannot grant itself a camera *by asserting so* — but it can carry the question +and the answer. Both surfaces work, and they differ only in where the prompt appears: + +| Surface | Prompt appears | Use when | +|---|---|---| +| **The board page** | inline, inside the preview panel that made the request | you clicked *Start preview* and are looking at it | +| **`/board preview `** | in the TUI | you want the grant before opening a browser | + +Both reach the same gate. What makes the page a legitimate surface is structural, not a +relaxation: + +- The prompt is **raised by the daemon's approval chain**, not invented in JavaScript. It + arrives carrying the risk assessment and *the choices the policy allowed* — the page + renders those verbatim, so it cannot offer an "always allow" the policy withheld. +- The answer goes back through **`approval.resolve`**, so the grant, the audit record and + the decision semantics stay the orchestrator's. +- The prompt only exists because **this page made the request**. The person answering is + the person who clicked. + +The mechanism is `_APPROVAL_ROUTED_METHODS` in `daemon/server.py`. +`ApprovalCoordinator.request_approval` returns `deny` when no approval route is installed, +and the daemon installs one for `command.execute` and for the two device observations +(`hardware.frame`, `hardware.read`). A routed request delivers its prompt as an interleaved +`stream.chunk` notification on its own socket, which +`DaemonClient.request(on_stream_event=...)` forwards — the dashboard forwards it to the +browser hub, and the request **waits** for the answer. + +That waiting is the point: answering completes the very request that raised the prompt, so +there is no second round trip and no window where a grant exists but the picture does not. +An unanswered prompt cannot leak, because a routed request registers, denies-on-exit and +unregisters exactly as a slash command does. + +A local environment camera uses a **session consent family**, not a per-device prompt: +one consent covers its probe, the following live stream, and another local camera looking +at the same physical space. Camera and microphone remain separate families; personal, +remote and unknown device classes remain per-device. The action summary, risk assessment +and audit record still name the actual device/channel, so only reusable grant identity is +grouped. + +The page promotes **Allow for this session** as the primary choice. **Allow once** returns +exactly one still frame or one level sample; it deliberately does not open a continuous +stream, so it never turns a one-shot decision into ongoing observation. + +### 3.2 Screens + +Screen-capture devices are **not enumerated by default** (`hardware.media_screens`). A +platform that presents the display as just another video input would otherwise put +"stream this person's screen" on the board beside the webcam, one click away. + +--- + +## 4. Operating it from the TUI + +`/board` is the operator surface, and every verb below reads or requests — none of them +commands a device directly. + +| Command | Does | +|---|---| +| `/board hardware` | the fleet: every attached peripheral, grouped by class | +| `/board devices` | the same list as text in the TUI — no browser needed | +| `/board device ` | the **same** `hardware` lens, focused on one device | +| `/board preview [channel]` | establish consent (prompt appears here), then open the preview | +| `/board rescan` | re-run discovery after a hot-plug | + +There is one hardware lens, not two. `hardware` renders the fleet; naming a device renders +that device. They were separate templates and the split did not pay for itself — +`hardware` and `hardware_device` read as synonyms in the lens list, and the second was +never a different *way of looking*, only a different subject. + +`` accepts a **unique prefix**, matching how `/board stop` already resolves a watch +id: discovered ids are long (`camera_0_macbook_pro`) and an ambiguous prefix reports the +candidates rather than guessing. Deliberately no completion is offered for the id — it +would be captured when the TUI started and keep offering a device that has since been +unplugged. + +`/board rescan` is ungated because every provider in the default set enumerates +passively. A scanner that transmits or leaves the host would need its own gate, which is +exactly why those are not in the default set. + +--- + +## 5. Preview: shared, bounded, self-releasing + +Two shapes, one gate. A **frame** channel streams pictures; a privacy-gated **scalar** +channel is a live meter, which is how a microphone's input level is presented. Both are +continuous disclosures of the surroundings, both need consent, and both are useless as a +static table cell. `inventory._is_previewable` decides from the declared representation, +and the preview row reports that representation verbatim as its render mode — so a frame +source that is not a camera renders as a picture, and a level source that is not a +microphone renders as a meter, on the strength of the declaration alone. No field in this +contract carries a device class. + +| Channel | Endpoint | Transport | +|---|---|---| +| `representation: frame` | `GET /api/media/ws` | WebSocket, binary JPEG frames, latest-only | +| `representation: frame` | `GET /api/media/frame` | one still frame, and the only place a refusal can be read as text | +| privacy-gated scalar | `GET /api/media/level` | JSON, polled four times a second into a browser-local waveform | +| either | `POST /api/media/release` | releases this viewer's lease at once, without waiting for the idle sweep | + +A binary WebSocket rather than MJPEG: an `` buffers at the browser's +discretion, and a viewer that falls behind is shown a backlog rather than the present — +which is how a 30fps preview came to display frames tens of seconds old. Frames now arrive +as discrete binary messages, are decoded with `createImageBitmap`, and only the newest is +drawn to a ``; a frame that arrives while one is decoding replaces the pending one +instead of queueing. The level channel is polled because its value is a number, so there is +no response to hold open. The board draws its last 96 readings as a **browser-local +waveform**; values are never persisted as audio history. + +The client asks for one frame (or one reading) first. That probe is what surfaces a +refusal as text — a stream cannot report *why* it failed. If the person chooses **Allow +once**, that probe is the complete result; choosing **Allow for this session** starts the +continuous stream without asking a second time. + +`PreviewBroker` (`src/leapflow/hardware/preview.py`) owns the one path where a device +stays claimed across requests. Four properties, each present because its absence is a +real failure: + +1. **Shared upstream.** Most devices admit a single reader, so two viewers of one camera + must not open it twice. Frames are captured once per channel and handed to whoever + asks. +2. **Profile-bounded work.** The page offers Economy (640px / 4fps / JPEG 60), Balanced + (960px / 8fps / JPEG 75, default) and Detail (1280px / 30fps / JPEG 85). The daemon + clamps every request against the channel declaration and `hardware.preview_*` ceilings; + a hand-edited URL cannot raise capture cost. Profile identity is part of the cached + frame key, so selecting Detail never shows a cached Economy frame. +3. **Viewer-owned, explicitly released.** Each viewer holds a named lease, and closing a + panel releases *that* lease immediately — only the last one to leave closes the device. + The idle sweep (`hardware.preview_idle_timeout_s`, 5s) remains as the lost-client + fallback, not the primary mechanism: a browser tab closing is not an event the daemon + can observe, but a tab that closes cleanly must not leave a camera on for the timeout. +4. **Serialised like every other device access.** The frame read runs inside the registry's + per-device I/O lock, exactly as a scalar read, a write and the sampling loop do. A + transport-internal lock is not enough: a third-party `FrameTransport` sharing a bus with + a scalar channel would otherwise interleave request and response frames. + +A one-shot `read` of a frame channel is **not** a preview and holds no lease, so it +releases whatever capture it started. Only the broker's path leaves a device claimed — +otherwise a single `hw_read` would light an indicator nothing owned and nothing would +switch it off. + +What is actually capturing is reported, not inferred: each preview row carries `active`, +`viewers` and `frame_age_ms` from the live lease. The device page's Connection stat cannot +say this — a media transport reports `connected` once its declaration is bound, before any +capture — so the lease is the only field that explains an indicator light somebody can see. +Reading it goes through `registry.active_previews()`, which answers "nothing" without +building a broker, because a broker that exists starts a sweeper task. + +The Preview selector is **not** a durable config editor. It records a browser-local +preference per device/channel and sends a bounded request for the next live preview; the +daemon is authoritative for every compute limit. The default balanced profile is chosen +to make a camera useful without spending Detail's CPU/bandwidth in every open board. + +There is deliberately no autostart: opening a camera follows a person asking, not a page +loading. + +The wire path is `MediaPreview` → `GET /api/media/ws` → `hardware.preview.stream` RPC → +broker → `read_frame` (under the device's I/O lock). `hardware.preview.release` and +`hardware.preview.status` are the matching control and introspection RPCs. + +--- + +## 6. Settings: the board asks, it never decides + +A control on a device page has two buttons, and the pair is the design: + +| Button | RPC | Effect | +|---|---|---| +| **Preview change** | `hardware.write_request` with `dry_run=true` | every feasibility check runs; nothing reaches the device | +| **Request approval** | `hardware.write_request` with `dry_run=false` | goes through `ApprovalOrchestrator` per invocation | + +Both reach the **same** daemon RPC, which delegates to the ordinary +`hw_configure`/`hw_actuate`/`hw_dispense` handler. There is deliberately no second write +path: the handler owns every feasibility check, the approval descriptor, the audit record +and the side-effect verdict, and a parallel implementation would be a second gate free to +disagree with the one that actually protects the device. + +The tool is chosen from the channel's **declared effect class**, never from the caller. +Letting the board name it would allow routing a motion command on an `actuate` channel +through `hw_configure` and getting the gentler classification. + +The board may carry an approval prompt raised by its own preview request and resolve it +through `approval.resolve`; the daemon still owns risk classification, policy, grants and +audit. The board cannot invent choices or approve an unrelated action. Device controls use +the same `hardware.write_request` path described above. + +--- + +## 7. Configuration + +| Key | Default | Notes | +|---|---|---| +| `hardware.enabled` | `true` | passive host/media discovery is on; reads that disclose surroundings still need consent | +| `hardware.providers` | `yaml,host,media` | comma-separated; scanners that transmit or leave the host are opt-in | +| `hardware.host_interval_s` | `5.0` | fast host channels; disk/battery/thermal multiply it | +| `hardware.host_include` / `_exclude` | empty | channel-id **prefixes**, because mounts and interfaces are discovered | +| `hardware.media_screens` | `false` | enumerate displays as previewable devices | +| `hardware.media_microphones` | `true` | level only, never audio | +| `hardware.preview_max_fps` | `12.0` | hard ceiling; the page defaults to Balanced at 8fps | +| `hardware.preview_max_width` | `1280` | hard ceiling; Balanced requests 960px, height follows aspect ratio | +| `hardware.preview_quality` | `85` | hard JPEG-quality ceiling; Balanced requests 75 | +| `hardware.preview_idle_timeout_s` | `15.0` | silence after which the device is released | +| `hardware.rediscover_interval_s` | `0` (off) | automatic rediscovery; runs on the monitor cadence, never on a turn | + +All of `hardware.*` is restart-required: providers run at startup and the preview broker +is built with these values. + +--- + +## 8. What the host provider exposes + +The machine LeapFlow runs on is a device whose channel set is discovered rather than +written down. It is **one** device (`host`) with namespaced channels — `cpu.utilization`, +`memory.available_bytes`, `disk..free_bytes`, `net..rx_bytes_per_s`, +`battery.percent`, `thermal..celsius` — because seven devices would consume most +of `hardware.max_devices` before a single real peripheral was admitted. + +`psutil` is an optional enhancement, not a requirement: without it the table shrinks to +what the standard library can answer (load average, disk usage, cpu count) and the rest +of the system behaves as though those channels do not exist, which is the honest report +rather than a zero. + +Three filters keep the set usable, and each earns its place on a real macOS host, which +enumerates two dozen interfaces and eight APFS volumes of one container: + +- **Interfaces**: up, non-loopback, and having carried traffic — then the busiest three. +- **Filesystems**: de-duplicated by observed capacity, because firmlinked volumes of one + APFS container all report the *same* total and free bytes. +- **Everything**: a `DEFAULT_MAX_CHANNELS` valve, logged when it bites. + +Nothing the host provider declares is writable. A discovered declaration carries no +envelope a person is accountable for, so `HostTransport.write` refuses every call and +reports `SIDE_EFFECT_NONE` — provable here, unlike in most transports, because the call +never reaches anything. + +--- + +## 9. Out of scope + +- **Audio playback.** A microphone exposes an input level scalar. A recording is a + different capability with a different consequence, and this must not quietly become + one: `FfmpegLevelReader` sends its output to `null` and only a number leaves it. +- **Emitting or egressing scanners.** Bluetooth (transmits) and mDNS/ONVIF (leaves the + host) are not implemented here. They are a provider module plus a row, and they must be + opt-in for the reason stated in §2.1. +- **Frames in stored history.** Media channels are never sampled. A trace of frames has + no mean, and the payload ceiling on a finding is 256 KB. diff --git a/docs/plugins/third_party_plugin_development.md b/docs/plugins/third_party_plugin_development.md index ea5d220..df3d29c 100644 --- a/docs/plugins/third_party_plugin_development.md +++ b/docs/plugins/third_party_plugin_development.md @@ -42,6 +42,9 @@ config, gateway dispatch) plus the Tool Capability Contract in | `SignalSource` | `perception/signal_source.py` | Stateless event → signal transform | | `ActiveSignalSource` | `perception/active_signal_source.py` | Long-running signal emitter (webhook listener, polling bot) | | `CVProcessor` | `perception/cv_processor.py` | Frame-pair visual diff processing | +| `HardwareContextProvider` | `hardware/providers/__init__.py` | Discover physical devices and declare their channels | +| `HardwareTransport` | `hardware/transport.py` | Execute reads/writes against one device (six methods) | +| `FrameTransport` | `hardware/transport.py` | Optional side protocol: a device that produces frames | Additionally, `FrameStore` (`perception/storage/frame_store.py`) is a `@runtime_checkable` Protocol for pluggable frame persistence backends. @@ -53,6 +56,13 @@ Additionally, `FrameStore` (`perception/storage/frame_store.py`) is a `@runtime_ - **SignalSource** — You need to normalize external events into LeapFlow's signal pipeline (stateless, transform-only). - **ActiveSignalSource** — You need a long-running listener that emits signals (websocket, polling loop). - **CVProcessor** — You are implementing a visual diff algorithm for the perception subsystem. +- **HardwareContextProvider / HardwareTransport** — You are adding a peripheral. Both have + their own entry-point groups (`leapflow.hardware.providers`, + `leapflow.hardware.transports`), so `pip install` is enough. See + [`hardware_peripherals_board.md`](hardware_peripherals_board.md) for the full contract, + including how a declared channel becomes a LeapBoard preview or control with no board + code, and [`hardware_init_calibration.md`](hardware_init_calibration.md) for declaring + readiness preconditions. --- diff --git a/src/leapflow/causal/inference.py b/src/leapflow/causal/inference.py index 23d0a05..f6b1ab4 100644 --- a/src/leapflow/causal/inference.py +++ b/src/leapflow/causal/inference.py @@ -231,6 +231,24 @@ class RuleEngine: def __init__(self, rules: Optional[List[CausalRule]] = None) -> None: self._rules = rules if rules is not None else _load_default_rules() + @property + def rules(self) -> List[CausalRule]: + """Return a copy of the current rule list.""" + return list(self._rules) + + def add_rule(self, rule: CausalRule) -> None: + """Append a rule dynamically (e.g. from teach→rule injection). + + Duplicate names are silently replaced so a reload never produces + parallel copies of the same declaration. + """ + self._rules = [r for r in self._rules if r.name != rule.name] + self._rules.append(rule) + + def set_rules(self, rules: List[CausalRule]) -> None: + """Replace the entire rule set (used by ``reload_rules``).""" + self._rules = list(rules) + def infer(self, events: List[CausalEvent], graph: CausalGraph) -> int: """Apply rules to establish edges. Returns number of edges added.""" edges_added = 0 @@ -625,3 +643,44 @@ def heuristic(self) -> HeuristicEngine: @property def verifier(self) -> VLMVerifier: return self._verifier + + # ── Dynamic rule management ── + + def add_rule(self, rule: CausalRule) -> None: + """Add or replace a single rule at runtime. + + Designed for the teach→rule injection path: a rule discovered during a + session can be installed immediately without a full reload. Duplicate + names are replaced so that teaching the same rule twice does not + accumulate copies. + """ + self._rules.add_rule(rule) + logger.debug("Dynamic rule added: %s", rule.name) + + def reload_rules(self, path: Optional[Path] = None) -> int: + """Hot-reload rules from YAML (default: bundled ``rules.yaml``). + + Returns the number of rules loaded. Existing dynamic rules that are + not present in the file are preserved, because they may have been + injected by teach→rule during this session. + """ + target = path or _DEFAULT_RULES_PATH + try: + loaded = load_rules_from_yaml(target) + except Exception as exc: + logger.warning("reload_rules failed for %s: %s", target, exc, exc_info=True) + return len(self._rules.rules) + + # Preserve dynamic (non-file) rules that are not in the reloaded set. + loaded_names = {r.name for r in loaded} + dynamic = [ + r for r in self._rules.rules + if r.name not in loaded_names + ] + merged = loaded + dynamic + self._rules.set_rules(merged) + logger.info( + "Rules reloaded: %d from file, %d dynamic, %d total", + len(loaded), len(dynamic), len(merged), + ) + return len(merged) diff --git a/src/leapflow/causal/rules.yaml b/src/leapflow/causal/rules.yaml index 866200c..9d24755 100644 --- a/src/leapflow/causal/rules.yaml +++ b/src/leapflow/causal/rules.yaml @@ -75,3 +75,45 @@ rules: child_type: RESPONSE time_delta_max: 0.5 confidence: 0.90 + + # ── hardware.* namespace: physical-domain causal priors ── + + - name: hw_actuate_to_reading_change + parent_channel: hardware.actuate + parent_type: TRIGGER + child_channel: hardware.reading + child_type: RESPONSE + time_delta_max: 5.0 + confidence: 0.85 + + - name: threshold_exceeded_to_estop + parent_channel: hardware.threshold_exceeded + parent_type: BOUNDARY + child_channel: hardware.estop + child_type: EFFECT + time_delta_max: 1.0 + confidence: 0.99 + + - name: hw_configure_to_settled + parent_channel: hardware.configure + parent_type: TRIGGER + child_channel: hardware.settled + child_type: RESPONSE + time_delta_max: 30.0 + confidence: 0.80 + + - name: hw_dispense_to_volume_change + parent_channel: hardware.dispense + parent_type: TRIGGER + child_channel: hardware.volume + child_type: EFFECT + time_delta_max: 10.0 + confidence: 0.90 + + - name: hw_reading_drift_to_recalibrate + parent_channel: hardware.reading_drift + parent_type: BOUNDARY + child_channel: hardware.recalibrate + child_type: EFFECT + time_delta_max: 60.0 + confidence: 0.70 diff --git a/src/leapflow/cli/cli.py b/src/leapflow/cli/cli.py index 86e9572..66c5334 100644 --- a/src/leapflow/cli/cli.py +++ b/src/leapflow/cli/cli.py @@ -285,6 +285,28 @@ def main(argv: list[str] | None = None) -> int: dashboard_parser.add_argument("--bind", default="", help="Override the dashboard bind address") dashboard_parser.add_argument("--no-open", action="store_true", help="Print the URL instead of opening a browser") + # leap hw (hardware inspection and direct intervention) + hw_parser = subparsers.add_parser("hw", help="Inspect hardware and intervene in it directly") + hw_sub = hw_parser.add_subparsers(dest="hw_action") + hw_json = argparse.ArgumentParser(add_help=False) + hw_json.add_argument("--json", action="store_true", help="Emit machine-readable JSON") + hw_sub.add_parser("list", parents=[hw_json], help="List admitted hardware devices") + hw_describe = hw_sub.add_parser("describe", parents=[hw_json], help="Show the full reference for one device") + hw_describe.add_argument("device", help="Device id from `leap hw list`") + hw_read = hw_sub.add_parser("read", parents=[hw_json], help="Read one channel") + hw_read.add_argument("device", help="Device id from `leap hw list`") + hw_read.add_argument("channel", help="Channel id from `leap hw describe`") + hw_status = hw_sub.add_parser("status", parents=[hw_json], help="Show transport health and recent events") + hw_status.add_argument("device", nargs="?", default="", help="Optional device id; omit to roll up every device") + hw_estop = hw_sub.add_parser("estop", parents=[hw_json], help="Emergency-stop a device (no approval required)") + hw_estop.add_argument("device", help="Device id from `leap hw list`") + hw_pause = hw_sub.add_parser("pause", parents=[hw_json], help="Pause daemon sampling for a device") + hw_pause.add_argument("device", help="Device id from `leap hw list`") + hw_resume = hw_sub.add_parser("resume", parents=[hw_json], help="Resume daemon sampling for a device") + hw_resume.add_argument("device", help="Device id from `leap hw list`") + hw_replay = hw_sub.add_parser("replay", parents=[hw_json], help="Replay a raw NDJSON segment through the event detector") + hw_replay.add_argument("segment_path", help="Path to the NDJSON segment file") + # leap config config_parser = subparsers.add_parser("config", help="View and update LeapFlow configuration") config_sub = config_parser.add_subparsers(dest="config_action") @@ -336,7 +358,7 @@ def main(argv: list[str] | None = None) -> int: # ── Pre-parse: detect if first non-flag arg is a known subcommand ── # If not, treat everything non-flag as a chat prompt. - known_commands = {"teach", "run", "skills", "relearn", "host", "daemon", "config", "board"} + known_commands = {"teach", "run", "skills", "relearn", "host", "daemon", "config", "board", "hw"} effective_argv = list(argv) if argv is not None else sys.argv[1:] # Find first non-flag argument, skipping values owned by global options. @@ -436,6 +458,12 @@ def main(argv: list[str] | None = None) -> int: from leapflow.cli.commands.dashboard import cmd_dashboard return cmd_dashboard(args) + # Hardware inspection/intervention: reads run in-process, pause/resume route + # to leapd over RPC. No engine Context is needed either way. + if args.command == "hw": + from leapflow.cli.commands.hardware import cmd_hardware + return cmd_hardware(args) + try: if args.command in {"interactive", "chat"} and _daemon_enabled(args): return asyncio.run(_async_daemon_main(args)) diff --git a/src/leapflow/cli/commands/hardware.py b/src/leapflow/cli/commands/hardware.py new file mode 100644 index 0000000..b00ef16 --- /dev/null +++ b/src/leapflow/cli/commands/hardware.py @@ -0,0 +1,397 @@ +"""`leap hw` — inspect hardware and intervene in it directly (Phase 1.4). + +Two planes share one command group: + +* **Read / estop** (``list``/``describe``/``read``/``status``/``estop``) reuse the + production ``HardwareTools`` handlers against a registry built in this process. + That registry deliberately runs with persistence and streaming *off*: leapd is + the single writer of the session DuckDB reading store (Phase 0.5), so a one-shot + CLI command must never open it for writing, and it must not start a sampling + loop of its own. Live reads still work — they open a transport on demand — while + sampled history stays with leapd. + +* **pause / resume** control the sampling lifecycle, which only ever runs inside + leapd. When a healthy daemon is present the command routes through the + ``hardware.pause`` / ``hardware.resume`` RPCs; without one it fails closed with an + actionable hint rather than pretending to pause a loop that an in-process command + never starts. + +Every subcommand accepts ``--json`` for machine-readable output and returns a +non-zero exit code whenever the structured result reports ``ok`` is false. +""" + +from __future__ import annotations + +import argparse +import asyncio +import json +import logging +import sys +from dataclasses import replace +from typing import Any + +from leapflow.config import load_config +from leapflow.daemon.client import DaemonClient, DaemonUnavailableError + +logger = logging.getLogger(__name__) + +# ── ANSI colors (mirrors the palette used by `leap host`) ──────────────────── + +_RESET = "\033[0m" +_DIM = "\033[2m" +_BOLD = "\033[1m" +_GREEN = "\033[32m" +_RED = "\033[31m" +_YELLOW = "\033[33m" +_CYAN = "\033[1;36m" + +# Subcommands that read the device on demand or halt it, served in-process. +_LOCAL_ACTIONS = frozenset({"list", "describe", "read", "status", "estop"}) +# Subcommands that steer the daemon-owned sampling loop. +_SAMPLING_ACTIONS = frozenset({"pause", "resume"}) +# Subcommands that operate on recorded data, no registry required. +_OFFLINE_ACTIONS = frozenset({"replay"}) + + +def _ok(msg: str) -> None: + print(f" {_GREEN}\u2713{_RESET} {msg}") + + +def _fail(msg: str) -> None: + print(f" {_RED}\u2717{_RESET} {msg}") + + +def _warn(msg: str) -> None: + print(f" {_YELLOW}!{_RESET} {msg}") + + +def _info(msg: str) -> None: + print(f" {_DIM}{msg}{_RESET}") + + +# ── Registry / daemon discovery (module-level so tests can substitute them) ── + + +def _build_local_registry(settings: Any) -> Any: + """Return a loaded registry for in-process reads, or None when hardware is off. + + Persistence and streaming are forced off: this process is not leapd, so it must + neither open the single-writer reading store nor start a sampling loop (Phase + 0.5). On-demand reads and estop do not depend on either. + """ + from leapflow.hardware.registry import HardwareRegistry, HardwareSettings + + policy = HardwareSettings.from_settings(settings) + if not policy.enabled: + return None + policy = replace(policy, persist_readings=False, stream_enabled=False) + registry = HardwareRegistry(policy) + registry.load() + return registry + + +def _discover_daemon(settings: Any) -> Any: + """Return healthy leapd discovery info with a usable socket, else None.""" + from leapflow.daemon.lifecycle import DaemonInfo + + info = DaemonInfo.discover(settings.runtime_dir) + if getattr(info, "is_healthy", False) and getattr(info, "sock_path", None) is not None: + return info + return None + + +# ── Entry point ────────────────────────────────────────────────────────────── + + +def cmd_hardware(args: argparse.Namespace) -> int: + """Route ``leap hw`` subcommands. Synchronous shell around an async worker.""" + action = getattr(args, "hw_action", None) + if action is None: + _print_usage() + return 1 + try: + return asyncio.run(_dispatch(args, action)) + except KeyboardInterrupt: # pragma: no cover - interactive interrupt + sys.stderr.write("\n\033[2m\u2192 Interrupted\033[0m\n") + return 130 + + +async def _dispatch(args: argparse.Namespace, action: str) -> int: + json_mode = bool(getattr(args, "json", False)) + if action in _LOCAL_ACTIONS: + return await _run_local(action, args, json_mode) + if action in _SAMPLING_ACTIONS: + return await _run_sampling_control(action, args, json_mode) + if action in _OFFLINE_ACTIONS: + return _run_offline(action, args, json_mode) + _fail(f"Unknown hw action: {action}") + return 1 + + +# ── Read / estop plane ───────────────────────────────────────────────────── + + +async def _run_local(action: str, args: argparse.Namespace, json_mode: bool) -> int: + settings = load_config() + registry = _build_local_registry(settings) + if registry is None: + return _emit_result( + action, + { + "ok": False, + "code": "hardware_disabled", + "error": ( + "Hardware is disabled for this profile. Enable it " + "(`leap config set hardware.enabled true`) and declare a device, " + "then retry." + ), + }, + json_mode, + ) + + from leapflow.hardware.tools import HardwareTools + + # session_id is intentionally empty: reads need no identity and hw_estop is + # ungated and identity-agnostic, so nothing here should adopt a session that + # belongs to another client. + tools = HardwareTools(registry) + try: + if action == "list": + result = await tools.hw_list() + elif action == "describe": + result = await tools.hw_describe(device_id=str(args.device)) + elif action == "read": + result = await tools.hw_read( + device_id=str(args.device), channel_id=str(args.channel) + ) + elif action == "status": + result = await _collect_status(tools, registry, str(getattr(args, "device", "") or "")) + elif action == "estop": + result = await tools.hw_estop(device_id=str(args.device)) + else: # pragma: no cover - guarded by _dispatch + result = {"ok": False, "code": "unknown_action", "error": action} + finally: + # This process owns the registry it just built, and answering a read claims the + # device -- a camera or microphone included. Nothing else will release it: the + # plugin path registers ``close_all`` on its EffectScope, but this registry has no + # scope, so without this the command could exit leaving a capture process holding + # the device and its indicator light on. ``close_all`` never raises. + await registry.close_all() + return _emit_result(action, result, json_mode) + + +async def _collect_status(tools: Any, registry: Any, device: str) -> dict[str, Any]: + """Return one device's status, or a roll-up across every admitted device.""" + if device: + return await tools.hw_status(device_id=device) + reports = [await tools.hw_status(device_id=c.device_id) for c in registry.contexts()] + return {"ok": True, "devices": reports, "count": len(reports)} + + +# ── Sampling-control plane (pause / resume) ───────────────────────────────── + + +async def _run_sampling_control( + action: str, args: argparse.Namespace, json_mode: bool +) -> int: + settings = load_config() + device = str(args.device) + info = _discover_daemon(settings) + if info is None: + # In-process mode never samples (Phase 0.5), so there is no loop to steer + # here. Fail closed with a concrete next step rather than a silent no-op. + return _emit_result( + action, + { + "ok": False, + "code": "daemon_required", + "device": device, + "error": ( + "Hardware sampling runs only inside leapd: an in-process command " + "never samples, so there is nothing to pause or resume here. Start " + "the daemon with `leap daemon start`, then re-run " + f"`leap hw {action} {device}`." + ), + }, + json_mode, + ) + + client = DaemonClient(info.sock_path) + try: + if action == "pause": + result = await client.hardware_pause(device) + else: + result = await client.hardware_resume(device) + except DaemonUnavailableError as exc: + result = { + "ok": False, + "code": "daemon_error", + "device": device, + "error": f"leapd request failed: {exc}", + } + return _emit_result(action, result, json_mode) + + +# ── Output ───────────────────────────────────────────────────────────────── + + +def _emit_result(action: str, result: dict[str, Any], json_mode: bool) -> int: + if json_mode: + print(json.dumps(result, indent=2, default=str, ensure_ascii=False)) + else: + _render(action, result) + return 0 if result.get("ok") else 1 + + +def _render(action: str, result: dict[str, Any]) -> None: + print(f"{_CYAN}LEAP Hardware \u2014 {action}{_RESET}") + if not result.get("ok"): + _fail(str(result.get("error") or result.get("code") or "command failed")) + return + renderer = { + "list": _render_list, + "describe": _render_describe, + "read": _render_read, + "status": _render_status, + "estop": _render_estop, + "pause": _render_sampling, + "resume": _render_sampling, + "replay": _render_replay, + }.get(action) + if renderer is not None: + renderer(result) + + +def _render_list(result: dict[str, Any]) -> None: + devices = result.get("devices") or [] + if not devices: + _info("No hardware devices admitted.") + return + for dev in devices: + quantities = ", ".join(dev.get("quantities") or []) or "-" + _ok(f"{dev.get('device_id')} \u2014 {dev.get('display_name') or ''}".rstrip()) + _info( + f"channels={dev.get('channels', 0)} writable={dev.get('writable', 0)} " + f"streaming={dev.get('streaming', 0)} verified={dev.get('verified')} " + f"quantities=[{quantities}]" + ) + + +def _render_describe(result: dict[str, Any]) -> None: + _ok(f"{result.get('device_id')} \u2014 {result.get('display_name') or ''}".rstrip()) + _info(f"location={result.get('location')} halt_supported={result.get('halt_supported')}") + _info(f"writable={result.get('writable_channels')} streaming={result.get('streaming_channels')}") + for channel in result.get("channels") or []: + _info( + f" \u2022 {channel.get('channel_id')} " + f"[{channel.get('direction')}] {channel.get('quantity')} " + f"{channel.get('unit') or ''}".rstrip() + ) + + +def _render_read(result: dict[str, Any]) -> None: + reading = result.get("reading") or {} + _ok( + f"{reading.get('channel_id', '')}={reading.get('value')} " + f"{reading.get('unit') or ''}".rstrip() + ) + if result.get("history"): + _info(f"history: {result['history']}") + + +def _render_status(result: dict[str, Any]) -> None: + if "devices" in result: + for report in result.get("devices") or []: + _render_one_status(report) + return + _render_one_status(result) + + +def _render_one_status(report: dict[str, Any]) -> None: + device = report.get("device_id", "") + if not report.get("ok"): + _fail(f"{device}: {report.get('error') or 'status unavailable'}") + return + status = report.get("status") or {} + _ok(f"{device}: connected={status.get('connected')} detail={status.get('detail') or ''}".rstrip()) + for event in report.get("recent_events") or []: + _info(f" \u2022 {event.get('kind')} {event.get('channel_id') or ''} {event.get('detail') or ''}".rstrip()) + + +def _render_estop(result: dict[str, Any]) -> None: + _ok(f"{result.get('device_id')}: halted={result.get('halted')}") + + +def _render_sampling(result: dict[str, Any]) -> None: + device = result.get("device", "") + verb = "paused" if result.get("paused") else "resumed" + channels = result.get("channels") or [] + _ok(f"{device}: {verb} {len(channels)} channel(s) [scope={result.get('scope', 'daemon')}]") + for channel in channels: + _info(f" \u2022 {channel}") + for channel in result.get("failed") or []: + _warn(f" \u2022 {channel} (failed to resume)") + + +def _render_replay(result: dict[str, Any]) -> None: + events = result.get("events") or [] + _ok(f"Replayed {result.get('readings', 0)} readings, produced {len(events)} event(s)") + for event in events: + _info(f" \u2022 {event}") + + +# ── Offline (recorded-data) plane ───────────────────────────────────────── + + +def _run_offline(action: str, args: argparse.Namespace, json_mode: bool) -> int: + if action == "replay": + return _run_replay(args, json_mode) + _fail(f"Unknown offline action: {action}") + return 1 + + +def _run_replay(args: argparse.Namespace, json_mode: bool) -> int: + from pathlib import Path + + from leapflow.hardware.replay import run_replay + + segment_path = Path(str(args.segment_path)) + if not segment_path.exists(): + return _emit_result( + "replay", + { + "ok": False, + "code": "file_not_found", + "error": f"Segment file not found: {segment_path}", + }, + json_mode, + ) + events = run_replay(segment_path) + # Count readings from the file for the summary. + try: + reading_count = sum(1 for line in segment_path.read_text(encoding="utf-8").splitlines() if line.strip()) + except OSError: + reading_count = 0 + result: dict[str, Any] = { + "ok": True, + "segment": str(segment_path), + "readings": reading_count, + "events": [event.to_detail() for event in events], + } + return _emit_result("replay", result, json_mode) + + +def _print_usage() -> None: + print("Usage: leap hw {list|describe|read|status|estop|pause|resume|replay} [--json]") + print() + print("Inspect hardware and intervene in it directly.") + print() + print("Commands:") + print(" list List admitted hardware devices") + print(" describe Show the full reference for one device") + print(" read Read one channel") + print(" status [device] Show transport health and recent events") + print(" estop Emergency-stop a device (no approval required)") + print(" pause Pause daemon sampling for a device") + print(" resume Resume daemon sampling for a device") + print(" replay Replay a raw NDJSON segment through the event detector") diff --git a/src/leapflow/cli/commands/interactive.py b/src/leapflow/cli/commands/interactive.py index 17bc6c5..a137b97 100644 --- a/src/leapflow/cli/commands/interactive.py +++ b/src/leapflow/cli/commands/interactive.py @@ -35,6 +35,22 @@ _WATCH_EXIT_ACTIVE_STATES = frozenset({"armed", "watching", "due", "confirming", "executing"}) +def _board_lens_names() -> tuple[str, ...]: + """Return the installed board lenses, for ``/board `` completion. + + Read from the template library rather than enumerated, because a lens is a YAML + file an operator can add: a hardcoded list would silently stop offering theirs. + Failure is empty, not fatal -- losing a completion must never stop the TUI starting. + """ + try: + from leapflow.dashboard.templates import TemplateLibrary + + return tuple(sorted(TemplateLibrary().names())) + except Exception: + logger.debug("slash completion: board lenses unavailable", exc_info=True) + return () + + def _is_app_command(canonical: str) -> bool: """Return true only for `/app` or `/app ...`, not `/apple`.""" return canonical == "app" or canonical.startswith("app ") @@ -158,11 +174,21 @@ async def _open_dashboard_view(settings: Any, console: Any, payload: dict[str, A return url = launcher.build_view_url( state["bind"], state["port"], state["token"], template=template, + # Forwarded so a drill-down (``/board device`` / ``/board preview``) lands on + # that device's page rather than the fleet: the daemon resolved which device + # was meant, and dropping it here would silently discard that answer. + device=str(payload.get("device") or ""), + channel=str(payload.get("channel") or ""), ) if launcher.open_in_browser(url): console.system(f"Opened dashboard in your browser: {url}") else: console.system(f"Dashboard ready (open manually): {url}") + # Any follow-up the daemon wants the operator to read -- what a preview grant now + # covers, or that a consent prompt is about to appear here. Emitted after the URL so + # the actionable line is the last thing on screen. + for note in payload.get("notes") or (): + console.system(str(note)) if payload.get("watch_id"): console.system( "Observing the current session; analysis streams to the board as it completes." @@ -913,6 +939,7 @@ def _handle_task_control(text: str) -> bool: status=status, commands=completion_entries(), config_fields=tuple(ConfigService(ctx.settings).list_fields()), + board_templates=_board_lens_names(), history_path=ctx.settings.profile_layout.tui_history_path, on_input=handle_input, on_control=_handle_task_control, @@ -1517,6 +1544,7 @@ def _handle_task_control(text: str) -> bool: status=status, commands=completion_entries(), config_fields=tuple(ConfigService(settings).list_fields()), + board_templates=_board_lens_names(), history_path=settings.profile_layout.tui_history_path, on_input=handle_input, on_control=_handle_task_control, diff --git a/src/leapflow/cli/commands/registry.py b/src/leapflow/cli/commands/registry.py index e1528d5..c7e2d02 100644 --- a/src/leapflow/cli/commands/registry.py +++ b/src/leapflow/cli/commands/registry.py @@ -145,7 +145,7 @@ def supports_runtime(self, runtime: CommandRuntime) -> bool: # Board & Monitors (LeapBoard) — one analysis target (current session), # rendered through a selectable template lens. - CommandDef("board", "Analyze the current session; optionally pick a template lens", "Board", args_hint="[