diff --git a/CHANGELOG.md b/CHANGELOG.md index d34f7f8..f174446 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,12 +33,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Remote access via connectors — Phases 0–6: converge four hand-rolled networking stacks onto two shared engines (Issue #39, [design doc](docs/design/remote-access-via-connectors.md)).** AimX remote access (and any future transport) now rides the connector layer instead of a bespoke I/O abstraction. New, runtime-neutral `aimdb-core::session` module (feature `connector-session`, `no_std + alloc`): the three-layer substrate (`Connection`/`Listener`/`Dialer` + `EnvelopeCodec` + `Dispatch`/`Session`), the reactive **server** engine (`serve`/`run_session`) and proactive **client** engine (`run_client`/`pump_client`), the `pump_sink`/`pump_source` data-plane toolkit, and the transport-agnostic `SessionClientConnector`/`SessionServerConnector`. The AimX-v2 NDJSON protocol (`session::aimx`: `AimxCodec` + `AimxDispatch`) and the WebSocket connector are ports onto this substrate, so the AimX server/client and WS server/client stacks collapse onto the two engines. New **`aimdb-uds-connector`** crate carries the UDS transport (`UdsClient`/`UdsServer`). ([aimdb-core](aimdb-core/CHANGELOG.md), [aimdb-uds-connector](aimdb-uds-connector/CHANGELOG.md), [aimdb-websocket-connector](aimdb-websocket-connector/CHANGELOG.md), [aimdb-client](aimdb-client/CHANGELOG.md), [aimdb-mqtt-connector](aimdb-mqtt-connector/CHANGELOG.md), [aimdb-knx-connector](aimdb-knx-connector/CHANGELOG.md), [aimdb-embassy-adapter](aimdb-embassy-adapter/CHANGELOG.md)) - **M16 — JSON codec extracted behind the `json-serialize` feature; `RecordValue::as_json()` now works on `no_std + alloc`, not just `std` ([Design 032](docs/design/032-M16-aimx-json-codec.md)).** New `aimdb-core::codec` module: `RemoteSerialize` (blanket-impl'd for every `serde` `Serialize + DeserializeOwned` type), the object-safe `JsonCodec`, and the zero-sized `SerdeJsonCodec`. `serde_json` runs on `alloc`, so embedded targets can opt in; `std` enables the feature transitively, so std builds are unaffected. ([aimdb-core](aimdb-core/CHANGELOG.md)) - **Embassy buffer + join-queue tests now run in CI (Issue #85).** The join-queue tests previously sat behind `embassy-runtime`, which pulls `embassy-executor`'s cortex-m assembly and can't compile under `cargo test` on x86_64 — so ordering / backpressure / clone-routing regressions were never caught. The `join_queue` module is now gated on `embassy-sync`, and `make test` runs the embassy adapter's unit tests + doctests on the host (no executor). Also adds `EmbassyBuffer::peek()` and fixes a stale `EmbassyBuffer` doc example. ([aimdb-embassy-adapter](aimdb-embassy-adapter/CHANGELOG.md)) +- **`no_std` AimX server — a board can serve a host, not just dial one (Issue #120, follow-up to #39).** Cross-cutting de-std of `aimdb-core`'s central record API behind a new **`remote-access`** feature (`= ["json-serialize", "thiserror"]`, transitively enabled by `std`): the type-erased `AnyRecord` JSON + metadata methods, the `AimDb` JSON read/write/subscribe API, the `remote` module (config / protocol / security / error), and the AimX server dispatch (`AimxDispatch`/`AimxSession`) now all compile on `no_std + alloc` — swapping `std::collections` → `hashbrown`, `std::sync::Arc` → `alloc::sync::Arc`, and `thiserror` to `default-features = false`. Adds a runtime-neutral wall clock, `TimeOps::unix_time()`, implemented from the OS clock on Tokio and from an `EmbassyAdapter::set_unix_time(...)` anchor on Embassy. Verified by a new `thumbv7em-none-eabihf` dispatch cross-check in the Makefile. ([aimdb-core](aimdb-core/CHANGELOG.md), [aimdb-executor](aimdb-executor/CHANGELOG.md), [aimdb-tokio-adapter](aimdb-tokio-adapter/CHANGELOG.md), [aimdb-embassy-adapter](aimdb-embassy-adapter/CHANGELOG.md), [aimdb-uds-connector](aimdb-uds-connector/CHANGELOG.md), [tools/aimdb-mcp](tools/aimdb-mcp/CHANGELOG.md)) ### Changed (breaking) - **`AimDbBuilder::with_remote_access(config)` removed — remote-access servers are now registered like any other connector (Issue #39).** Replace `.with_remote_access(config)` with `.with_connector(aimdb_uds_connector::UdsServer::from_config(config))`. The AimX wire was reshaped to **v2** (NDJSON tagged frames mapping onto the engine's role-neutral message set) and is **not** backward-compatible with the legacy AimX v1 framing; the bundled `aimdb-client` / CLI / MCP speak v2. The UDS transport types moved out of `aimdb-core` into `aimdb-uds-connector`. ([aimdb-core](aimdb-core/CHANGELOG.md), [aimdb-uds-connector](aimdb-uds-connector/CHANGELOG.md), [aimdb-client](aimdb-client/CHANGELOG.md)) - **M15 — `latest_snapshot` removed; point-in-time reads go through the new buffer-native `DynBuffer::peek()` ([Design 031](docs/design/031-M15-remove-latest-snapshot.md)).** `TypedRecord::latest()` and AimX `record.get` read the buffer directly instead of a per-record snapshot mutex (one lock + clone off the `produce()` hot path). Consequences: a `.with_remote_access()` record with **no buffer** now fails `build()` (was a silent runtime no-op); `record.get` / `latest()` on an `SpmcRing` record returns `not_found` / `None` (rings have no canonical latest — use `record.drain` / `record.subscribe`); `SingleLatest` and `Mailbox` are unaffected. `TypedRecord::produce` is removed — all writes go through `WriteHandle::push`. Adapters implement `peek()` per buffer type. ([aimdb-core](aimdb-core/CHANGELOG.md), [aimdb-tokio-adapter](aimdb-tokio-adapter/CHANGELOG.md), [aimdb-embassy-adapter](aimdb-embassy-adapter/CHANGELOG.md), [aimdb-wasm-adapter](aimdb-wasm-adapter/CHANGELOG.md)) - **M16 — `with_remote_access()` now requires the `json-serialize` feature (transitively enabled by `std`); `with_read_only_serialization()` removed ([Design 032](docs/design/032-M16-aimx-json-codec.md)).** The stored serializer/deserializer closures are replaced by a type-erased `Arc>`. A `Serialize`-only record can no longer be exposed read-only over remote access. ([aimdb-core](aimdb-core/CHANGELOG.md)) +- **`RecordMetadata` drops `created_at` / `last_update`; `AimxConfig.socket_path` is now `String` (Issue #120).** Core stopped keeping per-record timestamp state for the `no_std` AimX server (the `Mutex`-+-`SystemTime` `RecordMetadataTracker` is deleted), so AimX `record.list` and the MCP `list_records` / schema outputs no longer include the two timestamp fields. `RecordMetadata::new` drops its `created_at` parameter. `AimxConfig::socket_path` / `UdsServer::new` take `impl Into` instead of `impl Into` (`&Path` / `PathBuf` callers must convert). ([aimdb-core](aimdb-core/CHANGELOG.md), [aimdb-uds-connector](aimdb-uds-connector/CHANGELOG.md), [tools/aimdb-mcp](tools/aimdb-mcp/CHANGELOG.md)) - **M13 — `Spawn` trait removed across the workspace; `AimDbBuilder::build()` now returns `(AimDb, AimDbRunner)` (Issue #88, [Design 028](docs/design/028-M13-remove-spawn-trait.md)).** Every future the database drives — producer services, taps, transforms, join forwarders, connector loops, the remote-access supervisor, `on_start` tasks — is collected at build time and driven by a single `FuturesUnordered` inside `runner.run().await`. Adapter implementations (`TokioAdapter`, `EmbassyAdapter`, `WasmAdapter`) drop their `impl Spawn`. The `embassy-task-pool-8/16/32` features are deleted and `EmbassyAdapter::new_with_network` no longer takes a `Spawner`. Connector authors must update `ConnectorBuilder::build()` to return `Vec` instead of `Arc`. See each crate's CHANGELOG for the per-crate impact. diff --git a/Makefile b/Makefile index 17e4ca8..3a8d990 100644 --- a/Makefile +++ b/Makefile @@ -121,8 +121,10 @@ test: cargo test --package aimdb-core --no-default-features --features "alloc,profiling" @printf "$(YELLOW) → Testing aimdb-core (no_std + alloc + metrics)$(NC)\n" cargo test --package aimdb-core --no-default-features --features "alloc,metrics" - @printf "$(YELLOW) → Testing aimdb-core (no_std + alloc + json-serialize)$(NC)\n" + @printf "$(YELLOW) → Testing aimdb-core (no_std + alloc + json-serialize value codec)$(NC)\n" cargo test --package aimdb-core --no-default-features --features "alloc,json-serialize" + @printf "$(YELLOW) → Testing aimdb-core (no_std + alloc + remote-access data model)$(NC)\n" + cargo test --package aimdb-core --no-default-features --features "alloc,remote-access" @printf "$(YELLOW) → Testing aimdb-core remote module$(NC)\n" cargo test --package aimdb-core --lib --features "std" remote:: @printf "$(YELLOW) → Testing aimdb-core connector-session (contracts object-safety)$(NC)\n" @@ -198,8 +200,10 @@ clippy: cargo clippy --package aimdb-data-contracts --no-default-features --features alloc -- -D warnings @printf "$(YELLOW) → Clippy on aimdb-core (no_std + alloc)$(NC)\n" cargo clippy --package aimdb-core --no-default-features --features alloc --all-targets -- -D warnings - @printf "$(YELLOW) → Clippy on aimdb-core (no_std + alloc + json-serialize)$(NC)\n" + @printf "$(YELLOW) → Clippy on aimdb-core (no_std + alloc + json-serialize value codec)$(NC)\n" cargo clippy --package aimdb-core --no-default-features --features "alloc,json-serialize" --all-targets -- -D warnings + @printf "$(YELLOW) → Clippy on aimdb-core (no_std + alloc + remote-access data model)$(NC)\n" + cargo clippy --package aimdb-core --no-default-features --features "alloc,remote-access" --all-targets -- -D warnings @printf "$(YELLOW) → Clippy on aimdb-core (std)$(NC)\n" cargo clippy --package aimdb-core --features "std,tracing,metrics" --all-targets -- -D warnings @printf "$(YELLOW) → Clippy on tokio adapter$(NC)\n" @@ -294,12 +298,16 @@ test-embedded: cargo check --package aimdb-data-contracts --target thumbv7em-none-eabihf --target-dir $(EMBEDDED_CHECK_TARGET_DIR) --no-default-features --features alloc @printf "$(YELLOW) → Checking aimdb-core (no_std minimal) on thumbv7em-none-eabihf target$(NC)\n" cargo check --package aimdb-core --target thumbv7em-none-eabihf --target-dir $(EMBEDDED_CHECK_TARGET_DIR) --no-default-features --features alloc - @printf "$(YELLOW) → Checking aimdb-core (no_std + alloc + json-serialize) on thumbv7em-none-eabihf target$(NC)\n" + @printf "$(YELLOW) → Checking aimdb-core (no_std + alloc + json-serialize value codec) on thumbv7em-none-eabihf target$(NC)\n" cargo check --package aimdb-core --target thumbv7em-none-eabihf --target-dir $(EMBEDDED_CHECK_TARGET_DIR) --no-default-features --features "alloc,json-serialize" + @printf "$(YELLOW) → Checking aimdb-core (no_std + alloc + remote-access data model) on thumbv7em-none-eabihf target$(NC)\n" + cargo check --package aimdb-core --target thumbv7em-none-eabihf --target-dir $(EMBEDDED_CHECK_TARGET_DIR) --no-default-features --features "alloc,remote-access" @printf "$(YELLOW) → Checking aimdb-core session engines (no_std + connector-session) on thumbv7em-none-eabihf target$(NC)\n" cargo check --package aimdb-core --target thumbv7em-none-eabihf --target-dir $(EMBEDDED_CHECK_TARGET_DIR) --no-default-features --features "alloc,connector-session" - @printf "$(YELLOW) → Checking aimdb-core AimX codec (no_std + connector-session + json-serialize) on thumbv7em-none-eabihf target$(NC)\n" + @printf "$(YELLOW) → Checking aimdb-core AimX codec (no_std client path: connector-session + json-serialize) on thumbv7em-none-eabihf target$(NC)\n" cargo check --package aimdb-core --target thumbv7em-none-eabihf --target-dir $(EMBEDDED_CHECK_TARGET_DIR) --no-default-features --features "alloc,connector-session,json-serialize" + @printf "$(YELLOW) → Checking aimdb-core AimX codec + dispatch (full no_std AimX server: connector-session + remote-access) on thumbv7em-none-eabihf target$(NC)\n" + cargo check --package aimdb-core --target thumbv7em-none-eabihf --target-dir $(EMBEDDED_CHECK_TARGET_DIR) --no-default-features --features "alloc,connector-session,remote-access" @printf "$(YELLOW) → Checking aimdb-core (no_std/embassy) on thumbv7em-none-eabihf target$(NC)\n" cargo check --package aimdb-core --target thumbv7em-none-eabihf --target-dir $(EMBEDDED_CHECK_TARGET_DIR) --no-default-features --features alloc @printf "$(YELLOW) → Checking aimdb-embassy-adapter on thumbv7em-none-eabihf target$(NC)\n" diff --git a/_external/embassy b/_external/embassy index 44729ce..caf0b35 160000 --- a/_external/embassy +++ b/_external/embassy @@ -1 +1 @@ -Subproject commit 44729ce14de1694600d398b836c883e3fd2aff02 +Subproject commit caf0b353e1f809a5eb22fc2d3bec337d1833e07e diff --git a/aimdb-client/tests/aimx_session.rs b/aimdb-client/tests/aimx_session.rs index e6aa8b2..d186d7b 100644 --- a/aimdb-client/tests/aimx_session.rs +++ b/aimdb-client/tests/aimx_session.rs @@ -39,7 +39,7 @@ async fn aimx_roundtrip_over_uds_production_server() { let mut policy = SecurityPolicy::read_write(); policy.allow_write_key("setting"); let config = AimxConfig::uds_default() - .socket_path(&sock) + .socket_path(sock.to_str().unwrap()) .security_policy(policy) .max_connections(8) .max_subs_per_connection(8); @@ -144,7 +144,7 @@ async fn record_get_on_ring_falls_back_to_drain() { let dir = tempfile::tempdir().unwrap(); let sock = dir.path().join("aimdb.sock"); - let config = AimxConfig::uds_default().socket_path(&sock); + let config = AimxConfig::uds_default().socket_path(sock.to_str().unwrap()); let mut builder = AimDbBuilder::new() .runtime(Arc::new(TokioAdapter)) .with_connector(UdsServer::from_config(config)); diff --git a/aimdb-client/tests/pump_client.rs b/aimdb-client/tests/pump_client.rs index 8df2319..12e5c41 100644 --- a/aimdb-client/tests/pump_client.rs +++ b/aimdb-client/tests/pump_client.rs @@ -54,7 +54,7 @@ async fn pump_client_mirrors_record_both_directions() { let mut policy = SecurityPolicy::read_write(); policy.allow_write_key("cfg"); let config = AimxConfig::uds_default() - .socket_path(&sock) + .socket_path(sock.to_str().unwrap()) .security_policy(policy); let mut sb = AimDbBuilder::new() diff --git a/aimdb-core/CHANGELOG.md b/aimdb-core/CHANGELOG.md index 7d5541d..20e7831 100644 --- a/aimdb-core/CHANGELOG.md +++ b/aimdb-core/CHANGELOG.md @@ -15,7 +15,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Client engine** — `run_client` returns a cheap-clone `ClientHandle` (`call`/`subscribe`/`write`) + the engine future; demuxes replies by `id`, supports id- or topic-routed subscriptions, exponential reconnect backoff, idle keepalive, and a bounded offline command queue (`ClientConfig`). The only runtime dependency is the adapter's `TimeOps` clock; everything else is `futures` channels + `async-channel`. - **Data-plane toolkit** — `pump_sink` (outbound: consume-serialize-publish via `Connector`) and `pump_source` (inbound: one multiplexed `Source` reader → `Router` fan-out), extracting the boilerplate every data-plane connector hand-rolled. - **Generic connectors** — `SessionClientConnector` and `SessionServerConnector` wrap the engines onto the `ConnectorBuilder` spine so a transport crate contributes only its `Dialer`/`Listener`/`Connection` triple under a configurable scheme (default `"remote"`). -- **`session::aimx` — the AimX-v2 protocol substrate (gated `connector-session` + `json-serialize`).** `AimxCodec`, the symmetric NDJSON `EnvelopeCodec` (`no_std + alloc`; splices an already-serialized record-value `Payload` into the JSON envelope verbatim via `serde_json`'s `RawValue`, no re-escaping). `AimxDispatch`/`AimxSession` (still `std`-gated — it reaches into core's `record.list`/JSON API), porting the method semantics (`hello`, `record.{list,get,set,drain,query}`, `graph.*`, `*.reset`) off the deleted hand-rolled handler onto `Session`. The drain cursors live in the per-connection session; the AimX subscribe ack stays implicit (events carry the request id back). +- **`session::aimx` — the AimX-v2 protocol substrate (gated `connector-session` + `json-serialize`).** `AimxCodec`, the symmetric NDJSON `EnvelopeCodec` (`no_std + alloc`; splices an already-serialized record-value `Payload` into the JSON envelope verbatim via `serde_json`'s `RawValue`, no re-escaping). `AimxDispatch`/`AimxSession` (`no_std + alloc` as of Issue #120 — gated `connector-session` + `remote-access`; it reaches into core's `record.list`/JSON API, de-std'd to match), porting the method semantics (`hello`, `record.{list,get,set,drain,query}`, `graph.*`, `*.reset`) off the deleted hand-rolled handler onto `Session`. The drain cursors live in the per-connection session; the AimX subscribe ack stays implicit (events carry the request id back). +- **`remote-access` feature — the AimX server data model, now `no_std + alloc` (Issue #120, follow-up to #39).** New feature `remote-access = ["json-serialize", "thiserror"]` that lifts the AimX *server* path off the `std` gate so a board can **serve** a host (e.g. over serial), not just dial one. It re-gates, from `std` to `remote-access`: the `crate::remote` module (record metadata + introspection, the wire protocol/config/security/error types), the type-erased `AnyRecord` JSON + metadata methods (`collect_metadata` / `latest_json` / `subscribe_json` / `set_from_json`), the `JsonBufferReader` trait, and the `AimDb`/`AimDbInner` JSON read/write/subscribe API (`list_records` / `try_latest_as_json` / `set_record_from_json`). The server dispatch swaps `std::collections::HashMap`/`HashSet` → `hashbrown` and `std::sync::Arc` → `alloc::sync::Arc`; `thiserror` is pulled with `default-features = false` so `RemoteError` builds on `no_std`, and `From for RemoteError` is kept behind a `std` cfg. `std` enables `remote-access` transitively, so std builds are unaffected. Verified by `cargo check -p aimdb-core --no-default-features --features "alloc,connector-session,remote-access" --target thumbv7em-none-eabihf` (added to the Makefile `test-embedded` target alongside `alloc,remote-access` test/clippy lines). - **`AimDb::runtime_arc(&self) -> Arc`.** An owned runtime-adapter handle for connectors that hand the runtime to a `'static` engine future (the session client engine clones it for its `TimeOps` clock). - **`ConnectorConfig::from_query(&[(String, String)])`.** Builds a per-route `ConnectorConfig` from a link URL's query pairs (`timeout_ms` lifted to the typed field, everything else passed through in `protocol_options`); the seam `pump_sink` uses to thread per-route config to `Connector::publish`. - **`json-serialize` feature + `codec` module (M16, Design 032).** New `crate::codec` module with `RemoteSerialize` (capability trait, blanket-impl'd for every `serde` `Serialize + DeserializeOwned` type), the object-safe `JsonCodec` storage trait, and the zero-sized `SerdeJsonCodec`. All three are re-exported from the crate root. The feature is `no_std + alloc` compatible (`serde_json` runs on `alloc`), so `RecordValue::as_json()` now works on embedded targets, not just `std`. `std` enables `json-serialize` transitively, so existing std builds are unaffected. @@ -23,6 +24,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Internal refactors +- **`RecordMetadataTracker` deleted; `TypedRecord` keeps only a bare `writable` flag (Issue #120).** The per-record tracker (an `Arc>>` + `Arc` + `SystemTime::now()` on every `produce()` via `RecordWriter::push`) is gone; the surviving `writable` bit is now a single `portable_atomic::AtomicBool` field on `TypedRecord`, and `collect_metadata` reads the type name + `writable` directly with no shared state. New `pub(crate)` `DbError::{runtime_error, permission_denied, record_key_not_found}` constructors let the JSON/remote paths write one error expression across `std` (message carried) and `no_std` (unit placeholder) — replacing the inline `#[cfg]` splits at each call site. The `graph` types' `serde` derives (`RecordOrigin` / `GraphNode` / `GraphEdge` / `EdgeType`) move from the `std` gate to the `serde` feature (always on with `alloc`), so `RecordMetadata` can serialize on `no_std`. + - **AimX server/client ported onto the shared session engine; the hand-rolled loops deleted (Issue #39, [design doc](../docs/design/remote-access-via-connectors.md)).** Building on the spawn-free work below, `remote/handler.rs` (the per-connection `select!` loop) and `remote/supervisor.rs` (the accept loop) are **removed** — their behavior is now `run_session` + `serve` in `session`, driven by the AimX-v2 `AimxDispatch`/`AimxCodec`. `remote/stream.rs`'s `stream_record_updates` survives and is reused by `AimxSession::subscribe`. The UDS transport (socket bind/connect, NDJSON framing) relocated out of core into the new `aimdb-uds-connector` crate; core keeps only the protocol (codec + dispatch) and the generic session connectors. The query handler type-erasure moved to `remote/query.rs` (`QueryHandlerFn`/`QueryHandlerParams`). New dependencies: `async-channel` (runtime-neutral mpsc), `futures-channel` (oneshot), `futures-util`'s `async-await-macro` (`select_biased!`), and `serde_json`'s `raw_value` feature — all `no_std + alloc`-compatible, none entering the no_std contracts build. - **AimX remote-access path is now spawn-free (Issue #114, Design 030).** Every remaining `tokio::spawn` in `aimdb-core/src/remote/` was removed; the supervisor's accept loop and each connection handler now own their own `FuturesUnordered` driven by `tokio::select! { biased; }`. Cancellation collapsed to one mechanism — dropping the future. - New `aimdb-core/src/remote/stream.rs` exports a `pub(crate) stream_record_updates` helper that adapts a record's `JsonBufferReader` into a `Stream` via `futures_util::stream::unfold`. No task, no channel — drop the stream to cancel. @@ -32,6 +35,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed (breaking) +- **`RecordMetadata` drops `created_at` / `last_update`; `AimxConfig.socket_path` is now `String` (Issue #120).** Part of de-std'ing the remote data model for the `no_std` AimX server. Core no longer keeps per-record timestamp state — the `std::sync::Mutex`-+-`SystemTime`-backed `RecordMetadataTracker` is deleted — so `RecordMetadata` loses the `created_at` and `last_update` fields, `RecordMetadata::new` loses its `created_at` parameter, and `with_last_update` / `with_last_update_opt` are removed. AimX `record.list` (and downstream MCP / CLI output derived from it) therefore no longer carry those two fields; every other metadata field is unchanged. `AimxConfig.socket_path` becomes `String` (was `PathBuf`) and `AimxConfig::socket_path(impl Into)` replaces `impl Into` — `&str` / `String` callers are unaffected, `&Path` / `PathBuf` callers must convert (the UDS transport in `aimdb-uds-connector` does the `Path`-based bind). `SecurityPolicy`'s writable set and the `remote` module's collections move from `std::collections` to `hashbrown`. + - **`AimDbBuilder::with_remote_access(config)` removed (Issue #39).** Register a session connector instead: `.with_connector(aimdb_uds_connector::UdsServer::from_config(config))`. The connector binds its transport at `build` time (bind errors surface synchronously, as before), applies the security policy's writable-record marking, and drives the shared `serve` engine. The builder's private `remote_config` field is gone; the per-record `TypedRecord::with_remote_access()` is unrelated and unchanged. The reshaped **AimX-v2** wire is not backward-compatible with the legacy v1 framing. - **`latest_snapshot` removed from `TypedRecord`; `latest()` / AimX `record.get` read the buffer via `peek()` (M15, Design 031).** Eliminates one snapshot-mutex lock + `Option` clone per `produce()` on the hot path. Behavioural consequences: - A record configured with `.with_remote_access()` but **no buffer** now fails `build()` with a clear error (previously a silent runtime no-op — reads returned `not_found`, writes were discarded). Add a buffer, e.g. `.buffer(BufferCfg::SingleLatest)`. diff --git a/aimdb-core/Cargo.toml b/aimdb-core/Cargo.toml index 3793322..98f70eb 100644 --- a/aimdb-core/Cargo.toml +++ b/aimdb-core/Cargo.toml @@ -21,9 +21,8 @@ derive = ["aimdb-derive"] std = [ "alloc", # std includes alloc "serde", - "thiserror", "anyhow", - "json-serialize", + "remote-access", "tokio", "aimdb-executor/std", # AimX remote access now rides the shared session engine: `with_remote_access` @@ -34,36 +33,54 @@ std = [ # Heap allocation in no_std environments alloc = ["serde"] # Enable heap in no_std -# JSON codec (`crate::codec`): serde_json-backed `RemoteSerialize` / `JsonCodec`. -# no_std-compatible (serde_json runs on alloc); opt in on embedded targets to -# get `record.latest()?.as_json()` without std/AimX. `std` enables it for AimX. +# JSON *value* codec (`crate::codec`): serde_json-backed `RemoteSerialize` / +# `JsonCodec`, plus `RecordValue::as_json()`. no_std-compatible (serde_json runs +# on alloc); opt in on embedded targets to get `record.latest()?.as_json()` +# without any remote-access machinery. The `remote-access` feature builds on it. json-serialize = ["alloc", "serde_json"] +# AimX remote-access data model (`crate::remote`): record metadata + +# introspection (`RecordMetadata`, `AimDb::list_records`), the JSON +# read/write/subscribe API (`try_latest_as_json` / `set_record_from_json` / +# `JsonBufferReader`), and the wire protocol/config/security/error types. Builds on +# the `json-serialize` value codec and adds `thiserror` (no_std-capable; see below) +# for `RemoteError`. The AimX *server* dispatch (`session::aimx`) additionally +# needs `connector-session`. All compiles on `no_std + alloc`. +remote-access = ["json-serialize", "thiserror"] + # The connector-session substrate (`crate::session`): the dyn-safe trait set # (Connection/Listener/Dialer, Dispatch/EnvelopeCodec, Source + shared types) and # the runtime-neutral engines built on it — the reactive server (`serve`/ # `run_session`), the proactive client (`run_client`/`pump_client`), the # `pump_sink`/`pump_source` data plane, and the generic session connectors. Engine # logic included; all compiles on `no_std + alloc`. The AimX protocol port -# (`session::aimx`) additionally needs `json-serialize`. See the design doc: +# (`session::aimx`) additionally needs `remote-access`. See the design doc: # docs/design/remote-access-via-connectors.md. connector-session = ["alloc"] # Observability features (available on both std/no_std) -tracing = ["dep:tracing"] # Works in both std and no_std environments -defmt = ["dep:defmt"] # Embedded logging via probe (no_std) +tracing = ["dep:tracing"] # Works in both std and no_std environments +defmt = ["dep:defmt"] # Embedded logging via probe (no_std) # Buffer introspection counters (produced/consumed/dropped/occupancy). # Independent of `profiling`; works in no_std (only needs heap + atomics). # `portable-atomic/critical-section` provides the 64-bit-atomic fallback on # targets without native 64-bit atomics (e.g. thumbv7em); no-op elsewhere. -metrics = ["alloc", "portable-atomic/fallback", "portable-atomic/critical-section"] +metrics = [ + "alloc", + "portable-atomic/fallback", + "portable-atomic/critical-section", +] # Automatic stage profiling (.source()/.tap()/.link() timing). # Independent of `metrics`; works in no_std (only needs heap + a runtime clock). # `portable-atomic/critical-section` provides the 64-bit-atomic fallback on targets # without native 64-bit atomics (e.g. thumbv7em); it's a no-op where native atomics # exist, and embedded binaries already supply a `critical-section` impl. -profiling = ["alloc", "portable-atomic/fallback", "portable-atomic/critical-section"] +profiling = [ + "alloc", + "portable-atomic/fallback", + "portable-atomic/critical-section", +] # Testing features test-utils = ["std"] @@ -100,8 +117,8 @@ async-channel = { version = "2", default-features = false } # Serialization (optional) serde = { workspace = true, optional = true } -# Error handling - only for std environments -thiserror = { workspace = true, optional = true } +# Error handling +thiserror = { version = "2.0.16", default-features = false, optional = true } anyhow = { workspace = true, optional = true } # `raw_value` lets the connector-session `EnvelopeCodec` splice an already- # serialized record-value `Payload` into a JSON envelope without re-escaping diff --git a/aimdb-core/src/buffer/mod.rs b/aimdb-core/src/buffer/mod.rs index 1129a99..56b74ca 100644 --- a/aimdb-core/src/buffer/mod.rs +++ b/aimdb-core/src/buffer/mod.rs @@ -70,8 +70,8 @@ pub use traits::{Buffer, BufferReader, DynBuffer}; pub(crate) use traits::WriteHandle; pub(crate) use writer::RecordWriter; -// JSON streaming support (std only) -#[cfg(feature = "std")] +// JSON streaming support +#[cfg(feature = "remote-access")] pub use traits::JsonBufferReader; // Buffer metrics (feature-gated; works in no_std with portable-atomic) diff --git a/aimdb-core/src/buffer/traits.rs b/aimdb-core/src/buffer/traits.rs index e544443..842b45a 100644 --- a/aimdb-core/src/buffer/traits.rs +++ b/aimdb-core/src/buffer/traits.rs @@ -159,7 +159,7 @@ pub trait BufferReader: Send { fn try_recv(&mut self) -> Result; } -/// Reader trait for consuming JSON-serialized values from a buffer (std only) +/// Reader trait for consuming JSON-serialized values from a buffer /// /// Type-erased reader that subscribes to a typed buffer and emits values as /// `serde_json::Value`. Used by remote access protocol for subscriptions. @@ -169,7 +169,7 @@ pub trait BufferReader: Send { /// /// # Requirements /// - Record must be configured with `.with_remote_access()` -/// - Only available with `std` feature (requires serde_json) +/// - Only available with the `remote-access` feature (requires serde_json) /// /// # Example /// ```rust,ignore @@ -179,7 +179,7 @@ pub trait BufferReader: Send { /// // Forward JSON value to remote client... /// } /// ``` -#[cfg(feature = "std")] +#[cfg(feature = "remote-access")] pub trait JsonBufferReader: Send { /// Receive the next value as JSON (async) /// diff --git a/aimdb-core/src/buffer/writer.rs b/aimdb-core/src/buffer/writer.rs index e154302..6156ad8 100644 --- a/aimdb-core/src/buffer/writer.rs +++ b/aimdb-core/src/buffer/writer.rs @@ -1,8 +1,7 @@ //! `RecordWriter` — the sole implementor of `WriteHandle` (design 029). //! -//! Pre-binds the buffer and (std-only) metadata tracker so `Producer` can -//! push values without holding a `Arc>` or running a `HashMap` -//! lookup per call. +//! Pre-binds the buffer so `Producer` can push values without holding an +//! `Arc>` or running a `HashMap` lookup per call. #[cfg(not(feature = "std"))] extern crate alloc; @@ -18,23 +17,9 @@ use super::traits::{DynBuffer, WriteHandle}; pub(crate) struct RecordWriter { /// `None` for records without a configured buffer. buffer: Option>>, - - /// Metadata tracker (already `Clone` with shared inner `Arc` / - /// `Arc`). std-only. - #[cfg(feature = "std")] - metadata: crate::typed_record::RecordMetadataTracker, } impl RecordWriter { - #[cfg(feature = "std")] - pub(crate) fn new( - buffer: Option>>, - metadata: crate::typed_record::RecordMetadataTracker, - ) -> Self { - Self { buffer, metadata } - } - - #[cfg(not(feature = "std"))] pub(crate) fn new(buffer: Option>>) -> Self { Self { buffer } } @@ -44,8 +29,6 @@ impl WriteHandle for RecordWriter { fn push(&self, value: T) { if let Some(buf) = &self.buffer { buf.push(value); - #[cfg(feature = "std")] - self.metadata.mark_updated(); } } } diff --git a/aimdb-core/src/builder.rs b/aimdb-core/src/builder.rs index 20654dc..558c612 100644 --- a/aimdb-core/src/builder.rs +++ b/aimdb-core/src/builder.rs @@ -245,11 +245,11 @@ impl AimDbInner { Ok(typed_record) } - /// Collects metadata for all registered records (std only) + /// Collects metadata for all registered records /// - /// Returns a vector of `RecordMetadata` for remote access introspection. - /// Available only when the `std` feature is enabled. - #[cfg(feature = "std")] + /// Returns a vector of `RecordMetadata` for remote introspection. + /// Available only when the `remote-access` feature is enabled. + #[cfg(feature = "remote-access")] pub fn list_records(&self) -> Vec { self.storages .iter() @@ -263,7 +263,7 @@ impl AimDbInner { .collect() } - /// Try to get record's latest value as JSON by record key (std only) + /// Try to get record's latest value as JSON by record key /// /// O(1) lookup using the key-based index. /// @@ -272,7 +272,7 @@ impl AimDbInner { /// /// # Returns /// `Some(JsonValue)` with the current record value, or `None` - #[cfg(feature = "std")] + #[cfg(feature = "remote-access")] pub fn try_latest_as_json(&self, record_key: &str) -> Option { let id = self.resolve_str(record_key)?; self.storages.get(id.index())?.latest_json() @@ -293,7 +293,7 @@ impl AimDbInner { /// # Returns /// - `Ok(())` - Successfully set the value /// - `Err(DbError)` - If record not found, has producers, or deserialization fails - #[cfg(feature = "std")] + #[cfg(feature = "remote-access")] pub fn set_record_from_json( &self, record_key: &str, @@ -301,9 +301,7 @@ impl AimDbInner { ) -> DbResult<()> { let id = self .resolve_str(record_key) - .ok_or_else(|| DbError::RecordKeyNotFound { - key: record_key.to_string(), - })?; + .ok_or_else(|| DbError::record_key_not_found(record_key.to_string()))?; self.storages[id.index()].set_from_json(json_value) } @@ -1246,7 +1244,7 @@ impl AimDb { /// println!("Record: {} ({})", record.name, record.type_id); /// } /// ``` - #[cfg(feature = "std")] + #[cfg(feature = "remote-access")] pub fn list_records(&self) -> Vec { self.inner.list_records() } @@ -1267,7 +1265,7 @@ impl AimDb { } } - /// Try to get record's latest value as JSON by name (std only) + /// Try to get record's latest value as JSON by name /// /// Convenience wrapper around `AimDbInner::try_latest_as_json()`. /// @@ -1281,7 +1279,7 @@ impl AimDb { /// with no single "current value"; read it via a subscriber or `record.drain`, /// not a peek (`record.get`). Use [`SingleLatest`](crate::buffer::BufferCfg::SingleLatest) /// for state you want to read latest-value style. - #[cfg(feature = "std")] + #[cfg(feature = "remote-access")] pub fn try_latest_as_json(&self, record_name: &str) -> Option { self.inner.try_latest_as_json(record_name) } @@ -1304,7 +1302,7 @@ impl AimDb { /// ```rust,ignore /// db.set_record_from_json("AppConfig", json!({"debug": true}))?; /// ``` - #[cfg(feature = "std")] + #[cfg(feature = "remote-access")] pub fn set_record_from_json( &self, record_name: &str, diff --git a/aimdb-core/src/error.rs b/aimdb-core/src/error.rs index dcfaa9b..740ca2e 100644 --- a/aimdb-core/src/error.rs +++ b/aimdb-core/src/error.rs @@ -379,6 +379,44 @@ impl DbError { } } + /// Builds a [`RuntimeError`](DbError::RuntimeError). The message is carried + /// on `std` and dropped on `no_std` (where the variant holds a unit + /// placeholder); lets callers write one expression across both targets. + /// Gated on `remote-access` — its only callers are the JSON/remote paths. + #[cfg(feature = "remote-access")] + pub(crate) fn runtime_error(_message: impl Into) -> Self { + DbError::RuntimeError { + #[cfg(feature = "std")] + message: _message.into(), + #[cfg(not(feature = "std"))] + _message: (), + } + } + + /// Builds a [`PermissionDenied`](DbError::PermissionDenied). The operation + /// detail is carried on `std` and dropped on `no_std`. + #[cfg(feature = "remote-access")] + pub(crate) fn permission_denied(_operation: impl Into) -> Self { + DbError::PermissionDenied { + #[cfg(feature = "std")] + operation: _operation.into(), + #[cfg(not(feature = "std"))] + _operation: (), + } + } + + /// Builds a [`RecordKeyNotFound`](DbError::RecordKeyNotFound). The key is + /// carried on `std` and dropped on `no_std`. + #[cfg(feature = "remote-access")] + pub(crate) fn record_key_not_found(_key: impl Into) -> Self { + DbError::RecordKeyNotFound { + #[cfg(feature = "std")] + key: _key.into(), + #[cfg(not(feature = "std"))] + _key: (), + } + } + /// Returns true if this is a network-related error pub fn is_network_error(&self) -> bool { matches!(self, DbError::ConnectionFailed { .. }) diff --git a/aimdb-core/src/graph.rs b/aimdb-core/src/graph.rs index 3c5de61..5699296 100644 --- a/aimdb-core/src/graph.rs +++ b/aimdb-core/src/graph.rs @@ -22,8 +22,8 @@ use alloc::{ /// How a record gets its values — part of the dependency graph. #[derive(Clone, Debug)] -#[cfg_attr(feature = "std", derive(serde::Serialize, serde::Deserialize))] -#[cfg_attr(feature = "std", serde(rename_all = "snake_case"))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] pub enum RecordOrigin { /// Autonomous producer via `.source()` Source, @@ -43,7 +43,7 @@ pub enum RecordOrigin { /// Metadata for one node in the dependency graph. #[derive(Clone, Debug)] -#[cfg_attr(feature = "std", derive(serde::Serialize))] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] pub struct GraphNode { /// Record key (e.g. "temp.vienna") pub key: String, @@ -65,7 +65,7 @@ pub struct GraphNode { /// One directed edge in the dependency graph. #[derive(Clone, Debug)] -#[cfg_attr(feature = "std", derive(serde::Serialize))] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] pub struct GraphEdge { /// Source record key (None for external origins like source/link) pub from: Option, @@ -77,8 +77,8 @@ pub struct GraphEdge { /// Classification of a dependency graph edge. #[derive(Clone, Debug)] -#[cfg_attr(feature = "std", derive(serde::Serialize))] -#[cfg_attr(feature = "std", serde(rename_all = "snake_case"))] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] pub enum EdgeType { Source, Link { protocol: String }, diff --git a/aimdb-core/src/lib.rs b/aimdb-core/src/lib.rs index 89d3132..5e26208 100644 --- a/aimdb-core/src/lib.rs +++ b/aimdb-core/src/lib.rs @@ -32,7 +32,7 @@ pub mod graph; #[cfg(feature = "profiling")] pub mod profiling; pub mod record_id; -#[cfg(feature = "std")] +#[cfg(feature = "remote-access")] pub mod remote; pub mod router; #[cfg(feature = "connector-session")] @@ -44,7 +44,8 @@ pub mod typed_api; pub mod typed_record; /// Marker trait used to add a `TimeOps` requirement to runtime-agnostic builder -/// methods *only* when the `profiling` feature is enabled. +/// methods *only* when the `profiling` feature is enabled (stage-duration timing +/// needs the runtime clock). /// /// When `profiling` is off this is a blanket no-op (every `R` implements it), so /// the public API is unchanged. When `profiling` is on it requires diff --git a/aimdb-core/src/remote/config.rs b/aimdb-core/src/remote/config.rs index 8249b9e..c189e3e 100644 --- a/aimdb-core/src/remote/config.rs +++ b/aimdb-core/src/remote/config.rs @@ -1,6 +1,8 @@ //! Configuration types for AimX remote access -use std::{collections::HashSet, path::PathBuf, string::String, vec::Vec}; +use alloc::string::String; +use alloc::vec::Vec; +use hashbrown::HashSet; use crate::record_id::StringKey; @@ -11,7 +13,7 @@ use crate::record_id::StringKey; #[derive(Debug, Clone)] pub struct AimxConfig { /// Path to Unix domain socket - pub socket_path: PathBuf, + pub socket_path: String, /// Security policy (read-only or read-write) pub security_policy: SecurityPolicy, @@ -48,7 +50,7 @@ impl AimxConfig { /// - Socket permissions: 0o600 (owner-only) pub fn uds_default() -> Self { Self { - socket_path: PathBuf::from("/tmp/aimdb.sock"), + socket_path: String::from("/tmp/aimdb.sock"), security_policy: SecurityPolicy::ReadOnly, max_connections: 16, max_subs_per_connection: 32, @@ -58,7 +60,7 @@ impl AimxConfig { } /// Sets the socket path - pub fn socket_path(mut self, path: impl Into) -> Self { + pub fn socket_path(mut self, path: impl Into) -> Self { self.socket_path = path.into(); self } @@ -210,7 +212,7 @@ mod tests { #[cfg(feature = "std")] fn test_default_config() { let config = AimxConfig::uds_default(); - assert_eq!(config.socket_path, PathBuf::from("/tmp/aimdb.sock")); + assert_eq!(config.socket_path, "/tmp/aimdb.sock"); assert_eq!(config.max_connections, 16); assert_eq!(config.max_subs_per_connection, 32); assert!(matches!(config.security_policy, SecurityPolicy::ReadOnly)); @@ -227,7 +229,7 @@ mod tests { .auth_token("secret-token") .socket_permissions(0o660); - assert_eq!(config.socket_path, PathBuf::from("/var/run/aimdb.sock")); + assert_eq!(config.socket_path, "/var/run/aimdb.sock"); assert_eq!(config.max_connections, 32); assert_eq!(config.max_subs_per_connection, 8); assert_eq!(config.auth_token, Some("secret-token".to_string())); diff --git a/aimdb-core/src/remote/error.rs b/aimdb-core/src/remote/error.rs index c27de55..25b0bfb 100644 --- a/aimdb-core/src/remote/error.rs +++ b/aimdb-core/src/remote/error.rs @@ -1,6 +1,7 @@ //! Error types for AimX remote access protocol -use std::string::String; +use alloc::format; +use alloc::string::{String, ToString}; use thiserror::Error; /// Error type for remote access operations @@ -96,26 +97,22 @@ pub type RemoteResult = Result; impl From for RemoteError { fn from(err: crate::DbError) -> Self { use crate::DbError; + let message = err.to_string(); match err { - DbError::RecordNotFound { record_name } => RemoteError::NotFound { - resource: format!("record '{}'", record_name), - }, - DbError::InvalidOperation { operation, reason } => RemoteError::ValidationError { - message: format!("{}: {}", operation, reason), - }, - DbError::BufferFull { buffer_name, .. } => RemoteError::QueueFull { - queue_name: buffer_name, - }, - DbError::PermissionDenied { operation } => { - RemoteError::PermissionDenied { reason: operation } + DbError::RecordNotFound { .. } | DbError::RecordKeyNotFound { .. } => { + RemoteError::NotFound { resource: message } } - _ => RemoteError::InternalError { - message: err.to_string(), + DbError::InvalidOperation { .. } => RemoteError::ValidationError { message }, + DbError::BufferFull { .. } => RemoteError::QueueFull { + queue_name: message, }, + DbError::PermissionDenied { .. } => RemoteError::PermissionDenied { reason: message }, + _ => RemoteError::InternalError { message }, } } } +#[cfg(feature = "std")] impl From for RemoteError { fn from(err: std::io::Error) -> Self { RemoteError::InternalError { diff --git a/aimdb-core/src/remote/metadata.rs b/aimdb-core/src/remote/metadata.rs index 3870464..4299533 100644 --- a/aimdb-core/src/remote/metadata.rs +++ b/aimdb-core/src/remote/metadata.rs @@ -1,8 +1,17 @@ -//! Record metadata types for remote introspection - +//! Record metadata types for remote introspection (feature `remote-access`). +//! +//! [`RecordMetadata`] describes a registered record's runtime state — buffer +//! configuration, producer/consumer counts, the `writable` flag, and +//! (feature-gated) buffer metrics / stage profiling — for the AimX `record.list` +//! response. Computed on demand from a record's static structure; core keeps no +//! per-record metadata state. + +use alloc::format; +use alloc::string::{String, ToString}; +#[cfg(feature = "profiling")] +use alloc::vec::Vec; use core::any::TypeId; use serde::{Deserialize, Serialize}; -use std::string::String; use crate::graph::RecordOrigin; use crate::record_id::{RecordId, RecordKey}; @@ -10,7 +19,7 @@ use crate::record_id::{RecordId, RecordKey}; /// Metadata about a registered record type /// /// Provides information for remote introspection, including buffer -/// configuration, producer/consumer counts, and timestamps. +/// configuration and producer/consumer counts. /// /// When the `metrics` feature is enabled, additional fields are included /// for buffer-level statistics (produced_count, consumed_count, etc.). @@ -47,13 +56,6 @@ pub struct RecordMetadata { /// Whether write operations are permitted for this record pub writable: bool, - /// When the record was registered (ISO 8601) - pub created_at: String, - - /// Last update timestamp (ISO 8601), None if never updated - #[serde(skip_serializing_if = "Option::is_none")] - pub last_update: Option, - /// Number of outbound connector links registered pub outbound_connector_count: usize, @@ -83,7 +85,7 @@ pub struct RecordMetadata { /// `profiling` feature is enabled and any stage has been registered. #[cfg(feature = "profiling")] #[serde(skip_serializing_if = "Option::is_none")] - pub stage_profiling: Option>, + pub stage_profiling: Option>, } impl RecordMetadata { @@ -100,7 +102,6 @@ impl RecordMetadata { /// * `producer_count` - Number of producers /// * `consumer_count` - Number of consumers /// * `writable` - Whether writes are permitted - /// * `created_at` - Creation timestamp (ISO 8601) /// * `outbound_connector_count` - Number of outbound connectors #[allow(clippy::too_many_arguments)] pub fn new( @@ -114,7 +115,6 @@ impl RecordMetadata { producer_count: usize, consumer_count: usize, writable: bool, - created_at: String, outbound_connector_count: usize, ) -> Self { Self { @@ -128,8 +128,6 @@ impl RecordMetadata { producer_count, consumer_count, writable, - created_at, - last_update: None, outbound_connector_count, #[cfg(feature = "metrics")] produced_count: None, @@ -144,18 +142,6 @@ impl RecordMetadata { } } - /// Sets the last update timestamp - pub fn with_last_update(mut self, timestamp: String) -> Self { - self.last_update = Some(timestamp); - self - } - - /// Sets the last update timestamp from an Option - pub fn with_last_update_opt(mut self, timestamp: Option) -> Self { - self.last_update = timestamp; - self - } - /// Sets buffer metrics from a snapshot (metrics feature only) /// /// Populates produced_count, consumed_count, dropped_count, and occupancy @@ -176,7 +162,7 @@ impl RecordMetadata { #[cfg(feature = "profiling")] pub fn with_stage_profiling( mut self, - stages: std::vec::Vec, + stages: Vec, ) -> Self { if !stages.is_empty() { self.stage_profiling = Some(stages); @@ -204,7 +190,6 @@ mod tests { 1, 2, false, - "2025-10-31T10:00:00.000Z".to_string(), 0, ); @@ -234,10 +219,8 @@ mod tests { 1, 1, true, - "2025-10-31T10:00:00.000Z".to_string(), 2, - ) - .with_last_update("2025-10-31T12:00:00.000Z".to_string()); + ); let json = serde_json::to_string(&metadata).unwrap(); assert!(json.contains("\"record_id\":1")); diff --git a/aimdb-core/src/remote/mod.rs b/aimdb-core/src/remote/mod.rs index eda763c..e94e792 100644 --- a/aimdb-core/src/remote/mod.rs +++ b/aimdb-core/src/remote/mod.rs @@ -51,5 +51,5 @@ pub use protocol::{ErrorObject, Event, HelloMessage, Request, Response, WelcomeM pub use query::{QueryHandlerFn, QueryHandlerParams}; // Internal exports for implementation -#[cfg(feature = "std")] +#[cfg(feature = "connector-session")] pub(crate) mod stream; diff --git a/aimdb-core/src/remote/protocol.rs b/aimdb-core/src/remote/protocol.rs index a4b4b24..07d0116 100644 --- a/aimdb-core/src/remote/protocol.rs +++ b/aimdb-core/src/remote/protocol.rs @@ -2,9 +2,10 @@ //! //! Defines request, response, and event types for the remote access protocol. +use alloc::string::{String, ToString}; +use alloc::vec::Vec; use serde::{Deserialize, Serialize}; use serde_json::Value as JsonValue; -use std::{string::String, vec::Vec}; // Allow dead code for now - these are part of the public API for future implementation #[allow(dead_code)] @@ -242,6 +243,7 @@ impl Message { #[cfg(test)] mod tests { use super::*; + use alloc::vec; #[test] fn test_hello_serialization() { diff --git a/aimdb-core/src/remote/query.rs b/aimdb-core/src/remote/query.rs index b2595ea..8c5e08f 100644 --- a/aimdb-core/src/remote/query.rs +++ b/aimdb-core/src/remote/query.rs @@ -5,6 +5,9 @@ //! database's `Extensions` TypeMap by `aimdb_persistence::with_persistence()`, //! and invoked by the AimX server dispatch when a client calls `record.query`. +use alloc::boxed::Box; +use alloc::string::String; + /// Type-erased query handler registered by `aimdb-persistence` via Extensions. /// /// A boxed async function that accepts query parameters (record pattern, limit, diff --git a/aimdb-core/src/remote/stream.rs b/aimdb-core/src/remote/stream.rs index 1c9bf89..a594d63 100644 --- a/aimdb-core/src/remote/stream.rs +++ b/aimdb-core/src/remote/stream.rs @@ -7,12 +7,11 @@ //! connection's `FuturesUnordered` is the sole owner of the subscription's //! lifecycle. -#[cfg(feature = "std")] +use alloc::string::ToString; + use crate::{AimDb, DbError, DbResult}; -#[cfg(feature = "std")] use futures_core::Stream; -#[cfg(feature = "std")] use futures_util::stream::unfold; /// Subscribe to a record and yield each update as a JSON value. @@ -27,7 +26,6 @@ use futures_util::stream::unfold; /// - [`DbError::InvalidRecordId`] if the resolved id has no storage. /// - Any error returned by `subscribe_json()` (e.g. the record was not /// configured with `.with_remote_access()`). -#[cfg(feature = "std")] pub(crate) fn stream_record_updates( db: &AimDb, record_key: &str, @@ -38,9 +36,7 @@ where let inner = db.inner(); let id = inner .resolve_str(record_key) - .ok_or_else(|| DbError::RecordKeyNotFound { - key: record_key.to_string(), - })?; + .ok_or_else(|| DbError::record_key_not_found(record_key.to_string()))?; let record = inner .storage(id) .ok_or(DbError::InvalidRecordId { id: id.raw() })?; diff --git a/aimdb-core/src/session/aimx/dispatch.rs b/aimdb-core/src/session/aimx/dispatch.rs index 76220bd..4eaa304 100644 --- a/aimdb-core/src/session/aimx/dispatch.rs +++ b/aimdb-core/src/session/aimx/dispatch.rs @@ -1,10 +1,13 @@ -//! AimX server dispatch (`std`-only) — the method semantics of AimX remote -//! access, served on the shared session engine (`serve`/`run_session`). +//! AimX server dispatch (`no_std + alloc`; features `connector-session` + +//! `remote-access`) — the method semantics of AimX remote access, served on the +//! shared session engine (`serve`/`run_session`). //! -//! `std`-gated because it reaches into core's `record.list` / JSON API (the -//! `AnyRecord` JSON + metadata methods). A transport pairs this dispatch with the -//! generic [`SessionServerConnector`](crate::session::SessionServerConnector) — -//! see `aimdb-uds-connector`'s `UdsServer`. +//! Reaches into core's `record.list` / JSON API (the `AnyRecord` JSON + metadata +//! methods), which are gated on `remote-access` and de-std'd alongside this +//! dispatch, so a board can serve a host over serial. A transport pairs this +//! dispatch with the generic +//! [`SessionServerConnector`](crate::session::SessionServerConnector) — see +//! `aimdb-uds-connector`'s `UdsServer`. //! //! The role is split in two: //! - [`AimxDispatch`] — the shared half (one `Arc` per server): peer-only @@ -15,8 +18,12 @@ //! Param shapes follow the client ([`aimdb_client::AimxConnection`]): //! `record.get`/`record.set` take `{name[, value]}`, `write` takes `{value}`. -use std::collections::HashMap; -use std::sync::Arc; +use alloc::boxed::Box; +use alloc::string::{String, ToString}; +use alloc::sync::Arc; +use alloc::vec; +use alloc::vec::Vec; +use hashbrown::HashMap; use futures_util::StreamExt; use serde_json::{json, Value}; @@ -300,7 +307,7 @@ where let (permissions, writable_records) = match &self.config.security_policy { SecurityPolicy::ReadOnly => (vec!["read".to_string()], Vec::new()), SecurityPolicy::ReadWrite { writable_records } => { - let existing: std::collections::HashSet = self + let existing: hashbrown::HashSet = self .db .list_records() .into_iter() diff --git a/aimdb-core/src/session/aimx/mod.rs b/aimdb-core/src/session/aimx/mod.rs index 9a6f5db..a4f8f6b 100644 --- a/aimdb-core/src/session/aimx/mod.rs +++ b/aimdb-core/src/session/aimx/mod.rs @@ -4,8 +4,9 @@ //! - [`AimxCodec`] — the symmetric NDJSON [`EnvelopeCodec`](crate::session::EnvelopeCodec), //! `no_std + alloc` (features `connector-session` + `json-serialize`); used by //! both the `run_client` and `serve` engines. -//! - [`AimxDispatch`] — the server method semantics, `std`-only (it reaches into -//! core's `record.list` / JSON API). +//! - [`AimxDispatch`] — the server method semantics, `no_std + alloc` (features +//! `connector-session` + `remote-access`); it reaches into core's +//! `record.list` / JSON API, which are gated on `remote-access` too. //! //! The transport (UDS) lives in a separate connector crate //! (`aimdb-uds-connector`); core keeps only the protocol plus the generic @@ -15,7 +16,7 @@ mod codec; pub use codec::AimxCodec; -#[cfg(feature = "std")] +#[cfg(feature = "remote-access")] mod dispatch; -#[cfg(feature = "std")] +#[cfg(feature = "remote-access")] pub use dispatch::AimxDispatch; diff --git a/aimdb-core/src/session/mod.rs b/aimdb-core/src/session/mod.rs index 60266ed..825376c 100644 --- a/aimdb-core/src/session/mod.rs +++ b/aimdb-core/src/session/mod.rs @@ -31,9 +31,8 @@ mod pump; #[cfg(feature = "connector-session")] mod server; -// Concrete AimX protocol substrate. The codec is `no_std + alloc`; the server -// dispatch is `std`-gated. The transport lives in a separate connector crate -// (`aimdb-uds-connector`) — core keeps the protocol plus the generic +// Concrete AimX protocol substrate. The transport lives in a separate connector +// crate (`aimdb-uds-connector`) — core keeps the protocol plus the generic // [`SessionClientConnector`] / [`SessionServerConnector`] spine. #[cfg(all(feature = "connector-session", feature = "json-serialize"))] pub mod aimx; diff --git a/aimdb-core/src/typed_record.rs b/aimdb-core/src/typed_record.rs index 3196fe5..91ece48 100644 --- a/aimdb-core/src/typed_record.rs +++ b/aimdb-core/src/typed_record.rs @@ -4,11 +4,17 @@ //! //! # Feature Support //! -//! **Both std and no_std**: Core API (`TypedRecord`, `latest()`, `RecordValue`, producer/consumer) +//! All layers below work on both `std` and `no_std + alloc`; they are gated by +//! capability feature, not by `std`: //! -//! **std only**: JSON serialization (`.with_remote_access()`, `.as_json()`), remote access, metadata +//! - **always**: Core API (`TypedRecord`, `latest()`, `RecordValue`, producer/consumer). +//! - **`json-serialize`**: the JSON value codec — `.with_remote_access()` installs it +//! and `record.latest()?.as_json()` reads it. +//! - **`remote-access`**: record metadata (`collect_metadata` → `RecordMetadata`, +//! the `writable` flag) plus the type-erased JSON read/write/subscribe methods +//! (`latest_json` / `set_from_json` / `subscribe_json`) used by the AimX server. //! -//! **no_std**: Use `record.latest()` for value access and `Deref` for fields. JSON requires std; +//! Without `json-serialize`, use `record.latest()` + `Deref` for value access and //! implement custom serialization for embedded protocols (CBOR, MessagePack, etc.). use core::any::Any; @@ -123,7 +129,7 @@ impl core::ops::Deref for RecordValue { /// 3. Returns the JSON value /// /// Used internally by `TypedRecord::subscribe_json()`. -#[cfg(feature = "std")] +#[cfg(feature = "remote-access")] struct JsonReaderAdapter { /// The underlying typed buffer reader inner: Box + Send>, @@ -131,7 +137,7 @@ struct JsonReaderAdapter { codec: RecordCodec, } -#[cfg(feature = "std")] +#[cfg(feature = "remote-access")] impl crate::buffer::JsonBufferReader for JsonReaderAdapter { fn recv_json( &mut self, @@ -147,18 +153,9 @@ impl crate::buffer::JsonBufferReader for JsonReaderAd let value = self.inner.recv().await?; // Serialize to JSON - self.codec.encode(&value).ok_or_else(|| { - #[cfg(feature = "std")] - { - crate::DbError::RuntimeError { - message: "Failed to serialize value to JSON".to_string(), - } - } - #[cfg(not(feature = "std"))] - { - crate::DbError::RuntimeError { _message: () } - } - }) + self.codec + .encode(&value) + .ok_or_else(|| crate::DbError::runtime_error("Failed to serialize value to JSON")) }) } @@ -169,63 +166,7 @@ impl crate::buffer::JsonBufferReader for JsonReaderAd // Serialize to JSON using the configured codec self.codec .encode(&value) - .ok_or_else(|| crate::DbError::RuntimeError { - message: "Failed to serialize value to JSON".to_string(), - }) - } -} - -/// Metadata tracking for records (std only - used for remote access introspection) -#[cfg(feature = "std")] -#[derive(Debug, Clone)] -pub(crate) struct RecordMetadataTracker { - /// Human-readable record name (type name) - name: String, - /// Creation timestamp (seconds, nanoseconds since UNIX_EPOCH) - created_at: (u64, u32), - /// Last update timestamp (seconds, nanoseconds since UNIX_EPOCH, None if never updated) - last_update: std::sync::Arc>>, - /// Whether this record allows writes via remote access (using interior mutability) - writable: std::sync::Arc, -} - -#[cfg(feature = "std")] -impl RecordMetadataTracker { - fn new() -> Self { - use std::time::SystemTime; - - let duration = SystemTime::now() - .duration_since(SystemTime::UNIX_EPOCH) - .unwrap_or_default(); - - Self { - name: core::any::type_name::().to_string(), - created_at: (duration.as_secs(), duration.subsec_nanos()), - last_update: Arc::new(std::sync::Mutex::new(None)), - writable: Arc::new(std::sync::atomic::AtomicBool::new(false)), - } - } - - pub(crate) fn mark_updated(&self) { - use std::time::SystemTime; - - let duration = SystemTime::now() - .duration_since(SystemTime::UNIX_EPOCH) - .unwrap_or_default(); - - if let Ok(mut last) = self.last_update.lock() { - *last = Some((duration.as_secs(), duration.subsec_nanos())); - } - } - - fn set_writable(&self, writable: bool) { - self.writable - .store(writable, std::sync::atomic::Ordering::SeqCst); - } - - /// Formats a Unix timestamp as "secs.nanosecs" string - fn format_timestamp(timestamp: (u64, u32)) -> String { - format!("{}.{:09}", timestamp.0, timestamp.1) + .ok_or_else(|| crate::DbError::runtime_error("Failed to serialize value to JSON")) } } @@ -322,8 +263,8 @@ pub trait AnyRecord: Send + Sync { /// Used internally by the builder to apply security policy to records. fn set_writable_erased(&self, writable: bool); - /// Collects metadata for this record (std only) - #[cfg(feature = "std")] + /// Collects metadata for this record + #[cfg(feature = "remote-access")] fn collect_metadata( &self, type_id: core::any::TypeId, @@ -331,14 +272,14 @@ pub trait AnyRecord: Send + Sync { id: crate::record_id::RecordId, ) -> crate::remote::RecordMetadata; - /// Internal: Returns JSON for type-erased remote access (std only) + /// Internal: Returns JSON for type-erased remote access /// /// Used internally by remote access protocol. **Users should use `record.latest()?.as_json()`.** #[doc(hidden)] - #[cfg(feature = "std")] + #[cfg(feature = "remote-access")] fn latest_json(&self) -> Option; - /// Subscribe to record updates as JSON stream (std only) + /// Subscribe to record updates as JSON stream /// /// Creates a type-erased subscription that emits `serde_json::Value` instead of /// the concrete type `T`. This enables subscribing to a record without knowing @@ -366,10 +307,10 @@ pub trait AnyRecord: Send + Sync { /// } /// ``` #[doc(hidden)] - #[cfg(feature = "std")] + #[cfg(feature = "remote-access")] fn subscribe_json(&self) -> crate::DbResult>; - /// Sets a record value from JSON (std only) + /// Sets a record value from JSON /// /// Deserializes JSON and produces the value to the record's buffer. /// @@ -401,7 +342,7 @@ pub trait AnyRecord: Send + Sync { /// record.set_from_json(json_val)?; // Only works if producer_count == 0 /// ``` #[doc(hidden)] - #[cfg(feature = "std")] + #[cfg(feature = "remote-access")] fn set_from_json(&self, json_value: serde_json::Value) -> crate::DbResult<()>; /// Get the inbound connector links for this record @@ -580,16 +521,18 @@ pub struct TypedRecord< #[cfg(feature = "profiling")] profiling: RecordProfilingMetrics, - /// Metadata tracking (std only - for remote access) - #[cfg(feature = "std")] - metadata: RecordMetadataTracker, + /// Whether this record allows writes via remote access (feature + /// `remote-access`). Interior-mutable so the security policy can mark it after + /// `build()`; read by `collect_metadata` into `RecordMetadata.writable`. + #[cfg(feature = "remote-access")] + writable: portable_atomic::AtomicBool, - /// Type-erased JSON codec (feature `json-serialize`). + /// Type-erased JSON value codec (feature `json-serialize`). /// `Some` iff the record opted into JSON via `.with_remote_access()`. - /// `RecordValue::as_json` and — on std — the AimX read (`latest_json`), - /// write (`set_from_json`), and subscribe (`subscribe_json`) paths route - /// through it. Built from a `SerdeJsonCodec` where the `T: RemoteSerialize` - /// bound is known at the call site. + /// `RecordValue::as_json` and — under `remote-access` — the AimX read + /// (`latest_json`), write (`set_from_json`), and subscribe (`subscribe_json`) + /// paths route through it. Built from a `SerdeJsonCodec` where the + /// `T: RemoteSerialize` bound is known at the call site. #[cfg(feature = "json-serialize")] remote_codec: Option>, } @@ -611,8 +554,8 @@ impl(), + #[cfg(feature = "remote-access")] + writable: portable_atomic::AtomicBool::new(false), #[cfg(feature = "json-serialize")] remote_codec: None, } @@ -869,24 +812,14 @@ impl>` bound to this record's buffer, - /// snapshot, and metadata. Used at build time by the spawn machinery to - /// pre-resolve `Producer` handles (design 029). + /// Returns a fresh `Arc>` bound to this record's buffer. + /// Used at build time by the spawn machinery to pre-resolve `Producer` + /// handles (design 029). pub(crate) fn writer_handle(&self) -> Arc> where T: Send + Clone + 'static, { - #[cfg(feature = "std")] - { - Arc::new(crate::buffer::RecordWriter::new( - self.buffer.clone(), - self.metadata.clone(), - )) - } - #[cfg(not(feature = "std"))] - { - Arc::new(crate::buffer::RecordWriter::new(self.buffer.clone())) - } + Arc::new(crate::buffer::RecordWriter::new(self.buffer.clone())) } /// Returns a clone of the buffer `Arc` (or `None` if no buffer is @@ -924,12 +857,13 @@ impl &mut Self where @@ -1130,10 +1064,11 @@ impl().to_string(), self.record_origin(), buffer_type, buffer_capacity, if self.has_producer() { 1 } else { 0 }, self.consumer_count(), - self.metadata - .writable - .load(std::sync::atomic::Ordering::SeqCst), - RecordMetadataTracker::format_timestamp(self.metadata.created_at), + self.writable.load(portable_atomic::Ordering::SeqCst), self.outbound_connector_count(), - ) - .with_last_update_opt(last_update); + ); // Add buffer metrics if available #[cfg(feature = "metrics")] @@ -1350,7 +1276,7 @@ impl Option { #[cfg(feature = "tracing")] tracing::debug!( @@ -1370,7 +1296,7 @@ impl crate::DbResult> { use crate::DbError; @@ -1381,16 +1307,13 @@ impl() - ), - })?; + let codec = self.remote_codec.clone().ok_or_else(|| { + DbError::runtime_error(alloc::format!( + "Record '{}' not configured with .with_remote_access(). \ + Cannot subscribe to JSON stream.", + core::any::type_name::() + )) + })?; // 2. Subscribe to the buffer (get Box>) let reader = self.subscribe()?; @@ -1411,7 +1334,7 @@ impl crate::DbResult<()> { use crate::DbError; @@ -1429,49 +1352,40 @@ impl() ); - return Err(DbError::PermissionDenied { - operation: format!( - "Cannot set record '{}' - has active producer or transform. \ - Use internal application logic instead. \ - Remote access can only set configuration records without producers.", - core::any::type_name::() - ), - }); + return Err(DbError::permission_denied(alloc::format!( + "Cannot set record '{}' - has active producer or transform. \ + Use internal application logic instead. \ + Remote access can only set configuration records without producers.", + core::any::type_name::() + ))); } // Check if the codec is configured (set by .with_remote_access()) - let codec = self - .remote_codec - .clone() - .ok_or_else(|| DbError::RuntimeError { - message: format!( - "Record '{}' not configured with .with_remote_access(). \ - Cannot deserialize from JSON.", - core::any::type_name::() - ), - })?; + let codec = self.remote_codec.clone().ok_or_else(|| { + DbError::runtime_error(alloc::format!( + "Record '{}' not configured with .with_remote_access(). \ + Cannot deserialize from JSON.", + core::any::type_name::() + )) + })?; // Check if buffer exists if self.buffer.is_none() { - return Err(DbError::RuntimeError { - message: format!( - "Record '{}' has no buffer configured. \ - Cannot produce value without buffer.", - core::any::type_name::() - ), - }); + return Err(DbError::runtime_error(alloc::format!( + "Record '{}' has no buffer configured. \ + Cannot produce value without buffer.", + core::any::type_name::() + ))); } // Deserialize JSON -> T - let value: T = codec - .decode(&json_value) - .ok_or_else(|| DbError::RuntimeError { - message: format!( - "Failed to deserialize JSON to type '{}'. \ + let value: T = codec.decode(&json_value).ok_or_else(|| { + DbError::runtime_error(alloc::format!( + "Failed to deserialize JSON to type '{}'. \ JSON structure does not match the expected schema.", - core::any::type_name::() - ), - })?; + core::any::type_name::() + )) + })?; #[cfg(feature = "tracing")] tracing::debug!( diff --git a/aimdb-embassy-adapter/CHANGELOG.md b/aimdb-embassy-adapter/CHANGELOG.md index 9d93630..d739070 100644 --- a/aimdb-embassy-adapter/CHANGELOG.md +++ b/aimdb-embassy-adapter/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **Wall-clock anchor — `EmbassyAdapter::set_unix_time(now_unix_secs)` + `TimeOps::unix_time()` (Issue #120).** Embassy has no real-time clock, so `unix_time()` returns `None` until the device learns the real time (NTP / GPS / host handshake) and calls `set_unix_time` once (Unix **seconds**); thereafter it derives absolute `(secs, nanos)` from Embassy's monotonic uptime plus the anchor. The anchor is a process-global `u32` (Unix seconds at boot) — a natively-atomic word on Cortex-M, no `portable-atomic` / critical-section needed — with sub-second precision taken from uptime. Gated on `embassy-time`. - **`SendFutureWrapper` — shared force-`Send` wrapper for Embassy data-plane connectors (Issue #39).** New `pub` type (`no_std` only): asserts `Send` for a future driven exclusively by a single-core, cooperative Embassy executor, so an Embassy connector's `!Send` data-plane futures (over `NoopRawMutex` channels) satisfy the connector spine's `Send` `BoxFuture` bound. Consolidates the identical wrappers that the KNX and MQTT Embassy clients previously each hand-rolled. - **Session-engine smoke test on the Embassy clock (Issue #39, Phase 5, [design doc](../docs/design/remote-access-via-connectors.md)).** New `tests/session_smoke.rs` drives `aimdb-core`'s runtime-neutral `run_client` engine using the `EmbassyAdapter`'s `TimeOps` clock for reconnect backoff / keepalive — proving the shared session engines run on Embassy, not just Tokio. Dev-only: pulls in `aimdb-core` with the `connector-session` feature, so the normal `no_std` lib build and the `thumbv7em` cross-checks stay `alloc`-only. - **`EmbassyBuffer::peek()` (M15, Design 031).** Non-destructive buffer-native read matching the Tokio adapter's semantics: `SingleLatest` (`Watch`) via `Watch::try_get()`, `Mailbox` (`Channel<_, T, 1>`) via `Channel::try_peek()`, `SpmcRing` (`PubSubChannel`) returns `None`. Neither path consumes a receiver slot or advances a cursor. diff --git a/aimdb-embassy-adapter/src/runtime.rs b/aimdb-embassy-adapter/src/runtime.rs index 900445c..1f3e04c 100644 --- a/aimdb-embassy-adapter/src/runtime.rs +++ b/aimdb-embassy-adapter/src/runtime.rs @@ -120,6 +120,22 @@ impl EmbassyAdapter { network: Some(network), } } + + /// Anchors wall-clock time so [`TimeOps::unix_time`](aimdb_executor::TimeOps::unix_time) + /// can derive absolute timestamps from Embassy's monotonic uptime. + /// + /// Embassy has no real-time clock, so `unix_time` returns `None` until this is + /// called — typically once, after the device learns the real time (NTP / GPS / + /// a host handshake). `now_unix_secs` is the current Unix time in **seconds**; + /// the anchor is at second granularity (sub-second precision then comes from + /// uptime). The anchor is process-global (shared by all adapter clones). + #[cfg(feature = "embassy-time")] + pub fn set_unix_time(now_unix_secs: u64) { + use core::sync::atomic::Ordering; + let uptime_secs = embassy_time::Instant::now().as_secs(); + let boot = now_unix_secs.saturating_sub(uptime_secs) as u32; + BOOT_UNIX_SECS.store(boot, Ordering::Relaxed); + } } impl Default for EmbassyAdapter { @@ -136,6 +152,15 @@ impl RuntimeAdapter for EmbassyAdapter { } } +/// Wall-clock anchor for [`TimeOps::unix_time`](aimdb_executor::TimeOps::unix_time): +/// Unix **seconds** at Embassy uptime 0 (boot). `0` = unset (Embassy has no RTC), +/// so `unix_time` returns `None` until [`EmbassyAdapter::set_unix_time`] is called. +/// A `u32` keeps the anchor in a natively-atomic word on Cortex-M (no +/// `portable-atomic` / critical-section needed); sub-second precision comes from +/// uptime. +#[cfg(feature = "embassy-time")] +static BOOT_UNIX_SECS: core::sync::atomic::AtomicU32 = core::sync::atomic::AtomicU32::new(0); + // Implement TimeOps trait for time operations #[cfg(feature = "embassy-time")] impl aimdb_executor::TimeOps for EmbassyAdapter { @@ -179,6 +204,22 @@ impl aimdb_executor::TimeOps for EmbassyAdapter { // microsecond granularity is the portable lower bound. duration.as_micros().saturating_mul(1_000) } + + /// Wall-clock time, derived from the [`set_unix_time`](EmbassyAdapter::set_unix_time) + /// anchor plus monotonic uptime. `None` until the anchor is set (Embassy has + /// no RTC). Sub-second precision is taken from uptime, so it carries a + /// sub-second phase offset from the true wall clock — fine for timestamps. + fn unix_time(&self) -> Option<(u64, u32)> { + use core::sync::atomic::Ordering; + let boot = BOOT_UNIX_SECS.load(Ordering::Relaxed); + if boot == 0 { + return None; + } + let now = embassy_time::Instant::now(); + let secs = boot as u64 + now.as_secs(); + let sub_nanos = ((now.as_micros() % 1_000_000) as u32).saturating_mul(1_000); + Some((secs, sub_nanos)) + } } // Implement Logger trait diff --git a/aimdb-executor/CHANGELOG.md b/aimdb-executor/CHANGELOG.md index 1ba79d8..87629a3 100644 --- a/aimdb-executor/CHANGELOG.md +++ b/aimdb-executor/CHANGELOG.md @@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **`TimeOps::unix_time(&self) -> Option<(u64, u32)>` (Issue #120).** Absolute wall-clock time — `(seconds, nanoseconds)` since the Unix epoch — distinct from the monotonic `now()` used to measure durations. Intended for human / remote display (e.g. AimX `record.list` metadata). The default impl returns `None` for platforms without a real-time clock (a bare MCU); runtimes backed by an OS clock (or an MCU with a configured RTC) override it. Default-method, so existing `TimeOps` impls need no change. - `futures-util` (alloc-only) as a regular dependency — provides `FuturesUnordered` used by `aimdb-core`'s `AimDbRunner`. ## [0.2.0] - 2026-05-22 diff --git a/aimdb-executor/src/lib.rs b/aimdb-executor/src/lib.rs index 992978b..cc965ab 100644 --- a/aimdb-executor/src/lib.rs +++ b/aimdb-executor/src/lib.rs @@ -90,6 +90,21 @@ pub trait TimeOps: RuntimeAdapter { /// representation of an elapsed [`Self::Duration`]. Implementations should saturate /// rather than overflow for durations larger than `u64::MAX` nanoseconds. fn duration_as_nanos(&self, duration: Self::Duration) -> u64; + + /// Wall-clock time as `(seconds, nanoseconds)` since the Unix epoch, if the + /// runtime has a real-time clock. + /// + /// Unlike [`now`](Self::now) (a monotonic instant for measuring durations), + /// this is an *absolute* time suitable for human/remote display — e.g. AimX + /// `record.list` metadata timestamps. + /// + /// Returns `None` on platforms without a wall clock (a bare MCU with no RTC), + /// where only monotonic time is available. The default implementation returns + /// `None`; runtimes backed by an OS clock (or an MCU with a configured RTC) + /// should override it. + fn unix_time(&self) -> Option<(u64, u32)> { + None + } } /// Logging trait - enables ctx.log() accessor diff --git a/aimdb-tokio-adapter/CHANGELOG.md b/aimdb-tokio-adapter/CHANGELOG.md index aa40c5e..30688c7 100644 --- a/aimdb-tokio-adapter/CHANGELOG.md +++ b/aimdb-tokio-adapter/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **`TimeOps::unix_time()` implemented from the OS wall clock (Issue #120).** Returns `SystemTime::now()` since the Unix epoch as `(secs, subsec_nanos)`; `now()` stays monotonic for duration measurement. Supplies absolute timestamps to the runtime-neutral AimX server / remote-display paths. - **`TokioBuffer::peek()` (M15, Design 031).** Non-destructive buffer-native read backing AimX `record.get` / `TypedRecord::latest()`: `SingleLatest` (`Watch`) reads via `watch::Sender::borrow()`, `Mailbox` (`Notify`) clones the slot mutex, `SpmcRing` (`Broadcast`) returns `None` (no canonical latest). Unit tests cover all three buffer types (empty, populated, non-destructive, overwrite, drained). - **`tests/remote_access_validation.rs` integration test.** Asserts that a `.with_remote_access()` record with no buffer fails `build()`, and that the same record with a `SingleLatest` buffer builds — locking in the new build-time guard from `aimdb-core` (M15). diff --git a/aimdb-tokio-adapter/src/runtime.rs b/aimdb-tokio-adapter/src/runtime.rs index 95e23b5..e3524d6 100644 --- a/aimdb-tokio-adapter/src/runtime.rs +++ b/aimdb-tokio-adapter/src/runtime.rs @@ -178,6 +178,15 @@ impl TimeOps for TokioAdapter { fn duration_as_nanos(&self, duration: Self::Duration) -> u64 { duration.as_nanos().min(u64::MAX as u128) as u64 } + + fn unix_time(&self) -> Option<(u64, u32)> { + // The OS wall clock. `now()` is monotonic (for durations); this is the + // absolute time AimX metadata timestamps report. + std::time::SystemTime::now() + .duration_since(std::time::SystemTime::UNIX_EPOCH) + .ok() + .map(|d| (d.as_secs(), d.subsec_nanos())) + } } #[cfg(feature = "tokio-runtime")] diff --git a/aimdb-uds-connector/CHANGELOG.md b/aimdb-uds-connector/CHANGELOG.md index b48f7f7..de24ae1 100644 --- a/aimdb-uds-connector/CHANGELOG.md +++ b/aimdb-uds-connector/CHANGELOG.md @@ -12,3 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **New crate — the Unix-domain-socket transport for AimDB remote access (Issue #39, [design doc](../docs/design/remote-access-via-connectors.md)).** A thin, swappable transport that rides the shared session engine in `aimdb-core::session`: it contributes only the `Dialer`/`Listener`/`Connection` triple (`UdsDialer` / `UdsListener` / `UdsConnection`, NDJSON framing in the transport — one line per logical frame); the AimX codec + dispatch and the engine wiring are reused verbatim from core. Two ergonomic constructors: - **`UdsServer`** — accepts connections and serves the full AimX toolset over a socket. Register it via `with_connector` to stand up remote access (this replaces `aimdb-core`'s removed `AimDbBuilder::with_remote_access(config)`). Sugar over `SessionServerConnector`; `UdsServer::from_config(config)` is the one-line migration, plus `new`/`max_connections`/`max_subs_per_connection`/`socket_permissions`/`scheme` builders. Binds synchronously (remove-stale → `bind` → `set_permissions`) so bind errors surface from `build()`, and applies the security policy's writable-record marking. - **`UdsClient`** — dials a peer over UDS and mirrors records under a scheme (default `"uds"`), using `link_to`/`link_from` like any data-plane connector. Sugar over `SessionClientConnector`; chain `.scheme(...)` / `.with_config(...)`. + +### Changed (breaking) + +- **`UdsServer::new` takes `impl Into` (was `impl Into`) (Issue #120).** Follows `aimdb-core`'s de-std of `AimxConfig.socket_path` (`PathBuf` → `String`) for the `no_std` AimX server. `&str` / `String` callers are unaffected; callers passing a `&Path` / `PathBuf` must convert (e.g. `path.to_str().unwrap()`). The internal bind now goes through `std::path::Path::new(&config.socket_path)`, so the synchronous remove-stale → `bind` → `set_permissions` behavior is unchanged. \ No newline at end of file diff --git a/aimdb-uds-connector/src/lib.rs b/aimdb-uds-connector/src/lib.rs index cfdc613..4d6f7ca 100644 --- a/aimdb-uds-connector/src/lib.rs +++ b/aimdb-uds-connector/src/lib.rs @@ -96,7 +96,7 @@ pub struct UdsServer { impl UdsServer { /// Serve AimX over the socket at `socket_path`, with default limits/policy. - pub fn new(socket_path: impl Into) -> Self { + pub fn new(socket_path: impl Into) -> Self { Self { config: AimxConfig::uds_default().socket_path(socket_path), scheme: DEFAULT_SCHEME.to_string(), @@ -189,55 +189,41 @@ where /// Bind the Unix-domain socket synchronously (remove a stale socket file, /// `bind`, `set_permissions`) so bind errors surface from `build`. fn bind_uds_listener(config: &AimxConfig) -> DbResult { + let socket_path = &config.socket_path; + #[cfg(feature = "tracing")] - tracing::info!( - "Initializing AimX UDS server on socket: {}", - config.socket_path.display() - ); + tracing::info!("Initializing AimX UDS server on socket: {}", socket_path); - if config.socket_path.exists() { - std::fs::remove_file(&config.socket_path).map_err(|e| DbError::IoWithContext { - context: format!( - "Failed to remove existing socket file {}", - config.socket_path.display() - ), + if std::path::Path::new(socket_path).exists() { + std::fs::remove_file(socket_path).map_err(|e| DbError::IoWithContext { + context: format!("Failed to remove existing socket file {}", socket_path), source: e, })?; } - let listener = tokio::net::UnixListener::bind(&config.socket_path).map_err(|e| { - DbError::IoWithContext { - context: format!( - "Failed to bind Unix socket at {}", - config.socket_path.display() - ), + let listener = + tokio::net::UnixListener::bind(socket_path).map_err(|e| DbError::IoWithContext { + context: format!("Failed to bind Unix socket at {}", socket_path), source: e, - } - })?; + })?; let permissions = config.socket_permissions.unwrap_or(0o600); - let mut perms = std::fs::metadata(&config.socket_path) + let mut perms = std::fs::metadata(socket_path) .map_err(|e| DbError::IoWithContext { - context: format!( - "Failed to read socket metadata for {}", - config.socket_path.display() - ), + context: format!("Failed to read socket metadata for {}", socket_path), source: e, })? .permissions(); perms.set_mode(permissions); - std::fs::set_permissions(&config.socket_path, perms).map_err(|e| DbError::IoWithContext { - context: format!( - "Failed to set socket permissions for {}", - config.socket_path.display() - ), + std::fs::set_permissions(socket_path, perms).map_err(|e| DbError::IoWithContext { + context: format!("Failed to set socket permissions for {}", socket_path), source: e, })?; #[cfg(feature = "tracing")] tracing::info!( "AimX socket bound at {} (mode {:o})", - config.socket_path.display(), + socket_path, permissions ); diff --git a/tools/aimdb-cli/src/output/json.rs b/tools/aimdb-cli/src/output/json.rs index 7439432..38735e2 100644 --- a/tools/aimdb-cli/src/output/json.rs +++ b/tools/aimdb-cli/src/output/json.rs @@ -87,7 +87,6 @@ mod tests { 1, 2, false, - "2025-11-02T00:00:00Z".to_string(), 0, )]; diff --git a/tools/aimdb-cli/src/output/table.rs b/tools/aimdb-cli/src/output/table.rs index 63740b2..6e58cec 100644 --- a/tools/aimdb-cli/src/output/table.rs +++ b/tools/aimdb-cli/src/output/table.rs @@ -252,7 +252,6 @@ mod tests { 1, 2, false, - "2025-11-02T00:00:00Z".to_string(), 0, ), RecordMetadata::new( @@ -266,7 +265,6 @@ mod tests { 0, 3, true, - "2025-11-02T00:00:00Z".to_string(), 1, ), ]; diff --git a/tools/aimdb-mcp/CHANGELOG.md b/tools/aimdb-mcp/CHANGELOG.md index e7f0975..70db5f6 100644 --- a/tools/aimdb-mcp/CHANGELOG.md +++ b/tools/aimdb-mcp/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- **`list_records`, the `records` resource, and `query_schema` no longer emit `created_at` / `last_update` (Issue #120).** `aimdb-core` dropped per-record timestamp tracking from `RecordMetadata` (it kept no wall-clock state for the `no_std` AimX server), so the AimX `record.list` payload — and these MCP outputs derived from it — no longer carry those two fields. The `RecordInfo` struct drops them too. Every other record-metadata field is unchanged. - **Migrated to the engine-based `aimdb-client::AimxConnection` (Issue #39).** The connection pool and every tool now use `AimxConnection` instead of the retired `AimxClient`, speaking the reshaped **AimX-v2** protocol. Internal-only; no tool surface or behavior change (drain clients are still pooled per socket behind an `Arc>`). ## [0.8.0] - 2026-05-22 diff --git a/tools/aimdb-mcp/src/resources/records.rs b/tools/aimdb-mcp/src/resources/records.rs index 73c2617..4965ecc 100644 --- a/tools/aimdb-mcp/src/resources/records.rs +++ b/tools/aimdb-mcp/src/resources/records.rs @@ -101,8 +101,6 @@ pub async fn read_records_resource(socket_path: &str) -> McpResult, /// Number of outbound connector links outbound_connector_count: usize, } @@ -124,8 +119,6 @@ pub async fn list_records(args: Option) -> McpResult { producer_count: r.producer_count, consumer_count: r.consumer_count, writable: r.writable, - created_at: r.created_at, - last_update: r.last_update, outbound_connector_count: r.outbound_connector_count, }) .collect(); diff --git a/tools/aimdb-mcp/src/tools/schema.rs b/tools/aimdb-mcp/src/tools/schema.rs index 12f69cd..0955b16 100644 --- a/tools/aimdb-mcp/src/tools/schema.rs +++ b/tools/aimdb-mcp/src/tools/schema.rs @@ -169,8 +169,6 @@ pub async fn query_schema(args: Option) -> McpResult { "writable": metadata.writable, "producer_count": metadata.producer_count, "consumer_count": metadata.consumer_count, - "created_at": metadata.created_at, - "last_update": metadata.last_update, "outbound_connector_count": metadata.outbound_connector_count, }, "inferred_at": chrono::Utc::now().to_rfc3339_opts(