feat(plugins): add runtime handshake boundary
This commit is contained in:
parent
24a47318f2
commit
9d51080d2e
19
README.md
19
README.md
|
|
@ -25,13 +25,15 @@ unchanged.
|
||||||
|
|
||||||
Plugin SDK v0alpha2 now provides executable, vendor-neutral identity, session,
|
Plugin SDK v0alpha2 now provides executable, vendor-neutral identity, session,
|
||||||
operation, runtime-action, stream, evidence and compatibility contracts. Every
|
operation, runtime-action, stream, evidence and compatibility contracts. Every
|
||||||
backend action crosses immutable SDK `RuntimeActionInvocation` and
|
backend runtime must now pass a versioned descriptor/handshake against its
|
||||||
`RuntimeActionResult` validation in the actual dispatcher hot path. The meanings
|
manifest before actions are admitted. Every action then crosses immutable SDK
|
||||||
remain a local experimental vocabulary rather than a mutation of NODE.DC
|
`RuntimeActionInvocation` and `RuntimeActionResult` validation through a
|
||||||
Platform Ontology. The device process is still transitional and in process: it
|
replaceable runtime transport seam. The meanings remain a local experimental
|
||||||
uses an explicitly injected K1 normalizer to produce transport-neutral local
|
vocabulary rather than a mutation of NODE.DC Platform Ontology. The current
|
||||||
consumer views; portable SDK stream envelopes, process isolation, durable
|
transport is deliberately in process: it uses an explicitly injected K1
|
||||||
operations, multi-device routing and the remote Edge split remain later gates.
|
normalizer to produce transport-neutral local consumer views; portable SDK
|
||||||
|
stream envelopes, process isolation, durable operations, multi-device routing
|
||||||
|
and the remote Edge split remain later gates.
|
||||||
|
|
||||||
The current runtime cannot read K1 firmware automatically. It keeps the exact
|
The current runtime cannot read K1 firmware automatically. It keeps the exact
|
||||||
profile inactive until the operator explicitly attests firmware `3.0.2` and
|
profile inactive until the operator explicitly attests firmware `3.0.2` and
|
||||||
|
|
@ -245,6 +247,9 @@ present.
|
||||||
- [Vendor-neutral live observation sources](docs/adr/0006-vendor-neutral-observation-sources-and-live-only-timeline.md)
|
- [Vendor-neutral live observation sources](docs/adr/0006-vendor-neutral-observation-sources-and-live-only-timeline.md)
|
||||||
- [K1 camera preview copy-remux gateway](docs/adr/0007-k1-camera-preview-copy-remux-gateway.md)
|
- [K1 camera preview copy-remux gateway](docs/adr/0007-k1-camera-preview-copy-remux-gateway.md)
|
||||||
- [Durable observation sessions and workspace layout](docs/adr/0008-durable-observation-sessions-and-workspace-layout.md)
|
- [Durable observation sessions and workspace layout](docs/adr/0008-durable-observation-sessions-and-workspace-layout.md)
|
||||||
|
- [Device-plugin observation runtime and K1 extraction](docs/adr/0009-device-plugin-observation-runtime-and-k1-extraction.md)
|
||||||
|
- [Plugin-owned frontend device workflows](docs/adr/0010-plugin-owned-frontend-device-workflows.md)
|
||||||
|
- [Laboratory plugin runtime handshake and transport seam](docs/adr/0011-laboratory-plugin-runtime-handshake-and-transport-seam.md)
|
||||||
- [Redacted live lab report](docs/lab/001_K1_LIVE_MQTT_20260715.redacted.md)
|
- [Redacted live lab report](docs/lab/001_K1_LIVE_MQTT_20260715.redacted.md)
|
||||||
- [Session manifest schema](schemas/session-manifest.schema.json)
|
- [Session manifest schema](schemas/session-manifest.schema.json)
|
||||||
- [Reference input provenance](docs/reference/README.md)
|
- [Reference input provenance](docs/reference/README.md)
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ Each gate produces evidence and an explicit GO, PAUSE or BLOCKED result.
|
||||||
| Stage 6 live viewer | GO — React Control Station, embedded self-hosted Rerun cloud/trajectory and Mac pipeline metrics |
|
| Stage 6 live viewer | GO — React Control Station, embedded self-hosted Rerun cloud/trajectory and Mac pipeline metrics |
|
||||||
| Stage 7 observation archive | GO (point/pose) — durable catalog, recovery, background RRD preparation, saved-session timeline and atomic playback verified |
|
| Stage 7 observation archive | GO (point/pose) — durable catalog, recovery, background RRD preparation, saved-session timeline and atomic playback verified |
|
||||||
| Stage 7 recorded cameras | GO (contract), acceptance pending — acquisition-owned fMP4 archive and player are implemented/tested; no real archived K1 camera session exists yet |
|
| Stage 7 recorded cameras | GO (contract), acceptance pending — acquisition-owned fMP4 archive and player are implemented/tested; no real archived K1 camera session exists yet |
|
||||||
|
| Plugin isolation | GO (laboratory control plane) — vendor backend/frontend are plugin-owned; manifest/runtime descriptor parity, versioned handshake, lifecycle health and transport correlation fail closed while execution remains in-process |
|
||||||
| Stage 8 product storage | PAUSE — retention, replication, encryption, capacity monitoring and long-run browser/WASM stress remain deployment gates |
|
| Stage 8 product storage | PAUSE — retention, replication, encryption, capacity monitoring and long-run browser/WASM stress remain deployment gates |
|
||||||
|
|
||||||
USB project copying remains optional ground truth rather than a blocker for the
|
USB project copying remains optional ground truth rather than a blocker for the
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,10 @@
|
||||||
|
|
||||||
This document maps the current repository to the target Mission Core
|
This document maps the current repository to the target Mission Core
|
||||||
architecture without claiming that deferred package and process boundaries
|
architecture without claiming that deferred package and process boundaries
|
||||||
already exist. The accepted device-lifecycle decision is recorded in
|
already exist. The device-lifecycle decision is recorded in
|
||||||
[`ADR 0004`](adr/0004-plugin-sdk-v0alpha2-and-experimental-device-lifecycle.md).
|
[`ADR 0004`](adr/0004-plugin-sdk-v0alpha2-and-experimental-device-lifecycle.md),
|
||||||
|
and the bounded laboratory runtime seam in
|
||||||
|
[`ADR 0011`](adr/0011-laboratory-plugin-runtime-handshake-and-transport-seam.md).
|
||||||
|
|
||||||
## Current layout
|
## Current layout
|
||||||
|
|
||||||
|
|
@ -14,7 +16,7 @@ already exist. The accepted device-lifecycle decision is recorded in
|
||||||
| `packages/plugin-sdk/` | Installable v0alpha2 Pydantic contracts and JSON Schema export | Portable host/plugin identity, lifecycle, stream and evidence boundary |
|
| `packages/plugin-sdk/` | Installable v0alpha2 Pydantic contracts and JSON Schema export | Portable host/plugin identity, lifecycle, stream and evidence boundary |
|
||||||
| `plugins/xgrids-k1/` | v1alpha2 manifest, exact profile/loader and plugin-owned React connection/acquisition UI | Independently versioned XGRIDS device plugin |
|
| `plugins/xgrids-k1/` | v1alpha2 manifest, exact profile/loader and plugin-owned React connection/acquisition UI | Independently versioned XGRIDS device plugin |
|
||||||
| `plugins/xgrids-k1/frontend/` | XGRIDS provisioning, acquisition/replay, diagnostics, metrics, runtime mapping and scoped CSS | Plugin-owned reviewed frontend contribution |
|
| `plugins/xgrids-k1/frontend/` | XGRIDS provisioning, acquisition/replay, diagnostics, metrics, runtime mapping and scoped CSS | Plugin-owned reviewed frontend contribution |
|
||||||
| `src/k1link/web/` | Local FastAPI host, plugin composition, in-memory operation/acquisition lifecycle and generic session API | Generic host APIs plus isolated plugin supervisor |
|
| `src/k1link/web/` | Local FastAPI host, fail-closed runtime handshake/transport seam, in-memory operation/acquisition lifecycle and generic session API | Generic host APIs plus isolated plugin supervisor |
|
||||||
| `src/k1link/device_plugins/xgrids_k1/` | Physically isolated K1 BLE/MQTT/protobuf/LZ4/camera/replay/CLI compatibility implementation | Independently built XGRIDS device plugin process |
|
| `src/k1link/device_plugins/xgrids_k1/` | Physically isolated K1 BLE/MQTT/protobuf/LZ4/camera/replay/CLI compatibility implementation | Independently built XGRIDS device plugin process |
|
||||||
| `src/k1link/data_plane/` | Transport-neutral decoded in-process consumer views | Local projections hydrated from portable SDK envelopes |
|
| `src/k1link/data_plane/` | Transport-neutral decoded in-process consumer views | Local projections hydrated from portable SDK envelopes |
|
||||||
| `src/k1link/viewer/` | Vendor-neutral Rerun consumer, metrics and recorded blueprint | Replaceable canonical scene sink and presentation adapters |
|
| `src/k1link/viewer/` | Vendor-neutral Rerun consumer, metrics and recorded blueprint | Replaceable canonical scene sink and presentation adapters |
|
||||||
|
|
@ -57,10 +59,14 @@ The Plugin SDK v0alpha2 separately defines model/device/session identity,
|
||||||
operation events, acquisition-related session state, canonical stream
|
operation events, acquisition-related session state, canonical stream
|
||||||
envelopes, payload handles, evidence lineage and compatibility assessments. It
|
envelopes, payload handles, evidence lineage and compatibility assessments. It
|
||||||
is importable through the root editable path dependency and independently
|
is importable through the root editable path dependency and independently
|
||||||
buildable from `packages/plugin-sdk`. Backend actions now instantiate immutable
|
buildable from `packages/plugin-sdk`. A runtime-owned descriptor must match the
|
||||||
SDK `RuntimeActionInvocation` and `RuntimeActionResult` envelopes in the real
|
manifest ID, version, host API and exact action set, then complete the
|
||||||
dispatcher path. The live spatial path does not yet instantiate portable SDK
|
`plugin-runtime/v0alpha1` handshake before its lifecycle health becomes ready.
|
||||||
stream envelopes or use the SDK `EvidenceStore` protocol.
|
Backend actions instantiate immutable SDK `RuntimeActionInvocation` and
|
||||||
|
`RuntimeActionResult` envelopes through the replaceable transport seam, and the
|
||||||
|
host rejects uncorrelated results. The current transport is still in-process.
|
||||||
|
The live spatial path does not yet instantiate portable SDK stream envelopes or
|
||||||
|
use the SDK `EvidenceStore` protocol.
|
||||||
|
|
||||||
Frontend device workflows follow the same ownership rule. The generic host
|
Frontend device workflows follow the same ownership rule. The generic host
|
||||||
selects a manifest model and mounts its `device.connection` component. Concrete
|
selects a manifest model and mounts its `device.connection` component. Concrete
|
||||||
|
|
@ -149,11 +155,14 @@ for the measured gate and remaining limits.
|
||||||
## Remaining extraction order
|
## Remaining extraction order
|
||||||
|
|
||||||
Native replay parity, exact-profile physical point/pose regression, immutable
|
Native replay parity, exact-profile physical point/pose regression, immutable
|
||||||
SDK runtime-action envelopes and physical extraction of K1 transports/codecs are
|
SDK runtime-action envelopes, fail-closed runtime handshake and physical
|
||||||
complete. Portable SDK stream/evidence envelopes remain separate from the
|
extraction of K1 transports/codecs are complete. Portable SDK stream/evidence
|
||||||
allocation-conscious in-process preview views.
|
envelopes remain separate from the allocation-conscious in-process preview
|
||||||
|
views.
|
||||||
|
|
||||||
1. Add process isolation and a durable operation/evidence supervisor.
|
1. When a real deployment or second execution target requires it, implement a
|
||||||
|
subprocess transport behind the current seam, then add heartbeat timeout,
|
||||||
|
restart policy and resource limits as separately accepted behavior.
|
||||||
2. Replace compatibility routes and singleton state with multi-device session
|
2. Replace compatibility routes and singleton state with multi-device session
|
||||||
routing.
|
routing.
|
||||||
3. Split Edge execution from the Control Station behind authenticated transport.
|
3. Split Edge execution from the Control Station behind authenticated transport.
|
||||||
|
|
@ -163,6 +172,13 @@ allocation-conscious in-process preview views.
|
||||||
plane; keep the modeling-command publisher disabled until its separate safety
|
plane; keep the modeling-command publisher disabled until its separate safety
|
||||||
gate closes.
|
gate closes.
|
||||||
|
|
||||||
|
Complex equipment will likely be assembled from separately useful component
|
||||||
|
plugins into configured hardware packs, while standalone equipment remains
|
||||||
|
available for engineering work. That direction is intentionally not a current
|
||||||
|
domain schema: pack ownership, roles, compatibility and lifecycle must be
|
||||||
|
derived from real additional component families rather than inferred from the
|
||||||
|
single K1 vertical.
|
||||||
|
|
||||||
## Invariants
|
## Invariants
|
||||||
|
|
||||||
- Raw bytes are appended and OS-flushed before preview work; aligned raw and
|
- Raw bytes are appended and OS-flushed before preview work; aligned raw and
|
||||||
|
|
@ -208,6 +224,7 @@ From the repository root:
|
||||||
uv sync --frozen --group dev
|
uv sync --frozen --group dev
|
||||||
uv run pytest \
|
uv run pytest \
|
||||||
tests/test_plugin_sdk_v0alpha2_contracts.py \
|
tests/test_plugin_sdk_v0alpha2_contracts.py \
|
||||||
|
tests/test_plugin_runtime.py \
|
||||||
tests/test_plugin_catalog.py \
|
tests/test_plugin_catalog.py \
|
||||||
tests/test_xgrids_compatibility_profile.py \
|
tests/test_xgrids_compatibility_profile.py \
|
||||||
tests/test_device_lifecycle.py \
|
tests/test_device_lifecycle.py \
|
||||||
|
|
|
||||||
|
|
@ -26,11 +26,12 @@ navigation, workspaces, and generic runtime code do not change. Runtime discover
|
||||||
of arbitrary JavaScript is deliberately not supported.
|
of arbitrary JavaScript is deliberately not supported.
|
||||||
|
|
||||||
Backend factories are loaded only from validated, repository-local manifests.
|
Backend factories are loaded only from validated, repository-local manifests.
|
||||||
Startup fails unless every catalog manifest produces exactly one adapter with
|
Startup fails unless every catalog manifest produces exactly one runtime with
|
||||||
the same plugin ID and action set. The catalog is then frozen for the life of
|
the same plugin ID, version, supported host API and action set. ADR 0011 adds a
|
||||||
the process, so a backend-catalog model cannot appear without an executable
|
versioned descriptor/handshake before the runtime becomes ready. The catalog is
|
||||||
runtime. Frontend availability remains the separate reviewed static-composition
|
then frozen for the life of the process, so a backend-catalog model cannot
|
||||||
step described below.
|
appear without an executable runtime. Frontend availability remains the
|
||||||
|
separate reviewed static-composition step described below.
|
||||||
|
|
||||||
The local-device UX is:
|
The local-device UX is:
|
||||||
|
|
||||||
|
|
@ -76,20 +77,23 @@ firmware-specific phases remain inside the XGRIDS plugin adapter.
|
||||||
`GET /api/v1/device-models`.
|
`GET /api/v1/device-models`.
|
||||||
- Host-owned action dispatcher:
|
- Host-owned action dispatcher:
|
||||||
`POST /api/v1/device-plugins/{pluginId}/actions/{actionId}`.
|
`POST /api/v1/device-plugins/{pluginId}/actions/{actionId}`.
|
||||||
|
- Runtime lifecycle health:
|
||||||
|
`GET /api/v1/device-plugin-runtimes`.
|
||||||
- Plugin-scoped event stream:
|
- Plugin-scoped event stream:
|
||||||
`WS /api/v1/device-plugins/{pluginId}/events`, with plugin ID and sequence.
|
`WS /api/v1/device-plugins/{pluginId}/events`, with plugin ID and sequence.
|
||||||
- Manifest factory composition:
|
- Manifest factory composition:
|
||||||
`src/k1link/web/device_plugin_composition.py`.
|
`src/k1link/web/device_plugin_composition.py`.
|
||||||
- Existing K1 action endpoints are compatibility shims over that dispatcher, so
|
- Existing K1 action endpoints are compatibility shims over the same admitted
|
||||||
the proven physical implementation is unchanged.
|
runtime transport, so the proven physical implementation is unchanged.
|
||||||
|
|
||||||
The XGRIDS frontend code is physically plugin-owned and statically linked into
|
The XGRIDS frontend code is physically plugin-owned and statically linked into
|
||||||
the Control Station during this v1alpha milestone. Its provisioning,
|
the Control Station during this v1alpha milestone. Its provisioning,
|
||||||
acquisition/replay and diagnostics blocks are separate components, and it may
|
acquisition/replay and diagnostics blocks are separate components, and it may
|
||||||
import the host only through `@mission-core/plugin-sdk`. It is not yet a
|
import the host only through `@mission-core/plugin-sdk`. It is not yet a
|
||||||
separately built or signed npm package. Backend composition is manifest-driven;
|
separately built or signed npm package. Backend composition is manifest-driven
|
||||||
frontend/backend installed-set parity is a reviewed build-time responsibility
|
and performs its control-plane handshake; frontend/backend installed-set parity
|
||||||
until the host gains signed plugin bundles and a startup compatibility handshake.
|
is still a reviewed build-time responsibility until the host gains signed
|
||||||
|
plugin bundles and frontend loading policy.
|
||||||
|
|
||||||
## Required dependency rules
|
## Required dependency rules
|
||||||
|
|
||||||
|
|
@ -102,7 +106,7 @@ until the host gains signed plugin bundles and a startup compatibility handshake
|
||||||
- Passwords and other secrets cannot enter manifests, runtime snapshots, event
|
- Passwords and other secrets cannot enter manifests, runtime snapshots, event
|
||||||
payloads, or browser persistence.
|
payloads, or browser persistence.
|
||||||
- Invalid or duplicate plugin/model IDs fail registry construction.
|
- Invalid or duplicate plugin/model IDs fail registry construction.
|
||||||
- Manifest/runtime ID or action drift fails backend startup.
|
- Manifest/runtime ID, version, host API or action drift fails backend startup.
|
||||||
- Every v1alpha1 plugin declares exactly one model and a non-mutating,
|
- Every v1alpha1 plugin declares exactly one model and a non-mutating,
|
||||||
secret-free `state.read` action.
|
secret-free `state.read` action.
|
||||||
- A plugin must stop or cancel active work before model selection can change.
|
- A plugin must stop or cancel active work before model selection can change.
|
||||||
|
|
|
||||||
|
|
@ -217,6 +217,11 @@ SDK v0alpha2 runtime-action contracts are active in the backend hot path. This
|
||||||
does not imply that frontend state objects, live spatial frames or stored raw
|
does not imply that frontend state objects, live spatial frames or stored raw
|
||||||
evidence already use every SDK contract.
|
evidence already use every SDK contract.
|
||||||
|
|
||||||
|
ADR 0011 later adds runtime-owned descriptor, handshake and lifecycle-health
|
||||||
|
contracts plus a transport seam around this action path. Its current
|
||||||
|
implementation remains in-process and does not revise the device/acquisition
|
||||||
|
semantics accepted here.
|
||||||
|
|
||||||
## Deliberately deferred
|
## Deliberately deferred
|
||||||
|
|
||||||
The following are not complete and must not be inferred from this ADR:
|
The following are not complete and must not be inferred from this ADR:
|
||||||
|
|
|
||||||
|
|
@ -87,6 +87,11 @@ operator workflows remain compatible. The `k1link` distribution name and CLI
|
||||||
command remain transitional compatibility names; their implementation is now
|
command remain transitional compatibility names; their implementation is now
|
||||||
plugin-owned.
|
plugin-owned.
|
||||||
|
|
||||||
|
ADR 0011 subsequently places the action control plane behind a versioned
|
||||||
|
descriptor/handshake/health transport seam. Observation discovery and export
|
||||||
|
remain host-called plugin contributions and are not yet portable process
|
||||||
|
transport contracts.
|
||||||
|
|
||||||
This ADR does not claim process isolation, crash containment, a durable operation
|
This ADR does not claim process isolation, crash containment, a durable operation
|
||||||
journal, signed plugin bundles, multi-device concurrency, portable SDK stream
|
journal, signed plugin bundles, multi-device concurrency, portable SDK stream
|
||||||
envelopes or SDK `EvidenceStore` persistence. Those require a supervisor and a
|
envelopes or SDK `EvidenceStore` persistence. Those require a supervisor and a
|
||||||
|
|
|
||||||
|
|
@ -81,6 +81,7 @@ contribution and one reviewed composition import, not edits to `App`,
|
||||||
|
|
||||||
The remaining isolation gap is deployment-level: frontend contributions are
|
The remaining isolation gap is deployment-level: frontend contributions are
|
||||||
still compiled into one signed application build, and backend plugins still run
|
still compiled into one signed application build, and backend plugins still run
|
||||||
inside the control-plane process. Signed bundles, compatibility handshake,
|
inside the control-plane process. ADR 0011 adds a fail-closed backend runtime
|
||||||
dynamic loading policy and process/restart containment belong to the supervisor
|
descriptor/handshake and replaceable control-plane transport seam, but not a
|
||||||
milestone.
|
subprocess. Signed bundles, frontend loading/realm policy and process/restart
|
||||||
|
containment remain later deployment milestones.
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,103 @@
|
||||||
|
# ADR 0011: laboratory plugin runtime handshake and transport seam
|
||||||
|
|
||||||
|
- Status: accepted
|
||||||
|
- Date: 2026-07-17
|
||||||
|
- Extends: ADR 0003, ADR 0004, ADR 0009 and ADR 0010
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Backend and frontend vendor code are physically plugin-owned, but the backend
|
||||||
|
dispatcher still received a raw in-process action adapter. Manifest parity was
|
||||||
|
checked through Python attributes rather than a runtime-owned wire contract.
|
||||||
|
Replacing that adapter with a later subprocess transport would therefore have
|
||||||
|
changed the dispatcher and compatibility routes as well as composition.
|
||||||
|
|
||||||
|
Mission Core is still a development-source laboratory sandbox. The final Edge,
|
||||||
|
Control Station and equipment-composition architecture is not known. Complex
|
||||||
|
hardware packs will probably combine separately useful components, but there is
|
||||||
|
not yet enough evidence to define pack roles, lifecycle or compatibility
|
||||||
|
matrices. The next step must preserve the proven plugin boundary without
|
||||||
|
pretending that production supervision is already designed.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
The executable control plane gains one minimal, versioned runtime protocol:
|
||||||
|
|
||||||
|
```text
|
||||||
|
missioncore.nodedc/plugin-runtime/v0alpha1
|
||||||
|
```
|
||||||
|
|
||||||
|
Plugin SDK v0alpha2 defines closed immutable contracts for:
|
||||||
|
|
||||||
|
- `RuntimePluginDescriptor` — plugin ID/version, supported host APIs and the
|
||||||
|
exact action set owned by the runtime;
|
||||||
|
- `RuntimeHandshakeRequest` and `RuntimeHandshakeResult` — an activation
|
||||||
|
challenge correlated by handshake ID and runtime instance ID;
|
||||||
|
- `RuntimeHealthSnapshot` — lifecycle health only: `starting`, `ready` or
|
||||||
|
`stopped`;
|
||||||
|
- the existing `RuntimeActionInvocation` and `RuntimeActionResult` envelopes.
|
||||||
|
|
||||||
|
The generic host depends on `DevicePluginRuntimeTransport`, whose control-plane
|
||||||
|
surface is `handshake`, `health`, `invoke` and `close`. A plugin factory returns
|
||||||
|
this transport inside `DevicePluginRuntimeContribution`; it no longer exposes a
|
||||||
|
raw action adapter to composition or the dispatcher.
|
||||||
|
|
||||||
|
Before admitting a plugin, composition validates all of the following:
|
||||||
|
|
||||||
|
1. manifest ID equals runtime descriptor ID;
|
||||||
|
2. manifest version equals runtime descriptor version;
|
||||||
|
3. the manifest API is explicitly supported by the runtime;
|
||||||
|
4. manifest and runtime action sets are exactly equal;
|
||||||
|
5. handshake response correlates to the request and descriptor;
|
||||||
|
6. runtime lifecycle health is `ready`.
|
||||||
|
|
||||||
|
Any failure closes the runtime and aborts composition. The dispatcher addresses
|
||||||
|
only admitted transports and rejects an action result whose invocation, plugin
|
||||||
|
or action correlation differs from the request.
|
||||||
|
|
||||||
|
`InProcessDevicePluginRuntime` implements the protocol for the current lab. It
|
||||||
|
wraps the XGRIDS action adapter, performs no serialization and provides no crash
|
||||||
|
boundary. The deprecated XGRIDS action routes now use the same admitted
|
||||||
|
transport instead of invoking the adapter directly. The plugin-local camera
|
||||||
|
gateway remains a same-process data-plane route; portable stream/media IPC is a
|
||||||
|
separate future gate.
|
||||||
|
|
||||||
|
`GET /api/v1/device-plugin-runtimes` exposes the small lifecycle snapshots. The
|
||||||
|
main health response reports ready/total runtime counts. These values do not
|
||||||
|
claim device connectivity, sensor health, process containment or durable
|
||||||
|
supervision.
|
||||||
|
|
||||||
|
## Acceptance
|
||||||
|
|
||||||
|
The automated gate proves that:
|
||||||
|
|
||||||
|
- two unrelated synthetic plugin runtimes complete independent handshakes and
|
||||||
|
dispatch through the unchanged generic host;
|
||||||
|
- actions are rejected before successful activation;
|
||||||
|
- an unsupported host API fails closed without changing runtime health to
|
||||||
|
ready;
|
||||||
|
- uncorrelated action results are rejected;
|
||||||
|
- close is idempotent and lifecycle health becomes stopped;
|
||||||
|
- SDK handshake contracts remain closed, immutable and exported as JSON Schema.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
The generic action path now has a transport-replaceable seam. A later
|
||||||
|
subprocess implementation can satisfy the same host protocol while moving
|
||||||
|
serialization, heartbeat, restart and resource policy behind the transport.
|
||||||
|
This is useful architectural order even while the product topology is still
|
||||||
|
being discovered.
|
||||||
|
|
||||||
|
This ADR does **not** implement or promise:
|
||||||
|
|
||||||
|
- a subprocess supervisor, crash containment, heartbeat timeout or restart;
|
||||||
|
- CPU, memory, device-permission or filesystem limits;
|
||||||
|
- dynamic plugin discovery, quarantine or hot reload;
|
||||||
|
- bundle signing, frontend realm isolation, installers or updates;
|
||||||
|
- durable operation recovery or portable stream/EvidenceStore transport;
|
||||||
|
- multi-device routing or hardware-pack composition semantics.
|
||||||
|
|
||||||
|
The plugin remains the smallest canonical vendor-isolation unit. Whether a
|
||||||
|
future hardware pack is a host-owned composition of component plugins, a
|
||||||
|
deployable profile or another domain object is intentionally deferred until
|
||||||
|
real additional equipment makes that choice testable.
|
||||||
|
|
@ -36,8 +36,9 @@ The separate SDK v0alpha2 package establishes executable contracts for:
|
||||||
- independently revisioned enrollment, connectivity, and acquisition states;
|
- independently revisioned enrollment, connectivity, and acquisition states;
|
||||||
- operation policy, request, acknowledgement, progress, completion, failure,
|
- operation policy, request, acknowledgement, progress, completion, failure,
|
||||||
timeout, cancellation, secret reference, and idempotency boundaries;
|
timeout, cancellation, secret reference, and idempotency boundaries;
|
||||||
- immutable pre-session runtime-action invocation and result envelopes used by
|
- immutable runtime descriptor, activation handshake, lifecycle health,
|
||||||
the active backend dispatcher;
|
action-invocation and action-result envelopes used by the active backend
|
||||||
|
dispatcher;
|
||||||
- canonical point cloud, pose, image, encoded video, and device-status streams;
|
- canonical point cloud, pose, image, encoded video, and device-status streams;
|
||||||
- immutable evidence handles, raw transport records, lineage, and store
|
- immutable evidence handles, raw transport records, lineage, and store
|
||||||
protocol;
|
protocol;
|
||||||
|
|
@ -57,18 +58,21 @@ Current host implementation references:
|
||||||
that frontend surface;
|
that frontend surface;
|
||||||
- `apps/control-station/src/core/runtime/` — normalized runtime envelope;
|
- `apps/control-station/src/core/runtime/` — normalized runtime envelope;
|
||||||
- `src/k1link/web/plugin_catalog.py` — strict backend manifest validation;
|
- `src/k1link/web/plugin_catalog.py` — strict backend manifest validation;
|
||||||
- `src/k1link/web/plugin_runtime.py` — host-owned allowlisted SDK-envelope action dispatcher;
|
- `src/k1link/web/plugin_runtime.py` — transport protocol, laboratory in-process
|
||||||
|
implementation and host-owned SDK-envelope action dispatcher;
|
||||||
- `src/k1link/web/device_plugin_composition.py` — manifest factory loader and
|
- `src/k1link/web/device_plugin_composition.py` — manifest factory loader and
|
||||||
startup parity checks;
|
fail-closed descriptor/handshake checks;
|
||||||
- `docs/adr/0003-device-plugin-ui-and-runtime-boundary.md` — accepted boundary
|
- `docs/adr/0011-laboratory-plugin-runtime-handshake-and-transport-seam.md` —
|
||||||
and extraction sequence.
|
accepted control-plane seam and its explicit laboratory limits.
|
||||||
|
|
||||||
The existing lifecycle is fail-closed: inactive provider shells perform no
|
The existing lifecycle is fail-closed: a backend runtime stays `starting` until
|
||||||
I/O, events are scoped by plugin ID, and selection cannot change until the
|
its ID, version, host API and exact action set pass the handshake; inactive
|
||||||
active plugin confirms teardown. Process isolation remains a later host
|
provider shells perform no I/O; events are scoped by plugin ID; and selection
|
||||||
milestone. v0alpha2 models secret references and operation policy, but host
|
cannot change until the active plugin confirms teardown. `ready` health means
|
||||||
authorization and the actual secret vault remain separate implementation
|
only that this control-plane contract is admitted. Process isolation remains a
|
||||||
responsibilities.
|
later host milestone. v0alpha2 models secret references and operation policy,
|
||||||
|
but host authorization and the actual secret vault remain separate
|
||||||
|
implementation responsibilities.
|
||||||
|
|
||||||
The React surface is not yet an independently published npm package. Its alias
|
The React surface is not yet an independently published npm package. Its alias
|
||||||
is intentionally narrow so plugin source cannot import Control Station
|
is intentionally narrow so plugin source cannot import Control Station
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
|
||||||
|
|
@ -47,7 +47,15 @@ from .operations import (
|
||||||
validate_operation_request,
|
validate_operation_request,
|
||||||
)
|
)
|
||||||
from .payloads import InlinePayload, PayloadHandle, ReferencedPayload
|
from .payloads import InlinePayload, PayloadHandle, ReferencedPayload
|
||||||
from .runtime import RuntimeActionInvocation, RuntimeActionResult
|
from .runtime import (
|
||||||
|
RUNTIME_PROTOCOL_VERSION,
|
||||||
|
RuntimeActionInvocation,
|
||||||
|
RuntimeActionResult,
|
||||||
|
RuntimeHandshakeRequest,
|
||||||
|
RuntimeHandshakeResult,
|
||||||
|
RuntimeHealthSnapshot,
|
||||||
|
RuntimePluginDescriptor,
|
||||||
|
)
|
||||||
from .schema import contract_json_schemas
|
from .schema import contract_json_schemas
|
||||||
from .session import (
|
from .session import (
|
||||||
AcquisitionState,
|
AcquisitionState,
|
||||||
|
|
@ -121,8 +129,13 @@ __all__ = [
|
||||||
"Quaternion",
|
"Quaternion",
|
||||||
"RawTransportRecord",
|
"RawTransportRecord",
|
||||||
"RedactionState",
|
"RedactionState",
|
||||||
|
"RUNTIME_PROTOCOL_VERSION",
|
||||||
"RuntimeActionInvocation",
|
"RuntimeActionInvocation",
|
||||||
"RuntimeActionResult",
|
"RuntimeActionResult",
|
||||||
|
"RuntimeHandshakeRequest",
|
||||||
|
"RuntimeHandshakeResult",
|
||||||
|
"RuntimeHealthSnapshot",
|
||||||
|
"RuntimePluginDescriptor",
|
||||||
"ReferencedPayload",
|
"ReferencedPayload",
|
||||||
"RuleOutcome",
|
"RuleOutcome",
|
||||||
"SecretReference",
|
"SecretReference",
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,92 @@
|
||||||
"""Minimal pre-session action envelopes for the executable plugin runtime."""
|
"""Portable control-plane envelopes for an executable plugin runtime."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
|
||||||
from pydantic import AwareDatetime, Field
|
from pydantic import AwareDatetime, Field, model_validator
|
||||||
|
|
||||||
from .common import API_VERSION, ApiVersion, ContractModel, Identifier, JsonObject
|
from .common import (
|
||||||
|
API_VERSION,
|
||||||
|
ApiVersion,
|
||||||
|
ContractModel,
|
||||||
|
Identifier,
|
||||||
|
JsonObject,
|
||||||
|
ShortText,
|
||||||
|
)
|
||||||
|
|
||||||
|
RuntimeProtocolVersion = Literal["missioncore.nodedc/plugin-runtime/v0alpha1"]
|
||||||
|
RUNTIME_PROTOCOL_VERSION: RuntimeProtocolVersion = (
|
||||||
|
"missioncore.nodedc/plugin-runtime/v0alpha1"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class RuntimePluginDescriptor(ContractModel):
|
||||||
|
"""Plugin-owned declaration used before the host admits any action."""
|
||||||
|
|
||||||
|
api_version: ApiVersion = API_VERSION
|
||||||
|
kind: Literal["RuntimePluginDescriptor"] = "RuntimePluginDescriptor"
|
||||||
|
runtime_protocol_version: RuntimeProtocolVersion = RUNTIME_PROTOCOL_VERSION
|
||||||
|
plugin_id: Identifier
|
||||||
|
plugin_version: ShortText
|
||||||
|
supported_host_api_versions: tuple[Identifier, ...] = Field(min_length=1)
|
||||||
|
action_ids: tuple[Identifier, ...] = Field(min_length=1)
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def declarations_are_unique(self) -> RuntimePluginDescriptor:
|
||||||
|
if len(self.supported_host_api_versions) != len(
|
||||||
|
set(self.supported_host_api_versions)
|
||||||
|
):
|
||||||
|
raise ValueError("supported host API versions must be unique")
|
||||||
|
if len(self.action_ids) != len(set(self.action_ids)):
|
||||||
|
raise ValueError("runtime action ids must be unique")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class RuntimeHandshakeRequest(ContractModel):
|
||||||
|
"""Host compatibility challenge sent before a runtime becomes ready."""
|
||||||
|
|
||||||
|
api_version: ApiVersion = API_VERSION
|
||||||
|
kind: Literal["RuntimeHandshakeRequest"] = "RuntimeHandshakeRequest"
|
||||||
|
runtime_protocol_version: RuntimeProtocolVersion = RUNTIME_PROTOCOL_VERSION
|
||||||
|
handshake_id: Identifier
|
||||||
|
plugin_id: Identifier
|
||||||
|
plugin_version: ShortText
|
||||||
|
host_api_version: Identifier
|
||||||
|
requested_at: AwareDatetime
|
||||||
|
required_action_ids: tuple[Identifier, ...] = Field(min_length=1)
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def required_actions_are_unique(self) -> RuntimeHandshakeRequest:
|
||||||
|
if len(self.required_action_ids) != len(set(self.required_action_ids)):
|
||||||
|
raise ValueError("required runtime action ids must be unique")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class RuntimeHandshakeResult(ContractModel):
|
||||||
|
"""Runtime response proving identity, protocol and admitted host API."""
|
||||||
|
|
||||||
|
api_version: ApiVersion = API_VERSION
|
||||||
|
kind: Literal["RuntimeHandshakeResult"] = "RuntimeHandshakeResult"
|
||||||
|
handshake_id: Identifier
|
||||||
|
runtime_instance_id: Identifier
|
||||||
|
accepted_host_api_version: Identifier
|
||||||
|
descriptor: RuntimePluginDescriptor
|
||||||
|
ready_at: AwareDatetime
|
||||||
|
|
||||||
|
|
||||||
|
class RuntimeHealthSnapshot(ContractModel):
|
||||||
|
"""Small transport-neutral liveness snapshot for laboratory supervision."""
|
||||||
|
|
||||||
|
api_version: ApiVersion = API_VERSION
|
||||||
|
kind: Literal["RuntimeHealthSnapshot"] = "RuntimeHealthSnapshot"
|
||||||
|
runtime_instance_id: Identifier
|
||||||
|
plugin_id: Identifier
|
||||||
|
plugin_version: ShortText
|
||||||
|
runtime_protocol_version: RuntimeProtocolVersion = RUNTIME_PROTOCOL_VERSION
|
||||||
|
status: Literal["starting", "ready", "stopped"]
|
||||||
|
observed_at: AwareDatetime
|
||||||
|
detail_code: Identifier | None = None
|
||||||
|
|
||||||
|
|
||||||
class RuntimeActionInvocation(ContractModel):
|
class RuntimeActionInvocation(ContractModel):
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,14 @@ from .compatibility import CompatibilityAssessment
|
||||||
from .evidence import EvidenceRecord, RawTransportRecord
|
from .evidence import EvidenceRecord, RawTransportRecord
|
||||||
from .identity import DeviceInstanceRef
|
from .identity import DeviceInstanceRef
|
||||||
from .operations import OperationEvent, OperationPolicy, OperationRequest
|
from .operations import OperationEvent, OperationPolicy, OperationRequest
|
||||||
from .runtime import RuntimeActionInvocation, RuntimeActionResult
|
from .runtime import (
|
||||||
|
RuntimeActionInvocation,
|
||||||
|
RuntimeActionResult,
|
||||||
|
RuntimeHandshakeRequest,
|
||||||
|
RuntimeHandshakeResult,
|
||||||
|
RuntimeHealthSnapshot,
|
||||||
|
RuntimePluginDescriptor,
|
||||||
|
)
|
||||||
from .session import DeviceSessionContext, DeviceSessionSnapshot
|
from .session import DeviceSessionContext, DeviceSessionSnapshot
|
||||||
from .streams import CanonicalStreamEnvelope
|
from .streams import CanonicalStreamEnvelope
|
||||||
|
|
||||||
|
|
@ -25,6 +32,10 @@ def contract_json_schemas() -> dict[str, dict[str, object]]:
|
||||||
"OperationEvent": OperationEvent,
|
"OperationEvent": OperationEvent,
|
||||||
"RuntimeActionInvocation": RuntimeActionInvocation,
|
"RuntimeActionInvocation": RuntimeActionInvocation,
|
||||||
"RuntimeActionResult": RuntimeActionResult,
|
"RuntimeActionResult": RuntimeActionResult,
|
||||||
|
"RuntimePluginDescriptor": RuntimePluginDescriptor,
|
||||||
|
"RuntimeHandshakeRequest": RuntimeHandshakeRequest,
|
||||||
|
"RuntimeHandshakeResult": RuntimeHandshakeResult,
|
||||||
|
"RuntimeHealthSnapshot": RuntimeHealthSnapshot,
|
||||||
"CanonicalStreamEnvelope": CanonicalStreamEnvelope,
|
"CanonicalStreamEnvelope": CanonicalStreamEnvelope,
|
||||||
"EvidenceRecord": EvidenceRecord,
|
"EvidenceRecord": EvidenceRecord,
|
||||||
"RawTransportRecord": RawTransportRecord,
|
"RawTransportRecord": RawTransportRecord,
|
||||||
|
|
|
||||||
|
|
@ -50,10 +50,12 @@ other vendor state. The frontend contribution imports the host only through
|
||||||
`@mission-core/plugin-sdk`; its CSS is scoped below `.xgrids-k1-plugin`.
|
`@mission-core/plugin-sdk`; its CSS is scoped below `.xgrids-k1-plugin`.
|
||||||
|
|
||||||
Backend startup loads the reviewed factory declared by `backendEntrypoint`, then
|
Backend startup loads the reviewed factory declared by `backendEntrypoint`, then
|
||||||
cross-checks its plugin ID and complete action set against this manifest.
|
cross-checks the runtime-owned plugin ID, version, supported host API and exact
|
||||||
|
action set against this manifest. The runtime must complete the versioned
|
||||||
|
control-plane handshake and report `ready` before any action is admitted.
|
||||||
Actions enter through the host dispatcher and the transitional facade
|
Actions enter through the host dispatcher and the transitional facade
|
||||||
`src/k1link/device_plugins/xgrids_k1/facade.py`. The old flat routes live in the plugin's
|
`src/k1link/device_plugins/xgrids_k1/facade.py`. The old flat routes live in the plugin's
|
||||||
legacy router; both paths delegate to the same proven
|
legacy router; both paths now cross the same runtime transport and delegate to the proven
|
||||||
`XgridsK1CompatibilityService` methods. Synchronous capture/runtime operations
|
`XgridsK1CompatibilityService` methods. Synchronous capture/runtime operations
|
||||||
run outside the FastAPI event loop.
|
run outside the FastAPI event loop.
|
||||||
|
|
||||||
|
|
@ -62,8 +64,9 @@ semantic `acquisition.stop` in `capture-only` mode; replay and pre-v1alpha2
|
||||||
sessions retain the legacy `stream.stop` shim. Neither path claims that the
|
sessions retain the legacy `stream.stop` shim. Neither path claims that the
|
||||||
physical K1 stopped without separate operator evidence. BLE, MQTT, codec,
|
physical K1 stopped without separate operator evidence. BLE, MQTT, codec,
|
||||||
archive discovery and RRD export modules now live under the plugin package
|
archive discovery and RRD export modules now live under the plugin package
|
||||||
after replay parity and physical K1 regression. Process isolation and signed
|
after replay parity and physical K1 regression. The current transport remains
|
||||||
deployment remain later supervisor gates.
|
in-process and its health is lifecycle-only; process isolation, crash/restart
|
||||||
|
containment and signed deployment remain later supervisor gates.
|
||||||
|
|
||||||
The compatibility profile is descriptive and cannot itself authorize a vendor
|
The compatibility profile is descriptive and cannot itself authorize a vendor
|
||||||
write. The current runtime cannot inspect K1 firmware: it keeps the profile
|
write. The current runtime cannot inspect K1 firmware: it keeps the profile
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
"""Mission Core runtime with the transitional XGRIDS K1 compatibility adapter."""
|
"""Mission Core control, observation, and device-plugin runtime."""
|
||||||
|
|
||||||
__version__ = "0.1.0"
|
__version__ = "0.1.0"
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,10 @@ from pathlib import Path
|
||||||
from typing import Any, Literal, Protocol, cast
|
from typing import Any, Literal, Protocol, cast
|
||||||
|
|
||||||
from bleak.exc import BleakError
|
from bleak.exc import BleakError
|
||||||
from missioncore_plugin_sdk.v0alpha2 import RuntimeActionInvocation
|
from missioncore_plugin_sdk.v0alpha2 import (
|
||||||
|
RuntimeActionInvocation,
|
||||||
|
RuntimePluginDescriptor,
|
||||||
|
)
|
||||||
from pydantic import BaseModel, ConfigDict, Field, SecretStr, ValidationError
|
from pydantic import BaseModel, ConfigDict, Field, SecretStr, ValidationError
|
||||||
|
|
||||||
from k1link.artifacts import write_json_atomic
|
from k1link.artifacts import write_json_atomic
|
||||||
|
|
@ -50,11 +53,13 @@ from k1link.web.device_lifecycle import (
|
||||||
)
|
)
|
||||||
from k1link.web.plugin_runtime import (
|
from k1link.web.plugin_runtime import (
|
||||||
DevicePluginRuntimeContribution,
|
DevicePluginRuntimeContribution,
|
||||||
|
InProcessDevicePluginRuntime,
|
||||||
PluginActionNotFoundError,
|
PluginActionNotFoundError,
|
||||||
PluginExecutionError,
|
PluginExecutionError,
|
||||||
)
|
)
|
||||||
|
|
||||||
XGRIDS_K1_PLUGIN_ID = "nodedc.device.xgrids-lixelkity-k1"
|
XGRIDS_K1_PLUGIN_ID = "nodedc.device.xgrids-lixelkity-k1"
|
||||||
|
XGRIDS_K1_PLUGIN_VERSION = "0.3.0"
|
||||||
XGRIDS_K1_MODEL_ID = "xgrids.lixelkity-k1"
|
XGRIDS_K1_MODEL_ID = "xgrids.lixelkity-k1"
|
||||||
XGRIDS_K1_COMPATIBILITY_PROFILE_ID = "xgrids.lixelkity-k1.fw-3.0.2.direct-lan.v1"
|
XGRIDS_K1_COMPATIBILITY_PROFILE_ID = "xgrids.lixelkity-k1.fw-3.0.2.direct-lan.v1"
|
||||||
|
|
||||||
|
|
@ -1821,15 +1826,24 @@ def build_xgrids_k1_plugin(repository_root: Path) -> DevicePluginRuntimeContribu
|
||||||
_validate_installed_compatibility_profile(repository_root)
|
_validate_installed_compatibility_profile(repository_root)
|
||||||
service = XgridsK1CompatibilityService(repository_root)
|
service = XgridsK1CompatibilityService(repository_root)
|
||||||
adapter = XgridsK1PluginFacade(service)
|
adapter = XgridsK1PluginFacade(service)
|
||||||
|
runtime = InProcessDevicePluginRuntime(
|
||||||
|
adapter,
|
||||||
|
RuntimePluginDescriptor(
|
||||||
|
plugin_id=XGRIDS_K1_PLUGIN_ID,
|
||||||
|
plugin_version=XGRIDS_K1_PLUGIN_VERSION,
|
||||||
|
supported_host_api_versions=("missioncore.nodedc/v1alpha2",),
|
||||||
|
action_ids=tuple(sorted(adapter.action_ids)),
|
||||||
|
),
|
||||||
|
close=service.close,
|
||||||
|
)
|
||||||
return DevicePluginRuntimeContribution(
|
return DevicePluginRuntimeContribution(
|
||||||
adapter=adapter,
|
runtime=runtime,
|
||||||
legacy_routers=(
|
legacy_routers=(
|
||||||
build_xgrids_k1_legacy_router(adapter),
|
build_xgrids_k1_legacy_router(runtime),
|
||||||
build_xgrids_k1_camera_router(
|
build_xgrids_k1_camera_router(
|
||||||
service.camera_preview,
|
service.camera_preview,
|
||||||
XGRIDS_K1_PLUGIN_ID,
|
XGRIDS_K1_PLUGIN_ID,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
observation=build_xgrids_k1_observation(repository_root),
|
observation=build_xgrids_k1_observation(repository_root),
|
||||||
close=service.close,
|
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -18,25 +18,28 @@ from k1link.device_plugins.xgrids_k1.facade import (
|
||||||
LiveRequest,
|
LiveRequest,
|
||||||
ReplayRequest,
|
ReplayRequest,
|
||||||
ViewerSettingsRequest,
|
ViewerSettingsRequest,
|
||||||
XgridsK1PluginFacade,
|
|
||||||
)
|
)
|
||||||
from k1link.web.plugin_runtime import PluginExecutionError, invoke_device_plugin_adapter
|
from k1link.web.plugin_runtime import (
|
||||||
|
DevicePluginRuntimeTransport,
|
||||||
|
PluginExecutionError,
|
||||||
|
invoke_device_plugin_runtime,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def build_xgrids_k1_legacy_router(adapter: XgridsK1PluginFacade) -> APIRouter:
|
def build_xgrids_k1_legacy_router(runtime: DevicePluginRuntimeTransport) -> APIRouter:
|
||||||
"""Temporary flat API kept for scripts created before the plugin boundary."""
|
"""Temporary flat API kept for scripts created before the plugin boundary."""
|
||||||
|
|
||||||
router = APIRouter(include_in_schema=True)
|
router = APIRouter(include_in_schema=True)
|
||||||
|
|
||||||
@router.get("/api/state", deprecated=True)
|
@router.get("/api/state", deprecated=True)
|
||||||
async def get_state() -> dict[str, Any]:
|
async def get_state() -> dict[str, Any]:
|
||||||
return await invoke_device_plugin_adapter(adapter, ACTION_STATE_READ, {})
|
return await invoke_device_plugin_runtime(runtime, ACTION_STATE_READ, {})
|
||||||
|
|
||||||
@router.post("/api/ble/scan", deprecated=True)
|
@router.post("/api/ble/scan", deprecated=True)
|
||||||
async def scan_ble(request: BleScanRequest) -> dict[str, Any]:
|
async def scan_ble(request: BleScanRequest) -> dict[str, Any]:
|
||||||
try:
|
try:
|
||||||
return await invoke_device_plugin_adapter(
|
return await invoke_device_plugin_runtime(
|
||||||
adapter,
|
runtime,
|
||||||
ACTION_DISCOVERY_SCAN,
|
ACTION_DISCOVERY_SCAN,
|
||||||
request.model_dump(),
|
request.model_dump(),
|
||||||
)
|
)
|
||||||
|
|
@ -46,8 +49,8 @@ def build_xgrids_k1_legacy_router(adapter: XgridsK1PluginFacade) -> APIRouter:
|
||||||
@router.post("/api/connect", deprecated=True)
|
@router.post("/api/connect", deprecated=True)
|
||||||
async def connect(request: ConnectRequest) -> dict[str, Any]:
|
async def connect(request: ConnectRequest) -> dict[str, Any]:
|
||||||
try:
|
try:
|
||||||
return await invoke_device_plugin_adapter(
|
return await invoke_device_plugin_runtime(
|
||||||
adapter,
|
runtime,
|
||||||
ACTION_NETWORK_PROVISION,
|
ACTION_NETWORK_PROVISION,
|
||||||
request.model_dump(),
|
request.model_dump(),
|
||||||
)
|
)
|
||||||
|
|
@ -60,8 +63,8 @@ def build_xgrids_k1_legacy_router(adapter: XgridsK1PluginFacade) -> APIRouter:
|
||||||
@router.post("/api/session/live", deprecated=True)
|
@router.post("/api/session/live", deprecated=True)
|
||||||
async def start_live(request: LiveRequest) -> dict[str, Any]:
|
async def start_live(request: LiveRequest) -> dict[str, Any]:
|
||||||
try:
|
try:
|
||||||
return await invoke_device_plugin_adapter(
|
return await invoke_device_plugin_runtime(
|
||||||
adapter,
|
runtime,
|
||||||
ACTION_STREAM_START_LIVE,
|
ACTION_STREAM_START_LIVE,
|
||||||
request.model_dump(),
|
request.model_dump(),
|
||||||
)
|
)
|
||||||
|
|
@ -71,8 +74,8 @@ def build_xgrids_k1_legacy_router(adapter: XgridsK1PluginFacade) -> APIRouter:
|
||||||
@router.post("/api/session/replay", deprecated=True)
|
@router.post("/api/session/replay", deprecated=True)
|
||||||
async def start_replay(request: ReplayRequest) -> dict[str, Any]:
|
async def start_replay(request: ReplayRequest) -> dict[str, Any]:
|
||||||
try:
|
try:
|
||||||
return await invoke_device_plugin_adapter(
|
return await invoke_device_plugin_runtime(
|
||||||
adapter,
|
runtime,
|
||||||
ACTION_STREAM_START_REPLAY,
|
ACTION_STREAM_START_REPLAY,
|
||||||
request.model_dump(),
|
request.model_dump(),
|
||||||
)
|
)
|
||||||
|
|
@ -81,12 +84,12 @@ def build_xgrids_k1_legacy_router(adapter: XgridsK1PluginFacade) -> APIRouter:
|
||||||
|
|
||||||
@router.post("/api/session/stop", deprecated=True)
|
@router.post("/api/session/stop", deprecated=True)
|
||||||
async def stop_session() -> dict[str, Any]:
|
async def stop_session() -> dict[str, Any]:
|
||||||
return await invoke_device_plugin_adapter(adapter, ACTION_STREAM_STOP, {})
|
return await invoke_device_plugin_runtime(runtime, ACTION_STREAM_STOP, {})
|
||||||
|
|
||||||
@router.post("/api/viewer/settings", deprecated=True)
|
@router.post("/api/viewer/settings", deprecated=True)
|
||||||
async def update_viewer_settings(request: ViewerSettingsRequest) -> dict[str, Any]:
|
async def update_viewer_settings(request: ViewerSettingsRequest) -> dict[str, Any]:
|
||||||
return await invoke_device_plugin_adapter(
|
return await invoke_device_plugin_runtime(
|
||||||
adapter,
|
runtime,
|
||||||
ACTION_VIEWER_SETTINGS_UPDATE,
|
ACTION_VIEWER_SETTINGS_UPDATE,
|
||||||
request.model_dump(),
|
request.model_dump(),
|
||||||
)
|
)
|
||||||
|
|
@ -98,8 +101,8 @@ def build_xgrids_k1_legacy_router(adapter: XgridsK1PluginFacade) -> APIRouter:
|
||||||
while True:
|
while True:
|
||||||
await websocket.send_json(
|
await websocket.send_json(
|
||||||
{
|
{
|
||||||
"state": await invoke_device_plugin_adapter(
|
"state": await invoke_device_plugin_runtime(
|
||||||
adapter,
|
runtime,
|
||||||
ACTION_STATE_READ,
|
ACTION_STATE_READ,
|
||||||
{},
|
{},
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ from k1link.web.plugin_runtime import (
|
||||||
PluginActionNotFoundError,
|
PluginActionNotFoundError,
|
||||||
PluginExecutionError,
|
PluginExecutionError,
|
||||||
PluginNotFoundError,
|
PluginNotFoundError,
|
||||||
|
PluginRuntimeUnavailableError,
|
||||||
)
|
)
|
||||||
from k1link.web.session_api import build_session_router
|
from k1link.web.session_api import build_session_router
|
||||||
|
|
||||||
|
|
@ -175,11 +176,17 @@ async def request_validation_error_handler(
|
||||||
|
|
||||||
@app.get("/api/health")
|
@app.get("/api/health")
|
||||||
def health() -> dict[str, Any]:
|
def health() -> dict[str, Any]:
|
||||||
|
runtime_health = plugin_environment.runtime_health
|
||||||
|
runtimes_ready = all(item["status"] == "ready" for item in runtime_health)
|
||||||
return {
|
return {
|
||||||
"ok": True,
|
"ok": runtimes_ready,
|
||||||
"status": "ok",
|
"status": "ok" if runtimes_ready else "degraded",
|
||||||
"service": "mission-core-control-plane",
|
"service": "mission-core-control-plane",
|
||||||
"version": __version__,
|
"version": __version__,
|
||||||
|
"plugin_runtimes": {
|
||||||
|
"ready": sum(item["status"] == "ready" for item in runtime_health),
|
||||||
|
"total": len(runtime_health),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -199,6 +206,11 @@ def get_device_models() -> dict[str, Any]:
|
||||||
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/v1/device-plugin-runtimes")
|
||||||
|
def get_device_plugin_runtimes() -> dict[str, Any]:
|
||||||
|
return {"items": list(plugin_environment.runtime_health)}
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/v1/device-plugins/{plugin_id}/actions/{action_id}")
|
@app.post("/api/v1/device-plugins/{plugin_id}/actions/{action_id}")
|
||||||
async def invoke_device_plugin_action(
|
async def invoke_device_plugin_action(
|
||||||
plugin_id: str,
|
plugin_id: str,
|
||||||
|
|
@ -216,6 +228,8 @@ async def invoke_device_plugin_action(
|
||||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
except PluginExecutionError as exc:
|
except PluginExecutionError as exc:
|
||||||
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
||||||
|
except PluginRuntimeUnavailableError as exc:
|
||||||
|
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
@app.websocket("/api/v1/device-plugins/{plugin_id}/events")
|
@app.websocket("/api/v1/device-plugins/{plugin_id}/events")
|
||||||
|
|
@ -232,7 +246,7 @@ async def device_plugin_events(websocket: WebSocket, plugin_id: str) -> None:
|
||||||
return
|
return
|
||||||
except (PluginNotFoundError, PluginActionNotFoundError):
|
except (PluginNotFoundError, PluginActionNotFoundError):
|
||||||
await websocket.close(code=1008, reason="Device plugin is not available")
|
await websocket.close(code=1008, reason="Device plugin is not available")
|
||||||
except PluginExecutionError:
|
except (PluginExecutionError, PluginRuntimeUnavailableError):
|
||||||
await websocket.close(code=1011, reason="Device plugin state stream failed")
|
await websocket.close(code=1011, reason="Device plugin state stream failed")
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,14 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from datetime import UTC, datetime
|
||||||
from importlib import import_module
|
from importlib import import_module
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
|
from missioncore_plugin_sdk.v0alpha2 import RuntimeHandshakeRequest
|
||||||
|
|
||||||
from k1link.sessions.plugin_contract import ObservationArchiveSource, RecordingExporter
|
from k1link.sessions.plugin_contract import ObservationArchiveSource, RecordingExporter
|
||||||
from k1link.web.plugin_catalog import DevicePluginCatalog, DevicePluginManifest
|
from k1link.web.plugin_catalog import DevicePluginCatalog, DevicePluginManifest
|
||||||
|
|
@ -38,11 +41,20 @@ class InstalledDevicePluginEnvironment:
|
||||||
@property
|
@property
|
||||||
def recording_exporters(self) -> dict[str, RecordingExporter]:
|
def recording_exporters(self) -> dict[str, RecordingExporter]:
|
||||||
return {
|
return {
|
||||||
contribution.adapter.plugin_id: contribution.observation.recording_exporter
|
contribution.runtime.descriptor.plugin_id: (
|
||||||
|
contribution.observation.recording_exporter
|
||||||
|
)
|
||||||
for contribution in self._contributions
|
for contribution in self._contributions
|
||||||
if contribution.observation is not None
|
if contribution.observation is not None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def runtime_health(self) -> tuple[dict[str, Any], ...]:
|
||||||
|
return tuple(
|
||||||
|
contribution.runtime.health().model_dump(mode="json")
|
||||||
|
for contribution in self._contributions
|
||||||
|
)
|
||||||
|
|
||||||
def close(self) -> None:
|
def close(self) -> None:
|
||||||
cleanup_errors = _close_contributions(self._contributions)
|
cleanup_errors = _close_contributions(self._contributions)
|
||||||
if cleanup_errors:
|
if cleanup_errors:
|
||||||
|
|
@ -60,7 +72,7 @@ def _close_contributions(
|
||||||
errors: list[Exception] = []
|
errors: list[Exception] = []
|
||||||
for contribution in reversed(contributions):
|
for contribution in reversed(contributions):
|
||||||
try:
|
try:
|
||||||
contribution.close()
|
contribution.runtime.close()
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
errors.append(exc)
|
errors.append(exc)
|
||||||
return errors
|
return errors
|
||||||
|
|
@ -110,42 +122,83 @@ def _load_contribution(
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
adapter = contribution.adapter
|
runtime = contribution.runtime
|
||||||
if adapter.plugin_id != manifest.metadata.id:
|
descriptor = runtime.descriptor
|
||||||
|
if descriptor.plugin_id != manifest.metadata.id:
|
||||||
raise DevicePluginCompositionError(
|
raise DevicePluginCompositionError(
|
||||||
"Device-plugin manifest/runtime id mismatch: "
|
"Device-plugin manifest/runtime id mismatch: "
|
||||||
f"{manifest.metadata.id} != {adapter.plugin_id}"
|
f"{manifest.metadata.id} != {descriptor.plugin_id}"
|
||||||
|
)
|
||||||
|
if descriptor.plugin_version != manifest.metadata.version:
|
||||||
|
raise DevicePluginCompositionError(
|
||||||
|
"Device-plugin manifest/runtime version mismatch: "
|
||||||
|
f"{manifest.metadata.version} != {descriptor.plugin_version}"
|
||||||
|
)
|
||||||
|
if manifest.apiVersion not in descriptor.supported_host_api_versions:
|
||||||
|
raise DevicePluginCompositionError(
|
||||||
|
f"Device-plugin runtime does not support {manifest.apiVersion}"
|
||||||
)
|
)
|
||||||
manifest_actions = frozenset(action.id for action in manifest.spec.actions)
|
manifest_actions = frozenset(action.id for action in manifest.spec.actions)
|
||||||
if adapter.action_ids != manifest_actions:
|
if frozenset(descriptor.action_ids) != manifest_actions:
|
||||||
raise DevicePluginCompositionError(
|
raise DevicePluginCompositionError(
|
||||||
f"Device-plugin manifest/runtime actions mismatch for {adapter.plugin_id}"
|
"Device-plugin manifest/runtime actions mismatch for "
|
||||||
|
f"{descriptor.plugin_id}"
|
||||||
)
|
)
|
||||||
observation = contribution.observation
|
observation = contribution.observation
|
||||||
if observation is not None:
|
if observation is not None:
|
||||||
if not observation.archives:
|
if not observation.archives:
|
||||||
raise DevicePluginCompositionError(
|
raise DevicePluginCompositionError(
|
||||||
f"Device-plugin observation contribution is empty for {adapter.plugin_id}"
|
"Device-plugin observation contribution is empty for "
|
||||||
|
f"{descriptor.plugin_id}"
|
||||||
)
|
)
|
||||||
archive_ids: set[str] = set()
|
archive_ids: set[str] = set()
|
||||||
archive_roots: set[Path] = set()
|
archive_roots: set[Path] = set()
|
||||||
for archive in observation.archives:
|
for archive in observation.archives:
|
||||||
if archive.plugin_id != adapter.plugin_id:
|
if archive.plugin_id != descriptor.plugin_id:
|
||||||
raise DevicePluginCompositionError(
|
raise DevicePluginCompositionError(
|
||||||
"Device-plugin observation/runtime id mismatch: "
|
"Device-plugin observation/runtime id mismatch: "
|
||||||
f"{archive.plugin_id} != {adapter.plugin_id}"
|
f"{archive.plugin_id} != {descriptor.plugin_id}"
|
||||||
)
|
)
|
||||||
if archive.archive_id in archive_ids:
|
if archive.archive_id in archive_ids:
|
||||||
raise DevicePluginCompositionError(
|
raise DevicePluginCompositionError(
|
||||||
f"Duplicate observation archive id for {adapter.plugin_id}"
|
f"Duplicate observation archive id for {descriptor.plugin_id}"
|
||||||
)
|
)
|
||||||
resolved_root = archive.root.expanduser().resolve()
|
resolved_root = archive.root.expanduser().resolve()
|
||||||
if resolved_root in archive_roots:
|
if resolved_root in archive_roots:
|
||||||
raise DevicePluginCompositionError(
|
raise DevicePluginCompositionError(
|
||||||
f"Duplicate observation archive root for {adapter.plugin_id}"
|
f"Duplicate observation archive root for {descriptor.plugin_id}"
|
||||||
)
|
)
|
||||||
archive_ids.add(archive.archive_id)
|
archive_ids.add(archive.archive_id)
|
||||||
archive_roots.add(resolved_root)
|
archive_roots.add(resolved_root)
|
||||||
|
handshake_request = RuntimeHandshakeRequest(
|
||||||
|
handshake_id=uuid4().hex,
|
||||||
|
plugin_id=manifest.metadata.id,
|
||||||
|
plugin_version=manifest.metadata.version,
|
||||||
|
host_api_version=manifest.apiVersion,
|
||||||
|
requested_at=datetime.now(UTC),
|
||||||
|
required_action_ids=tuple(sorted(manifest_actions)),
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
handshake = runtime.handshake(handshake_request)
|
||||||
|
runtime_health = runtime.health()
|
||||||
|
except Exception as exc:
|
||||||
|
raise DevicePluginCompositionError(
|
||||||
|
f"Device-plugin runtime handshake failed for {descriptor.plugin_id}"
|
||||||
|
) from exc
|
||||||
|
if (
|
||||||
|
handshake.handshake_id != handshake_request.handshake_id
|
||||||
|
or handshake.descriptor != descriptor
|
||||||
|
or handshake.accepted_host_api_version != manifest.apiVersion
|
||||||
|
or runtime_health.runtime_instance_id != handshake.runtime_instance_id
|
||||||
|
or runtime_health.plugin_id != descriptor.plugin_id
|
||||||
|
or runtime_health.plugin_version != descriptor.plugin_version
|
||||||
|
or runtime_health.runtime_protocol_version
|
||||||
|
!= descriptor.runtime_protocol_version
|
||||||
|
or runtime_health.status != "ready"
|
||||||
|
):
|
||||||
|
raise DevicePluginCompositionError(
|
||||||
|
f"Device-plugin runtime handshake is inconsistent for {descriptor.plugin_id}"
|
||||||
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
_add_cleanup_notes(exc, _close_contributions((contribution,)))
|
_add_cleanup_notes(exc, _close_contributions((contribution,)))
|
||||||
raise
|
raise
|
||||||
|
|
@ -155,7 +208,7 @@ def _load_contribution(
|
||||||
def load_installed_device_plugins(
|
def load_installed_device_plugins(
|
||||||
repository_root: Path,
|
repository_root: Path,
|
||||||
) -> InstalledDevicePluginEnvironment:
|
) -> InstalledDevicePluginEnvironment:
|
||||||
"""Load only local, validated manifest entrypoints and cross-check every adapter."""
|
"""Load local manifests and admit only compatible plugin runtimes."""
|
||||||
|
|
||||||
catalog = DevicePluginCatalog(repository_root)
|
catalog = DevicePluginCatalog(repository_root)
|
||||||
manifests = catalog.manifests()
|
manifests = catalog.manifests()
|
||||||
|
|
@ -167,7 +220,9 @@ def load_installed_device_plugins(
|
||||||
_add_cleanup_notes(exc, _close_contributions(loaded))
|
_add_cleanup_notes(exc, _close_contributions(loaded))
|
||||||
raise
|
raise
|
||||||
contributions = tuple(loaded)
|
contributions = tuple(loaded)
|
||||||
dispatcher = DevicePluginDispatcher([contribution.adapter for contribution in contributions])
|
dispatcher = DevicePluginDispatcher(
|
||||||
|
[contribution.runtime for contribution in contributions]
|
||||||
|
)
|
||||||
catalog_ids = {manifest.metadata.id for manifest in manifests}
|
catalog_ids = {manifest.metadata.id for manifest in manifests}
|
||||||
if set(dispatcher.action_declarations) != catalog_ids:
|
if set(dispatcher.action_declarations) != catalog_ids:
|
||||||
raise DevicePluginCompositionError(
|
raise DevicePluginCompositionError(
|
||||||
|
|
|
||||||
|
|
@ -3,11 +3,19 @@ from __future__ import annotations
|
||||||
from collections.abc import Callable, Mapping
|
from collections.abc import Callable, Mapping
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from typing import Any, Protocol
|
from threading import RLock
|
||||||
|
from typing import Any, Literal, Protocol
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
from missioncore_plugin_sdk.v0alpha2 import RuntimeActionInvocation, RuntimeActionResult
|
from missioncore_plugin_sdk.v0alpha2 import (
|
||||||
|
RuntimeActionInvocation,
|
||||||
|
RuntimeActionResult,
|
||||||
|
RuntimeHandshakeRequest,
|
||||||
|
RuntimeHandshakeResult,
|
||||||
|
RuntimeHealthSnapshot,
|
||||||
|
RuntimePluginDescriptor,
|
||||||
|
)
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
from k1link.sessions.plugin_contract import ObservationRuntimeContribution
|
from k1link.sessions.plugin_contract import ObservationRuntimeContribution
|
||||||
|
|
@ -32,14 +40,18 @@ def _noop() -> None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
class DevicePluginRuntimeTransport(Protocol):
|
||||||
class DevicePluginRuntimeContribution:
|
"""Replaceable control-plane seam between Core and one plugin runtime."""
|
||||||
"""One reviewed backend plugin contribution loaded from its manifest factory."""
|
|
||||||
|
|
||||||
adapter: DevicePluginActionAdapter
|
descriptor: RuntimePluginDescriptor
|
||||||
legacy_routers: tuple[APIRouter, ...] = ()
|
|
||||||
observation: ObservationRuntimeContribution | None = None
|
def handshake(self, request: RuntimeHandshakeRequest) -> RuntimeHandshakeResult: ...
|
||||||
close: Callable[[], None] = _noop
|
|
||||||
|
def health(self) -> RuntimeHealthSnapshot: ...
|
||||||
|
|
||||||
|
async def invoke(self, invocation: RuntimeActionInvocation) -> RuntimeActionResult: ...
|
||||||
|
|
||||||
|
def close(self) -> None: ...
|
||||||
|
|
||||||
|
|
||||||
class PluginNotFoundError(LookupError):
|
class PluginNotFoundError(LookupError):
|
||||||
|
|
@ -54,19 +66,150 @@ class PluginExecutionError(RuntimeError):
|
||||||
"""A validated plugin action failed while talking to its device/runtime."""
|
"""A validated plugin action failed while talking to its device/runtime."""
|
||||||
|
|
||||||
|
|
||||||
|
class PluginRuntimeCompatibilityError(RuntimeError):
|
||||||
|
"""A plugin runtime cannot satisfy the reviewed manifest/host contract."""
|
||||||
|
|
||||||
|
|
||||||
|
class PluginRuntimeUnavailableError(RuntimeError):
|
||||||
|
"""A plugin runtime has not completed activation or has already stopped."""
|
||||||
|
|
||||||
|
|
||||||
|
class InProcessDevicePluginRuntime:
|
||||||
|
"""Laboratory transport preserving the future process boundary in-process."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
adapter: DevicePluginActionAdapter,
|
||||||
|
descriptor: RuntimePluginDescriptor,
|
||||||
|
*,
|
||||||
|
close: Callable[[], None] = _noop,
|
||||||
|
) -> None:
|
||||||
|
if adapter.plugin_id != descriptor.plugin_id:
|
||||||
|
raise PluginRuntimeCompatibilityError(
|
||||||
|
"In-process adapter/runtime descriptor plugin id mismatch"
|
||||||
|
)
|
||||||
|
if adapter.action_ids != frozenset(descriptor.action_ids):
|
||||||
|
raise PluginRuntimeCompatibilityError(
|
||||||
|
"In-process adapter/runtime descriptor actions mismatch"
|
||||||
|
)
|
||||||
|
self._adapter = adapter
|
||||||
|
self.descriptor = descriptor
|
||||||
|
self._close = close
|
||||||
|
self._runtime_instance_id = f"{descriptor.plugin_id}:{uuid4().hex}"
|
||||||
|
self._status: Literal["starting", "ready", "stopped"] = "starting"
|
||||||
|
self._lock = RLock()
|
||||||
|
|
||||||
|
def handshake(self, request: RuntimeHandshakeRequest) -> RuntimeHandshakeResult:
|
||||||
|
with self._lock:
|
||||||
|
if self._status == "stopped":
|
||||||
|
raise PluginRuntimeUnavailableError(
|
||||||
|
f"Device plugin runtime is stopped: {self.descriptor.plugin_id}"
|
||||||
|
)
|
||||||
|
if request.plugin_id != self.descriptor.plugin_id:
|
||||||
|
raise PluginRuntimeCompatibilityError(
|
||||||
|
"Runtime handshake plugin id does not match the descriptor"
|
||||||
|
)
|
||||||
|
if request.plugin_version != self.descriptor.plugin_version:
|
||||||
|
raise PluginRuntimeCompatibilityError(
|
||||||
|
"Runtime handshake plugin version does not match the descriptor"
|
||||||
|
)
|
||||||
|
if request.runtime_protocol_version != self.descriptor.runtime_protocol_version:
|
||||||
|
raise PluginRuntimeCompatibilityError(
|
||||||
|
"Runtime handshake protocol version is not supported"
|
||||||
|
)
|
||||||
|
if request.host_api_version not in self.descriptor.supported_host_api_versions:
|
||||||
|
raise PluginRuntimeCompatibilityError(
|
||||||
|
"Runtime does not support the manifest host API version"
|
||||||
|
)
|
||||||
|
if frozenset(request.required_action_ids) != frozenset(
|
||||||
|
self.descriptor.action_ids
|
||||||
|
):
|
||||||
|
raise PluginRuntimeCompatibilityError(
|
||||||
|
"Runtime handshake actions do not match the manifest"
|
||||||
|
)
|
||||||
|
self._status = "ready"
|
||||||
|
return RuntimeHandshakeResult(
|
||||||
|
handshake_id=request.handshake_id,
|
||||||
|
runtime_instance_id=self._runtime_instance_id,
|
||||||
|
accepted_host_api_version=request.host_api_version,
|
||||||
|
descriptor=self.descriptor,
|
||||||
|
ready_at=datetime.now(UTC),
|
||||||
|
)
|
||||||
|
|
||||||
|
def health(self) -> RuntimeHealthSnapshot:
|
||||||
|
with self._lock:
|
||||||
|
status = self._status
|
||||||
|
return RuntimeHealthSnapshot(
|
||||||
|
runtime_instance_id=self._runtime_instance_id,
|
||||||
|
plugin_id=self.descriptor.plugin_id,
|
||||||
|
plugin_version=self.descriptor.plugin_version,
|
||||||
|
runtime_protocol_version=self.descriptor.runtime_protocol_version,
|
||||||
|
status=status,
|
||||||
|
observed_at=datetime.now(UTC),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def invoke(self, invocation: RuntimeActionInvocation) -> RuntimeActionResult:
|
||||||
|
with self._lock:
|
||||||
|
if self._status != "ready":
|
||||||
|
raise PluginRuntimeUnavailableError(
|
||||||
|
f"Device plugin runtime is not ready: {self.descriptor.plugin_id}"
|
||||||
|
)
|
||||||
|
if invocation.plugin_id != self.descriptor.plugin_id:
|
||||||
|
raise PluginActionNotFoundError("Runtime invocation targets another plugin")
|
||||||
|
if invocation.action_id not in self.descriptor.action_ids:
|
||||||
|
raise PluginActionNotFoundError(
|
||||||
|
f"Device plugin {self.descriptor.plugin_id} does not declare action "
|
||||||
|
f"{invocation.action_id}"
|
||||||
|
)
|
||||||
|
output = await self._adapter.invoke(invocation)
|
||||||
|
return RuntimeActionResult(
|
||||||
|
invocation_id=invocation.invocation_id,
|
||||||
|
plugin_id=invocation.plugin_id,
|
||||||
|
action_id=invocation.action_id,
|
||||||
|
completed_at=datetime.now(UTC),
|
||||||
|
output=dict(output),
|
||||||
|
)
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
with self._lock:
|
||||||
|
if self._status == "stopped":
|
||||||
|
return
|
||||||
|
self._status = "stopped"
|
||||||
|
self._close()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class DevicePluginRuntimeContribution:
|
||||||
|
"""One reviewed backend plugin contribution loaded from its manifest factory."""
|
||||||
|
|
||||||
|
runtime: DevicePluginRuntimeTransport
|
||||||
|
legacy_routers: tuple[APIRouter, ...] = ()
|
||||||
|
observation: ObservationRuntimeContribution | None = None
|
||||||
|
|
||||||
|
|
||||||
class DevicePluginDispatcher:
|
class DevicePluginDispatcher:
|
||||||
"""Host-owned dispatcher for allowlisted, namespaced plugin actions."""
|
"""Host-owned dispatcher for allowlisted, namespaced plugin actions."""
|
||||||
|
|
||||||
def __init__(self, adapters: list[DevicePluginActionAdapter]) -> None:
|
def __init__(self, runtimes: list[DevicePluginRuntimeTransport]) -> None:
|
||||||
self._adapters: dict[str, DevicePluginActionAdapter] = {}
|
self._runtimes: dict[str, DevicePluginRuntimeTransport] = {}
|
||||||
for adapter in adapters:
|
for runtime in runtimes:
|
||||||
if adapter.plugin_id in self._adapters:
|
plugin_id = runtime.descriptor.plugin_id
|
||||||
raise ValueError(f"Duplicate runtime device-plugin id: {adapter.plugin_id}")
|
if plugin_id in self._runtimes:
|
||||||
self._adapters[adapter.plugin_id] = adapter
|
raise ValueError(f"Duplicate runtime device-plugin id: {plugin_id}")
|
||||||
|
self._runtimes[plugin_id] = runtime
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def action_declarations(self) -> dict[str, frozenset[str]]:
|
def action_declarations(self) -> dict[str, frozenset[str]]:
|
||||||
return {plugin_id: adapter.action_ids for plugin_id, adapter in self._adapters.items()}
|
return {
|
||||||
|
plugin_id: frozenset(runtime.descriptor.action_ids)
|
||||||
|
for plugin_id, runtime in self._runtimes.items()
|
||||||
|
}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def health_snapshots(self) -> dict[str, RuntimeHealthSnapshot]:
|
||||||
|
return {
|
||||||
|
plugin_id: runtime.health() for plugin_id, runtime in self._runtimes.items()
|
||||||
|
}
|
||||||
|
|
||||||
async def invoke(
|
async def invoke(
|
||||||
self,
|
self,
|
||||||
|
|
@ -74,36 +217,36 @@ class DevicePluginDispatcher:
|
||||||
action_id: str,
|
action_id: str,
|
||||||
payload: Mapping[str, Any],
|
payload: Mapping[str, Any],
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
adapter = self._adapters.get(plugin_id)
|
runtime = self._runtimes.get(plugin_id)
|
||||||
if adapter is None:
|
if runtime is None:
|
||||||
raise PluginNotFoundError(f"Device plugin is not installed: {plugin_id}")
|
raise PluginNotFoundError(f"Device plugin is not installed: {plugin_id}")
|
||||||
return await invoke_device_plugin_adapter(adapter, action_id, payload)
|
return await invoke_device_plugin_runtime(runtime, action_id, payload)
|
||||||
|
|
||||||
|
|
||||||
async def invoke_device_plugin_adapter(
|
async def invoke_device_plugin_runtime(
|
||||||
adapter: DevicePluginActionAdapter,
|
runtime: DevicePluginRuntimeTransport,
|
||||||
action_id: str,
|
action_id: str,
|
||||||
payload: Mapping[str, Any],
|
payload: Mapping[str, Any],
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Validate SDK request/result envelopes around one plugin action call."""
|
"""Validate SDK request/result envelopes around one plugin action call."""
|
||||||
|
|
||||||
if action_id not in adapter.action_ids:
|
descriptor = runtime.descriptor
|
||||||
|
if action_id not in descriptor.action_ids:
|
||||||
raise PluginActionNotFoundError(
|
raise PluginActionNotFoundError(
|
||||||
f"Device plugin {adapter.plugin_id} does not declare action {action_id}"
|
f"Device plugin {descriptor.plugin_id} does not declare action {action_id}"
|
||||||
)
|
)
|
||||||
invocation = RuntimeActionInvocation(
|
invocation = RuntimeActionInvocation(
|
||||||
invocation_id=uuid4().hex,
|
invocation_id=uuid4().hex,
|
||||||
plugin_id=adapter.plugin_id,
|
plugin_id=descriptor.plugin_id,
|
||||||
action_id=action_id,
|
action_id=action_id,
|
||||||
requested_at=datetime.now(UTC),
|
requested_at=datetime.now(UTC),
|
||||||
parameters=dict(payload),
|
parameters=dict(payload),
|
||||||
)
|
)
|
||||||
output = await adapter.invoke(invocation)
|
result = await runtime.invoke(invocation)
|
||||||
result = RuntimeActionResult(
|
if (
|
||||||
invocation_id=invocation.invocation_id,
|
result.invocation_id != invocation.invocation_id
|
||||||
plugin_id=invocation.plugin_id,
|
or result.plugin_id != invocation.plugin_id
|
||||||
action_id=invocation.action_id,
|
or result.action_id != invocation.action_id
|
||||||
completed_at=datetime.now(UTC),
|
):
|
||||||
output=dict(output),
|
raise PluginExecutionError("Plugin runtime returned an uncorrelated action result")
|
||||||
)
|
|
||||||
return dict(result.output)
|
return dict(result.output)
|
||||||
|
|
|
||||||
|
|
@ -3,11 +3,19 @@ from __future__ import annotations
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import threading
|
import threading
|
||||||
|
from datetime import UTC, datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from missioncore_plugin_sdk.v0alpha2 import RuntimeActionInvocation
|
from missioncore_plugin_sdk.v0alpha2 import (
|
||||||
|
RuntimeActionInvocation,
|
||||||
|
RuntimeActionResult,
|
||||||
|
RuntimeHandshakeRequest,
|
||||||
|
RuntimeHandshakeResult,
|
||||||
|
RuntimePluginDescriptor,
|
||||||
|
)
|
||||||
from pydantic import ValidationError
|
from pydantic import ValidationError
|
||||||
|
|
||||||
import k1link.web.device_plugin_composition as plugin_composition
|
import k1link.web.device_plugin_composition as plugin_composition
|
||||||
|
|
@ -19,6 +27,7 @@ from k1link.device_plugins.xgrids_k1.facade import (
|
||||||
ACTION_STREAM_STOP,
|
ACTION_STREAM_STOP,
|
||||||
ACTION_VIEWER_SETTINGS_UPDATE,
|
ACTION_VIEWER_SETTINGS_UPDATE,
|
||||||
XGRIDS_K1_PLUGIN_ID,
|
XGRIDS_K1_PLUGIN_ID,
|
||||||
|
XGRIDS_K1_PLUGIN_VERSION,
|
||||||
CompatibilityAttestationRequest,
|
CompatibilityAttestationRequest,
|
||||||
ConnectRequest,
|
ConnectRequest,
|
||||||
ViewerSettingsRequest,
|
ViewerSettingsRequest,
|
||||||
|
|
@ -33,8 +42,12 @@ from k1link.web.plugin_catalog import DevicePluginCatalog
|
||||||
from k1link.web.plugin_runtime import (
|
from k1link.web.plugin_runtime import (
|
||||||
DevicePluginDispatcher,
|
DevicePluginDispatcher,
|
||||||
DevicePluginRuntimeContribution,
|
DevicePluginRuntimeContribution,
|
||||||
|
InProcessDevicePluginRuntime,
|
||||||
PluginActionNotFoundError,
|
PluginActionNotFoundError,
|
||||||
|
PluginExecutionError,
|
||||||
PluginNotFoundError,
|
PluginNotFoundError,
|
||||||
|
PluginRuntimeCompatibilityError,
|
||||||
|
PluginRuntimeUnavailableError,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -76,6 +89,38 @@ class FakeXgridsService:
|
||||||
return {"phase": "idle", "viewer_settings": request.model_dump()}
|
return {"phase": "idle", "viewer_settings": request.model_dump()}
|
||||||
|
|
||||||
|
|
||||||
|
def _in_process_runtime(
|
||||||
|
adapter: Any,
|
||||||
|
*,
|
||||||
|
plugin_version: str = XGRIDS_K1_PLUGIN_VERSION,
|
||||||
|
host_api_version: str = "missioncore.nodedc/v1alpha2",
|
||||||
|
close: Any | None = None,
|
||||||
|
activate: bool = True,
|
||||||
|
) -> InProcessDevicePluginRuntime:
|
||||||
|
runtime = InProcessDevicePluginRuntime(
|
||||||
|
adapter,
|
||||||
|
RuntimePluginDescriptor(
|
||||||
|
plugin_id=adapter.plugin_id,
|
||||||
|
plugin_version=plugin_version,
|
||||||
|
supported_host_api_versions=(host_api_version,),
|
||||||
|
action_ids=tuple(sorted(adapter.action_ids)),
|
||||||
|
),
|
||||||
|
**({"close": close} if close is not None else {}),
|
||||||
|
)
|
||||||
|
if activate:
|
||||||
|
runtime.handshake(
|
||||||
|
RuntimeHandshakeRequest(
|
||||||
|
handshake_id=uuid4().hex,
|
||||||
|
plugin_id=adapter.plugin_id,
|
||||||
|
plugin_version=plugin_version,
|
||||||
|
host_api_version=host_api_version,
|
||||||
|
requested_at=datetime.now(UTC),
|
||||||
|
required_action_ids=tuple(sorted(adapter.action_ids)),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return runtime
|
||||||
|
|
||||||
|
|
||||||
def test_manifest_and_runtime_facade_declare_identical_actions() -> None:
|
def test_manifest_and_runtime_facade_declare_identical_actions() -> None:
|
||||||
repository_root = Path(__file__).resolve().parents[1]
|
repository_root = Path(__file__).resolve().parents[1]
|
||||||
manifest = next(
|
manifest = next(
|
||||||
|
|
@ -112,8 +157,11 @@ def test_composition_closes_a_runtime_that_does_not_match_its_manifest(
|
||||||
)
|
)
|
||||||
closed: list[bool] = []
|
closed: list[bool] = []
|
||||||
contribution = DevicePluginRuntimeContribution(
|
contribution = DevicePluginRuntimeContribution(
|
||||||
adapter=XgridsK1PluginFacade(FakeXgridsService()),
|
runtime=_in_process_runtime(
|
||||||
close=lambda: closed.append(True),
|
XgridsK1PluginFacade(FakeXgridsService()),
|
||||||
|
close=lambda: closed.append(True),
|
||||||
|
activate=False,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
plugin_composition,
|
plugin_composition,
|
||||||
|
|
@ -127,6 +175,47 @@ def test_composition_closes_a_runtime_that_does_not_match_its_manifest(
|
||||||
assert closed == [True]
|
assert closed == [True]
|
||||||
|
|
||||||
|
|
||||||
|
def test_composition_rejects_an_uncorrelated_runtime_handshake(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
class UncorrelatedHandshakeRuntime(InProcessDevicePluginRuntime):
|
||||||
|
def handshake(
|
||||||
|
self,
|
||||||
|
request: RuntimeHandshakeRequest,
|
||||||
|
) -> RuntimeHandshakeResult:
|
||||||
|
result = super().handshake(request)
|
||||||
|
return result.model_copy(update={"handshake_id": "another-handshake"})
|
||||||
|
|
||||||
|
repository_root = Path(__file__).resolve().parents[1]
|
||||||
|
manifest = next(
|
||||||
|
candidate
|
||||||
|
for candidate in DevicePluginCatalog(repository_root).manifests()
|
||||||
|
if candidate.metadata.id == XGRIDS_K1_PLUGIN_ID
|
||||||
|
)
|
||||||
|
closed: list[bool] = []
|
||||||
|
adapter = XgridsK1PluginFacade(FakeXgridsService())
|
||||||
|
runtime = UncorrelatedHandshakeRuntime(
|
||||||
|
adapter,
|
||||||
|
RuntimePluginDescriptor(
|
||||||
|
plugin_id=adapter.plugin_id,
|
||||||
|
plugin_version=XGRIDS_K1_PLUGIN_VERSION,
|
||||||
|
supported_host_api_versions=(manifest.apiVersion,),
|
||||||
|
action_ids=tuple(sorted(adapter.action_ids)),
|
||||||
|
),
|
||||||
|
close=lambda: closed.append(True),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
plugin_composition,
|
||||||
|
"_load_factory",
|
||||||
|
lambda _: lambda __: DevicePluginRuntimeContribution(runtime=runtime),
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(DevicePluginCompositionError, match="handshake is inconsistent"):
|
||||||
|
plugin_composition._load_contribution(repository_root, manifest)
|
||||||
|
|
||||||
|
assert closed == [True]
|
||||||
|
|
||||||
|
|
||||||
def test_composition_loads_and_dispatches_two_synthetic_plugins(
|
def test_composition_loads_and_dispatches_two_synthetic_plugins(
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
tmp_path: Path,
|
tmp_path: Path,
|
||||||
|
|
@ -149,8 +238,13 @@ def test_composition_loads_and_dispatches_two_synthetic_plugins(
|
||||||
closed: list[str] = []
|
closed: list[str] = []
|
||||||
contributions = {
|
contributions = {
|
||||||
f"synthetic:{suffix}": DevicePluginRuntimeContribution(
|
f"synthetic:{suffix}": DevicePluginRuntimeContribution(
|
||||||
adapter=SyntheticAdapter(f"example.{suffix}"),
|
runtime=_in_process_runtime(
|
||||||
close=lambda suffix=suffix: closed.append(suffix),
|
SyntheticAdapter(f"example.{suffix}"),
|
||||||
|
plugin_version="0.1.0",
|
||||||
|
host_api_version="missioncore.nodedc/v1alpha1",
|
||||||
|
close=lambda suffix=suffix: closed.append(suffix),
|
||||||
|
activate=False,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
for suffix in ("first", "second")
|
for suffix in ("first", "second")
|
||||||
}
|
}
|
||||||
|
|
@ -205,6 +299,7 @@ def test_composition_loads_and_dispatches_two_synthetic_plugins(
|
||||||
environment.dispatcher.invoke(f"example.{suffix}", "state.read", {})
|
environment.dispatcher.invoke(f"example.{suffix}", "state.read", {})
|
||||||
)
|
)
|
||||||
assert state == {"plugin_id": f"example.{suffix}"}
|
assert state == {"plugin_id": f"example.{suffix}"}
|
||||||
|
assert {item["status"] for item in environment.runtime_health} == {"ready"}
|
||||||
finally:
|
finally:
|
||||||
environment.close()
|
environment.close()
|
||||||
|
|
||||||
|
|
@ -226,12 +321,16 @@ def test_environment_shutdown_attempts_every_plugin_after_close_failure(
|
||||||
legacy_routers=(),
|
legacy_routers=(),
|
||||||
_contributions=(
|
_contributions=(
|
||||||
DevicePluginRuntimeContribution(
|
DevicePluginRuntimeContribution(
|
||||||
adapter=XgridsK1PluginFacade(FakeXgridsService()),
|
runtime=_in_process_runtime(
|
||||||
close=lambda: closed.append("healthy"),
|
XgridsK1PluginFacade(FakeXgridsService()),
|
||||||
|
close=lambda: closed.append("healthy"),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
DevicePluginRuntimeContribution(
|
DevicePluginRuntimeContribution(
|
||||||
adapter=XgridsK1PluginFacade(FakeXgridsService()),
|
runtime=_in_process_runtime(
|
||||||
close=failing_close,
|
XgridsK1PluginFacade(FakeXgridsService()),
|
||||||
|
close=failing_close,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
@ -245,7 +344,9 @@ def test_environment_shutdown_attempts_every_plugin_after_close_failure(
|
||||||
|
|
||||||
def test_dispatcher_routes_allowlisted_action_to_xgrids_facade() -> None:
|
def test_dispatcher_routes_allowlisted_action_to_xgrids_facade() -> None:
|
||||||
service = FakeXgridsService()
|
service = FakeXgridsService()
|
||||||
dispatcher = DevicePluginDispatcher([XgridsK1PluginFacade(service)])
|
dispatcher = DevicePluginDispatcher(
|
||||||
|
[_in_process_runtime(XgridsK1PluginFacade(service))]
|
||||||
|
)
|
||||||
|
|
||||||
state = asyncio.run(
|
state = asyncio.run(
|
||||||
dispatcher.invoke(
|
dispatcher.invoke(
|
||||||
|
|
@ -260,7 +361,9 @@ def test_dispatcher_routes_allowlisted_action_to_xgrids_facade() -> None:
|
||||||
|
|
||||||
|
|
||||||
def test_dispatcher_rejects_unknown_plugin_and_action() -> None:
|
def test_dispatcher_rejects_unknown_plugin_and_action() -> None:
|
||||||
dispatcher = DevicePluginDispatcher([XgridsK1PluginFacade(FakeXgridsService())])
|
dispatcher = DevicePluginDispatcher(
|
||||||
|
[_in_process_runtime(XgridsK1PluginFacade(FakeXgridsService()))]
|
||||||
|
)
|
||||||
|
|
||||||
with pytest.raises(PluginNotFoundError):
|
with pytest.raises(PluginNotFoundError):
|
||||||
asyncio.run(dispatcher.invoke("missing.plugin", ACTION_STREAM_STOP, {}))
|
asyncio.run(dispatcher.invoke("missing.plugin", ACTION_STREAM_STOP, {}))
|
||||||
|
|
@ -270,7 +373,9 @@ def test_dispatcher_rejects_unknown_plugin_and_action() -> None:
|
||||||
|
|
||||||
def test_facade_validates_payload_before_calling_service() -> None:
|
def test_facade_validates_payload_before_calling_service() -> None:
|
||||||
service = FakeXgridsService()
|
service = FakeXgridsService()
|
||||||
dispatcher = DevicePluginDispatcher([XgridsK1PluginFacade(service)])
|
dispatcher = DevicePluginDispatcher(
|
||||||
|
[_in_process_runtime(XgridsK1PluginFacade(service))]
|
||||||
|
)
|
||||||
|
|
||||||
with pytest.raises(ValidationError):
|
with pytest.raises(ValidationError):
|
||||||
asyncio.run(
|
asyncio.run(
|
||||||
|
|
@ -332,7 +437,9 @@ def test_facade_preserves_existing_runtime_operations(
|
||||||
expected_call: str,
|
expected_call: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
service = FakeXgridsService()
|
service = FakeXgridsService()
|
||||||
dispatcher = DevicePluginDispatcher([XgridsK1PluginFacade(service)])
|
dispatcher = DevicePluginDispatcher(
|
||||||
|
[_in_process_runtime(XgridsK1PluginFacade(service))]
|
||||||
|
)
|
||||||
|
|
||||||
asyncio.run(dispatcher.invoke(XGRIDS_K1_PLUGIN_ID, action_id, payload))
|
asyncio.run(dispatcher.invoke(XGRIDS_K1_PLUGIN_ID, action_id, payload))
|
||||||
|
|
||||||
|
|
@ -348,10 +455,89 @@ def test_sync_runtime_actions_run_outside_the_api_event_loop() -> None:
|
||||||
return super().stop()
|
return super().stop()
|
||||||
|
|
||||||
service = ThreadAwareService()
|
service = ThreadAwareService()
|
||||||
dispatcher = DevicePluginDispatcher([XgridsK1PluginFacade(service)])
|
dispatcher = DevicePluginDispatcher(
|
||||||
|
[_in_process_runtime(XgridsK1PluginFacade(service))]
|
||||||
|
)
|
||||||
event_loop_thread = threading.get_ident()
|
event_loop_thread = threading.get_ident()
|
||||||
|
|
||||||
asyncio.run(dispatcher.invoke(XGRIDS_K1_PLUGIN_ID, ACTION_STREAM_STOP, {}))
|
asyncio.run(dispatcher.invoke(XGRIDS_K1_PLUGIN_ID, ACTION_STREAM_STOP, {}))
|
||||||
|
|
||||||
assert service.thread_id is not None
|
assert service.thread_id is not None
|
||||||
assert service.thread_id != event_loop_thread
|
assert service.thread_id != event_loop_thread
|
||||||
|
|
||||||
|
|
||||||
|
def test_runtime_requires_successful_handshake_before_dispatch() -> None:
|
||||||
|
adapter = XgridsK1PluginFacade(FakeXgridsService())
|
||||||
|
runtime = _in_process_runtime(adapter, activate=False)
|
||||||
|
dispatcher = DevicePluginDispatcher([runtime])
|
||||||
|
|
||||||
|
assert runtime.health().status == "starting"
|
||||||
|
with pytest.raises(PluginRuntimeUnavailableError, match="not ready"):
|
||||||
|
asyncio.run(dispatcher.invoke(XGRIDS_K1_PLUGIN_ID, ACTION_STREAM_STOP, {}))
|
||||||
|
|
||||||
|
with pytest.raises(PluginRuntimeCompatibilityError, match="host API"):
|
||||||
|
runtime.handshake(
|
||||||
|
RuntimeHandshakeRequest(
|
||||||
|
handshake_id="handshake-incompatible",
|
||||||
|
plugin_id=XGRIDS_K1_PLUGIN_ID,
|
||||||
|
plugin_version=XGRIDS_K1_PLUGIN_VERSION,
|
||||||
|
host_api_version="missioncore.nodedc/v1alpha1",
|
||||||
|
requested_at=datetime.now(UTC),
|
||||||
|
required_action_ids=tuple(sorted(adapter.action_ids)),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert runtime.health().status == "starting"
|
||||||
|
|
||||||
|
|
||||||
|
def test_runtime_health_transitions_to_stopped_and_close_is_idempotent() -> None:
|
||||||
|
closed: list[bool] = []
|
||||||
|
runtime = _in_process_runtime(
|
||||||
|
XgridsK1PluginFacade(FakeXgridsService()),
|
||||||
|
close=lambda: closed.append(True),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert runtime.health().status == "ready"
|
||||||
|
runtime.close()
|
||||||
|
runtime.close()
|
||||||
|
|
||||||
|
assert runtime.health().status == "stopped"
|
||||||
|
assert closed == [True]
|
||||||
|
|
||||||
|
|
||||||
|
def test_dispatcher_rejects_uncorrelated_transport_result() -> None:
|
||||||
|
class UncorrelatedRuntime(InProcessDevicePluginRuntime):
|
||||||
|
async def invoke(
|
||||||
|
self,
|
||||||
|
invocation: RuntimeActionInvocation,
|
||||||
|
) -> RuntimeActionResult:
|
||||||
|
result = await super().invoke(invocation)
|
||||||
|
return result.model_copy(update={"invocation_id": "another-invocation"})
|
||||||
|
|
||||||
|
adapter = XgridsK1PluginFacade(FakeXgridsService())
|
||||||
|
descriptor = RuntimePluginDescriptor(
|
||||||
|
plugin_id=adapter.plugin_id,
|
||||||
|
plugin_version=XGRIDS_K1_PLUGIN_VERSION,
|
||||||
|
supported_host_api_versions=("missioncore.nodedc/v1alpha2",),
|
||||||
|
action_ids=tuple(sorted(adapter.action_ids)),
|
||||||
|
)
|
||||||
|
runtime = UncorrelatedRuntime(adapter, descriptor)
|
||||||
|
runtime.handshake(
|
||||||
|
RuntimeHandshakeRequest(
|
||||||
|
handshake_id="handshake-correlation",
|
||||||
|
plugin_id=adapter.plugin_id,
|
||||||
|
plugin_version=XGRIDS_K1_PLUGIN_VERSION,
|
||||||
|
host_api_version="missioncore.nodedc/v1alpha2",
|
||||||
|
requested_at=datetime.now(UTC),
|
||||||
|
required_action_ids=descriptor.action_ids,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(PluginExecutionError, match="uncorrelated"):
|
||||||
|
asyncio.run(
|
||||||
|
DevicePluginDispatcher([runtime]).invoke(
|
||||||
|
XGRIDS_K1_PLUGIN_ID,
|
||||||
|
ACTION_STREAM_STOP,
|
||||||
|
{},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -339,6 +339,52 @@ def test_plugin_sdk_v0alpha2_compatibility_blocks_unproven_control() -> None:
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_plugin_sdk_v0alpha2_runtime_handshake_is_versioned_and_closed() -> None:
|
||||||
|
descriptor = sdk.RuntimePluginDescriptor(
|
||||||
|
plugin_id="nodedc.device.synthetic",
|
||||||
|
plugin_version="0.1.0",
|
||||||
|
supported_host_api_versions=("missioncore.nodedc/v1alpha2",),
|
||||||
|
action_ids=("state.read", "acquisition.start"),
|
||||||
|
)
|
||||||
|
request = sdk.RuntimeHandshakeRequest(
|
||||||
|
handshake_id="handshake-001",
|
||||||
|
plugin_id=descriptor.plugin_id,
|
||||||
|
plugin_version=descriptor.plugin_version,
|
||||||
|
host_api_version="missioncore.nodedc/v1alpha2",
|
||||||
|
requested_at=NOW,
|
||||||
|
required_action_ids=descriptor.action_ids,
|
||||||
|
)
|
||||||
|
result = sdk.RuntimeHandshakeResult(
|
||||||
|
handshake_id=request.handshake_id,
|
||||||
|
runtime_instance_id="runtime.synthetic-001",
|
||||||
|
accepted_host_api_version=request.host_api_version,
|
||||||
|
descriptor=descriptor,
|
||||||
|
ready_at=NOW,
|
||||||
|
)
|
||||||
|
health = sdk.RuntimeHealthSnapshot(
|
||||||
|
runtime_instance_id=result.runtime_instance_id,
|
||||||
|
plugin_id=descriptor.plugin_id,
|
||||||
|
plugin_version=descriptor.plugin_version,
|
||||||
|
status="ready",
|
||||||
|
observed_at=NOW,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert descriptor.runtime_protocol_version == sdk.RUNTIME_PROTOCOL_VERSION
|
||||||
|
assert result.descriptor == descriptor
|
||||||
|
assert health.status == "ready"
|
||||||
|
with pytest.raises(ValidationError, match="runtime action ids must be unique"):
|
||||||
|
sdk.RuntimePluginDescriptor.model_validate(
|
||||||
|
{
|
||||||
|
**descriptor.model_dump(),
|
||||||
|
"action_ids": ["state.read", "state.read"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
with pytest.raises(ValidationError, match="extra"):
|
||||||
|
sdk.RuntimeHandshakeRequest.model_validate(
|
||||||
|
{**request.model_dump(), "process_id": 42}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_plugin_sdk_v0alpha2_exports_closed_json_schemas_and_vocabulary() -> None:
|
def test_plugin_sdk_v0alpha2_exports_closed_json_schemas_and_vocabulary() -> None:
|
||||||
schemas = sdk.contract_json_schemas()
|
schemas = sdk.contract_json_schemas()
|
||||||
|
|
||||||
|
|
@ -348,6 +394,10 @@ def test_plugin_sdk_v0alpha2_exports_closed_json_schemas_and_vocabulary() -> Non
|
||||||
"OperationEvent",
|
"OperationEvent",
|
||||||
"RuntimeActionInvocation",
|
"RuntimeActionInvocation",
|
||||||
"RuntimeActionResult",
|
"RuntimeActionResult",
|
||||||
|
"RuntimePluginDescriptor",
|
||||||
|
"RuntimeHandshakeRequest",
|
||||||
|
"RuntimeHandshakeResult",
|
||||||
|
"RuntimeHealthSnapshot",
|
||||||
"CanonicalStreamEnvelope",
|
"CanonicalStreamEnvelope",
|
||||||
"EvidenceRecord",
|
"EvidenceRecord",
|
||||||
"CompatibilityAssessment",
|
"CompatibilityAssessment",
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue