From ecb190855158d2205ab2edf01134da36094ab2a8 Mon Sep 17 00:00:00 2001 From: everettVT Date: Mon, 14 Sep 2026 01:59:38 -0700 Subject: [PATCH] Add managed worlds control plane: activity telemetry, registry import, scenarios, captures Extends the runtime-owned worlds control plane (ddlog-worlds) so an operator view can be its front end: - Activity telemetry: worlds run the native child with full observer detail and log rotation; a per-generation tailer thread ingests schedule, message, progress and differential events into a shared telemetry state; inspection reports an `activity` object with lag and rotation counts, and `available` means topology-complete. - Instance operations: `relations`, typed bounded `query_rows`, `program_source`; `instance_info` and `apply_changes` carry the committed revision; status carries a cheap top-level `revision`. - Registry import preserving processor ids and versions from another registry directory (plain JSON reads, dependency closure, validate-all-then-write, dry run); libraries carry their registered definitions with names; the `definitions` listing covers every version and reports compositions. - World purpose (instance/test), summary inventories, build log tail, `started_at_unix_ms`, truthful `persistence: not_configured`, `runtime_info` from build.rs. - Scenarios per definition version and an asynchronous `test` verb that runs them on the world's start thread and records results in status. - Retained compiled topology per definition version (`capture_get`). - Authored groups resolved at snapshot time by `member_match` (scope name or debug pattern) instead of unstable native operator ids. - Star phase instrumentation (large-star, small-star, minimum-label named regions) installed by scripts/install-observer.py at build time, keeping registered content hashes unchanged; build-ddlog.sh fails closed when an explicit Cargo lock cannot build a star program. Tests: fake-driver world tests, a stdio protocol test for ddlog-worlds, a fixture hash stability test, native acceptance covering activity, relations, paged reads, captures, the test verb and phase scopes. Docs in docs/worlds.md and docs/inspection.md. Co-Authored-By: Claude Fable 5.1 --- Cargo.lock | 1 + Cargo.toml | 1 + README.md | 11 +- build.rs | 31 + docs/building.md | 2 +- docs/inspection.md | 140 +++ docs/world-extraction.md | 26 + docs/worlds.md | 179 +++ native/observer.rs | 103 ++ scripts/build-ddlog.sh | 9 + scripts/install-observer.py | 122 ++ scripts/test_install_observer.py | 43 + src/bin/ddlog-worlds.rs | 146 +++ src/inspection.rs | 801 ++++++++++++ src/instance.rs | 231 +++- src/lib.rs | 115 +- src/processes.rs | 7 +- src/registry.rs | 483 +++++++- src/telemetry.rs | 698 +++++++++++ src/worlds.rs | 1476 +++++++++++++++++++++++ tests/fixture_hashes.rs | 56 + tests/processor_composition_registry.rs | 15 +- tests/processor_registry.rs | 55 + tests/test_native_packaging.py | 79 +- tests/worlds.rs | 1436 ++++++++++++++++++++++ tests/worlds_native.rs | 338 ++++++ tests/worlds_stdio.rs | 247 ++++ 27 files changed, 6813 insertions(+), 38 deletions(-) create mode 100644 build.rs create mode 100644 docs/inspection.md create mode 100644 docs/world-extraction.md create mode 100644 docs/worlds.md create mode 100644 native/observer.rs create mode 100644 scripts/install-observer.py create mode 100644 scripts/test_install_observer.py create mode 100644 src/bin/ddlog-worlds.rs create mode 100644 src/inspection.rs create mode 100644 src/telemetry.rs create mode 100644 src/worlds.rs create mode 100644 tests/fixture_hashes.rs create mode 100644 tests/worlds.rs create mode 100644 tests/worlds_native.rs create mode 100644 tests/worlds_stdio.rs diff --git a/Cargo.lock b/Cargo.lock index 0e19ace..03f008a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -812,6 +812,7 @@ dependencies = [ "lemmalog-syntax", "libc", "parquet", + "regex", "reqwest 0.13.5", "serde", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index 894da0d..d85c63a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,6 +16,7 @@ lemmalog-syntax = { path = "crates/syntax", version = "0.1.0" } serde = { version = "1", features = ["derive"] } serde_json = { version = "1", features = ["float_roundtrip"] } sha2 = "0.10" +regex = "1" iceberg = { git = "https://github.com/apache/iceberg-rust.git", rev = "4d83bc77dc10cff851a3ef427c451ff977bc3643", optional = true } iceberg-catalog-sql = { git = "https://github.com/apache/iceberg-rust.git", rev = "4d83bc77dc10cff851a3ef427c451ff977bc3643", optional = true } diff --git a/README.md b/README.md index 1352a71..a4a0bce 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,15 @@ Library callers can explicitly save and restore pure-program local checkpoints. A runnable library example is [`examples/program.rs`](examples/program.rs). Installation invokes native compilation; pure parsing, lowering and registry validation do not. The workspace does not require the MCP feature for library use. +## World control plane + +`cargo build --locked --bin ddlog-worlds` builds an explicitly launched owner for +registered worlds. It provides asynchronous start/stop, persistent instance history, +real process CPU/RSS, and opt-in native topology through versioned inspection +contracts. Registration alone never starts execution. See [world lifecycle and +protocol](docs/worlds.md) and [inspection metadata](docs/inspection.md). Existing +`ProgramInstance` and shared-host callers remain supported. + ## MCP ```sh @@ -92,6 +101,6 @@ cargo build --locked --features mcp --bin lemmalog-ddlog-mcp python3 -m unittest discover -s tests -p 'test_*.py' -v ``` -These tests need Rust and Python 3.11+, but no DDlog compiler or provider. Python host/MCP tests use explicitly simulated graph fixtures. [Native acceptance](docs/building.md#native-acceptance) is a separate operator-configured step. Compatibility fixtures captured before extraction check old registry records, content hashes, generated source, bundled native source and MCP schemas. +These tests need Rust and Python 3.11+, but no DDlog compiler or provider. `--all-features` includes the Iceberg checkpoint feature, whose pinned `iceberg` revision needs Rust 1.95 (`cargo +1.95.0 …`, see [Iceberg checkpoints](docs/iceberg-checkpoints.md)); the default feature set and `--features mcp` build on the 1.94 toolchain. Python host/MCP tests use explicitly simulated graph fixtures. [Native acceptance](docs/building.md#native-acceptance) is a separate operator-configured step. Compatibility fixtures captured before extraction check old registry records, content hashes, generated source, bundled native source and MCP schemas. MIT licensed; the upstream copyright notice is retained in [LICENSE](LICENSE). diff --git a/build.rs b/build.rs new file mode 100644 index 0000000..4d7e4c0 --- /dev/null +++ b/build.rs @@ -0,0 +1,31 @@ +//! Records the crate's git identity for the `runtime_info` control-plane verb. +//! A missing git checkout yields no commit; the runtime reports that truthfully. +use std::process::Command; + +fn git(args: &[&str]) -> Option { + let output = Command::new("git").args(args).output().ok()?; + if !output.status.success() { + return None; + } + Some(String::from_utf8(output.stdout).ok()?.trim().to_string()) +} + +fn main() { + println!("cargo:rerun-if-changed=build.rs"); + for path in [ + ".git/HEAD", + ".git/index", + "../.git/modules/ddlog-runtime/HEAD", + ] { + println!("cargo:rerun-if-changed={path}"); + } + let commit = git(&["rev-parse", "HEAD"]).filter(|hash| hash.len() == 40); + let dirty = commit.is_some() + && git(&["status", "--porcelain", "--untracked-files=no"]) + .is_some_and(|status| !status.is_empty()); + println!( + "cargo:rustc-env=DDLOG_RUNTIME_COMMIT={}", + commit.unwrap_or_default() + ); + println!("cargo:rustc-env=DDLOG_RUNTIME_DIRTY={dirty}"); +} diff --git a/docs/building.md b/docs/building.md index 75d4045..4119a53 100644 --- a/docs/building.md +++ b/docs/building.md @@ -27,7 +27,7 @@ The smoke test retains the actual MCP requests/responses, generated source, exec ### Existing operator-managed environments -The driver still accepts `DDLOG_HOME`, `DDLOG_CARGO`, `RUSTC`, `CARGO_HOME`, `CARGO_TARGET_DIR`, `DDLOG_CARGO_CONFIG`, `DDLOG_OFFLINE` and `DDLOG_CARGO_LOCK`. A supplied `DDLOG_CARGO_LOCK` takes precedence over bootstrap's `DDLOG_LOCK_DIR`; otherwise the driver selects the ordinary or Star lock from that directory. The repository root lockfile is for the modern host, not generated programs. Use absolute paths and scope native environment variables to the native server/build process, not the modern host build. +The driver still accepts `DDLOG_HOME`, `DDLOG_CARGO`, `RUSTC`, `CARGO_HOME`, `CARGO_TARGET_DIR`, `DDLOG_CARGO_CONFIG`, `DDLOG_OFFLINE` and `DDLOG_CARGO_LOCK`. A supplied `DDLOG_CARGO_LOCK` takes precedence over bootstrap's `DDLOG_LOCK_DIR`; otherwise the driver selects the ordinary or Star lock from that directory. A program that imports `lemmalog_star` needs the Star lock (it adds the `types__lemmalog_star` workspace member): the driver refuses a `DDLOG_CARGO_LOCK` that does not name that member before invoking cargo, naming both variables, rather than failing inside `cargo build --locked`. Control planes that run both kinds of program should set `DDLOG_LOCK_DIR` and leave `DDLOG_CARGO_LOCK` unset. The repository root lockfile is for the modern host, not generated programs. Use absolute paths and scope native environment variables to the native server/build process, not the modern host build. The library bundles `src/star/lemmalog_star.dl` and `.rs` as text and writes them beside generated source when selected. The generated source binds their hashes. This native Rust code compiles in DDlog's generated context, including `Weight` and `ddlog_std`; it is not an independently compiled workspace crate. diff --git a/docs/inspection.md b/docs/inspection.md new file mode 100644 index 0000000..63d7af6 --- /dev/null +++ b/docs/inspection.md @@ -0,0 +1,140 @@ +# Inspection metadata version 1 + +`inspection::InspectionMetadata` is a serializable, validated authored contract. +Call `validate` before registering a definition and `validate_with_graph` when +binding it to a live native graph. Unsupported schema versions fail validation; +a reader must not silently interpret them as version 1. + +An authored group has a stable `id` and `member_key` (the composition member's +qualified identity). Its repository/revision and optional one-based source range +identify authored provenance. A revision is caller-supplied provenance, not proof +that a remote repository or release exists. This module does not query Git. + +`authoredGroups` and `memberIds` match the observer's existing grouping field names. +`memberIds` are exact native IDs, including worker distinctions. Groups are separate +from `NativeGraph.nodes`; they never become operators. Native operators retain +`id`, `worker`, `address`, `name`, and `debug`. Channels retain `id`, `worker`, +`scope`, `source`, `target`, `source_port`, and `target_port`. Validation is read-only. +Native IDs and addresses must never be reconstructed from display labels. + +Ports carry a stable authored ID, name, direction, and source-language `data_type`. +A port can map to several native operator/index pairs. Mapping indexes are native +port indexes, not layout port IDs. Live validation requires a matching channel in +the stated direction. Unconnected ports cannot be verified from channel data and +must have no native mapping until supporting observations exist. + +Version 1 groups are flat and disjoint: overlapping native membership is rejected. +Registered definitions may have no native member IDs or mappings; registration is +not execution. Live bindings are execution-specific and must be revalidated after +recompilation or restart. Clients still decide whether a group can be laid out +within native scopes; visual geometry is deliberately not part of this contract. +No memory usage, CPU usage, liveness, or process ownership is inferred from a graph. + +## Membership by match + +Native operator ids are not stable across builds, so a group may declare +`member_match` instead of (or in addition to) `memberIds`, and a port may declare +`native_match` instead of `native_ports`. Both are omitted from the wire form when +absent, so definitions without them keep their content hash. + +- `member_match: {scope_name?, debug_pattern?}` — at least one. `scope_name` names a + native scope (an operator with nested children, e.g. a `region_named` phase) and + claims every matching scope with its whole subtree. `debug_pattern` is a regular + expression searched in the operator `debug` text (the DDlog profiler context). +- `native_match: {debug_pattern, index}` — the port binds to native port `index` of + the one group member whose `debug` matches. + +`validate()` (registration time) checks shape only for match-based groups: the +pattern compiles, `scope_name` is non-empty, ids are unique. Membership is resolved +by `resolve(graph)` when a snapshot is taken, deterministically: + +1. Groups are processed in declaration order. Explicit `memberIds` and every + `scope_name` match claim first; a claim on an operator already owned by another + group is a mapping error. +2. Then `debug_pattern` matches, again in declaration order, claim only operators + nobody claimed yet. +3. Zero matches for a group, a `native_match` that resolves to zero or several + members, a port mapped outside its group, a member whose native children are not + all members (a split scope), or top-level members under different native parents + are mapping errors naming the group. +4. The resolved metadata then passes the existing live checks (ids exist, ports are + observed on channels in the stated direction). + +A snapshot reports the resolved metadata (`authoredGroups[*].memberIds` are the +resolved ids, `ports[*].native_ports` include the matched binding) and +`mapping_error: null`, or the authored metadata unchanged with the error text. +Resolution is a pure function of the metadata and the observed graph; it never +consults the registry or invents operators. + +## Native capture build hook + +The standard build driver installs `native/observer.rs` into generated DDlog Rust. +`scripts/install-observer.py` accepts the generated project directory and verifies +both pinned generator patch sites before writing. Repeated installation is a no-op; +changed/duplicate sites fail rather than compiling without observation support. +Python 3 is required by this build step. Runtime logging and debug regions remain +opt-in through `DDLOG_OBSERVER_FILE`; building alone does not capture anything. + +Capture records are actual Timely, progress, and differential events. The writer +limits each worker to a byte budget (`DDLOG_OBSERVER_BYTES`, default 64 MiB; values +below 4096 or non-integers fail the install). Without rotation it emits a +`capture_status` record with `status: truncated` and `reason: worker_byte_limit` +when its budget is exhausted and writes nothing further. With +`DDLOG_OBSERVER_ROTATE=1` it instead truncates the shared file to zero, writes +`{"stream":"capture_status","status":"rotated","bytes":N}` as the first line of the +fresh file and continues; if truncation fails it falls back to the truncated marker +(`reason: rotation_failed`). Consumers must report incomplete capture when a +truncation marker appears; absence of additional events is not proof of idle +execution. Rotation discards earlier bytes of every worker (the budget is per +worker, the file is shared), so a reader must have ingested topology before the +first rotation; the runtime's reader keeps what it ingested and restarts at offset +zero when the file shrinks. The runtime must select a private capture path. Raw +`operator_id` and `channel_id` are retained independently of composite display +IDs, together with source and target addresses and native port indices. + +Library capture defaults to topology-only (actual operator and channel creation). +`Backend::set_observer` (`ObserverOptions {detail_full, rotate}`) sets +`DDLOG_OBSERVER_DETAIL=full` and `DDLOG_OBSERVER_ROTATE=1` for the native child; +managed worlds turn both on, library users leave both off. The runtime only adds the +variables it is configured with and never strips inherited `DDLOG_OBSERVER_*` +variables, so an operator who exports them to a library host or the MCP server still +gets the capture they asked for. Full tracing adds runtime overhead and is not +required by the world inventory. + +## Reader, state and activity + +`telemetry::Reader` owns a capture file position (path, offset, pending partial +line) and is driven by a tailer thread or a one-shot pass; `telemetry::State` is +the shared ingested view (`Arc>`) that `status` snapshots. Each tick +reads at most 4 MiB; a single event line above 1 MiB, a malformed record or a +conflicting operator/channel identity is a sticky error. Ingest handles `Operates`, +`Channels`, `Schedule` (Start/Stop pairs → `schedule_count`, `busy_ns`, +`last_seen_ns`), `Messages` (`is_send` → channel `message_count`, `records += +length`), `Shutdown` (`active: false`), `stream: progress` (counter), +`stream: differential` (per-operator `arrangement_events`, `last_arrangement_event +{kind, time_ns, length}`) and `capture_status` (`truncated`, or `rotated`, counted +once per rotation whether the shrink or the record is observed first). + +`inspection.state` is `missing` (no capture file yet), `failed` (sticky error), +`truncated`, `pending` (no operators yet) or `available` (no error, not truncated, +operators observed). Lag never changes the state; it is reported in +`inspection.activity`: + +```json +"activity": { + "nodes": {"": {"schedule_count":0,"busy_ns":0,"last_seen_ns":0,"active":true, + "arrangement_events":0,"last_arrangement_event":null}}, + "channels": {"": {"message_count":0,"records":0}}, + "totals": {"events":0,"timely":0,"progress":0,"differential":0, + "operators":0,"channels":0,"unresolved_channels":0}, + "last_event_ns": null, "progress_events": 0, + "complete": true, "truncated_at_bytes": null, "rotations": 0, + "lag_bytes": 0, "last_ingest_unix_ms": 0 +} +``` + +`complete` is whether the reader has ingested every complete line the file held at +its last tick without error or truncation. Every observed operator and channel has +an activity entry (zeros until events arrive); events naming an operator that never +reported `Operates` are kept under that id as well. Channels whose endpoints are not +known are counted in `unresolved_channels` and never emitted as edges. diff --git a/docs/world-extraction.md b/docs/world-extraction.md new file mode 100644 index 0000000..443f739 --- /dev/null +++ b/docs/world-extraction.md @@ -0,0 +1,26 @@ +# Program extraction: distinct from inspection groups + +The control-plane work does not turn authored display groups into independent +programs. A future user-directed extraction action must operate on a selected +rule/operator subgraph, infer and present its typed boundary, and register an +immutable child program. It must rewrite the parent composition to the child's +exact version and preserve public relation names, types and update semantics. + +Admission must reuse the existing parser/lowering, typed interface and composition +validation paths. Reject cuts through registered-operation ownership, unsafe +negation/recursion, private native state or unsupported operator boundaries. Show +the candidate child definition, typed inputs/outputs, parent wiring and source +provenance before publishing. Compilation/admission failure must leave the saved +parent and any active instances unchanged. + +Validation must compare original and rewritten programs on user-selected regression +traces including insertions and retractions, recursive fixed points and empty +inputs. Example traces are evidence, not a universal proof of equivalence. When a +structural equivalence argument cannot be established, report that limitation +explicitly and require review of the proposed transformation. Publishing definitions +and choosing to run the rewritten composition are separate actions. + +The current observer can inspect declared child blocks and recursively navigate +exact pins. Leaf topology requires a compiled/captured native artifact; browsing +does not compile or launch a world implicitly. No extraction mutation is currently +exposed by the control-plane protocol. diff --git a/docs/worlds.md b/docs/worlds.md new file mode 100644 index 0000000..1c39d7d --- /dev/null +++ b/docs/worlds.md @@ -0,0 +1,179 @@ +# Managed worlds control plane + +`ddlog-worlds REGISTRY BUILD_ROOT BUILD_DRIVER` owns a single `WorldManager`. +All three paths must be absolute. Registry and build roots must be dedicated, +owner-private directories. An exclusive advisory lock rejects another manager for +the same build root. Clients attach to the existing owner; constructing another +manager is not discovery. Different stores can deliberately own different worlds. + +The stdio protocol is newline-delimited JSON, capped at 1 MiB per request. +Requests have `operation` and `args`; replies have `ok` and either `result` or +`error`. There is no network listener, arbitrary host-process discovery, or daemon +autostart. The operator chooses when to run this owner. + +- `runtime_info` `{}` returns `{commit, dirty, crate_version, schema_version}` recorded by + `build.rs` at compile time. `commit` is null when the crate was not built from a git + checkout; `dirty` is whether tracked files differed from that commit. +- `library_create` registers `{name, repository, revision}`; `libraries` lists every + entry with its `processors` (`[{processor_id, version, name, description}]`) plus the + implicit `unassigned` library (name "Unassigned", always last). A (processor, version) + belongs to exactly one library; a later association moves it. Libraries are + operator-supplied provenance, not proof of executable DDLog definitions. +- `register` takes `name` (required), optional `description`, optional `library_id` + (default `unassigned`), `definition` and optional `git_provenance`. The reply is the + registry record plus `name`, `description` and `library_id`. Every registered + definition has a name; the UI never has to show a processor id. +- `definitions` `{}` returns `{"processors":[…]}` with one row per **version** of every + processor (archived included), looping registry pages internally: + `{processor_id, version, current, kind, status, name, description, library_id, + created_at_unix_ms}`. `kind` is `program` or `composition` (also fixed in the + registry's own `ProcessorSummary`). A version without an association is listed under + `unassigned` with a derived name: `outputs ← inputs` from its public interface, or + `Composition of `. `definition` reads an exact `processor_id` and `version`. +- `import` `{source_registry, processor_id?, library_id?, names?: {"": "…"}, dry_run?}` + copies version records from another registry directory **preserving `processor_id` + and `version`**. The source is read with plain JSON reads of `/current.json` and + `/versions/*.json`; it is never locked, permission-checked or written, so a 0755 + fixture registry is a valid source. Every version of every selected processor is + imported, dependency closure first; a processor new to this registry gets + `current.json` copied from the source, an existing processor keeps its pointer. Each + record is envelope-, hash- and definition-validated (compositions resolve against + source-plus-destination records) **before anything is written**; any error returns + `errors: [{processor_id, version, error}]` with nothing imported. The reply lists + `imported: [{processor_id, version, kind, status: imported|present, current, name, + library_id}]`. An identical existing version is `present`; a same-name record with + different content is an error. `dry_run` validates and plans without writing. + Imported definitions are named from `names`, an existing association, or derived. +- `create` takes a `label`, exact `processor` reference, optional `purpose` + (`instance`, the default, or `test`) and, for tests, non-empty `scenarios`. It does + not start a process. Scenarios are validated at creation. +- `start` takes `id`, records `starting`, then compiles/installs asynchronously. + Poll `status`, `inspect`, or `inventory` to collect completion or failure. +- `stop` takes `id`. It cancels managed compiler/native process groups. A pending + install reports `stopping` until its completion is collected, then `stopped`. +- `inventory` `{processor_id?, version?, summary?}` lists worlds, optionally filtered by + pin. `summary: true` returns each status **without** `inspection`, `instance` and + `managed_processes` and never calls the live instance; it is what a sidebar polls. +- `execute` takes world `id`, an inner `operation`, and inner `args`. Definition + installation/registry mutations are rejected here; world definitions stay pinned. + Beyond the existing program operations it offers: + - `instance_info` adds `revision`, `program_version` and `source_sha256` (sha256 of + the lowered `program.dl`) while the instance is healthy. + - `apply_changes` adds `revision`. + - `relations` `{}` → `{revision, relations: [{name, input, fields, count}]}` over the + program's public relations. Output counts are a streamed native scan per relation + (rows are never accumulated); input counts come from retained facts. + - `query_rows` `{predicate, max_rows (1..=10000, default 500), continuation?}` → + `{predicate, revision, fields, rows, total, complete, continuation}`. Outputs page + through the bounded native reader, inputs page over retained facts; `total` is the + full count at `revision`; rows are typed by `fields` (int → number, string → + string). Continuations are opaque and invalid after any mutation. + - `program_source` `{}` → `{source, source_sha256}`. +- `scenarios_set` `{processor_id, version, scenarios}` / `scenarios_get` store and read + `/scenarios//.json`. A scenario is + `{name, description?, changes: [{op: insert|delete, predicate, values}], expect: + {"": [[…], …]}}`. Names are unique and non-empty; change predicates must be + public inputs, expected relations public relations; arity and types must match the + declared fields; expected rows must have no duplicates (relations are sets). +- `test` `{processor_id, version, scenarios?: [names], keep_world?}` creates a world + `{label: "Test · ", purpose: test, scenarios}` from the stored scenarios, + starts it and returns its `starting` status immediately. After a successful install + the start thread applies each scenario's changes cumulatively in order, reads every + expected relation through `query_rows`, compares as sets and records the results; + it then drops the instance (the world becomes `stopped`) unless `keep_world`. +- `capture_get` `{processor_id, version}` returns the retained compiled topology of + that definition version (below) or `{state: "missing"}`; an unknown pin is an + error. + +**Public relations rule** (used by `relations`, `query_rows` and scenario validation): a +program without an interface exposes every declared schema; a program with an interface +exposes its interface inputs and outputs; a composition exposes its resolution inputs and +outputs. The runtime exposes this as `ddlog_runtime::instance::public_relations(record)`. + +A world progresses from `created` to `starting` to `running`, or to `failed`. +Stopping a running world drops and reaps its native child. Restart is explicit and +uses a fresh generation/build directory. When `status` observes that the native +child died it reports `failed` and reaps the instance, so `start` is valid for +`failed` worlds (crashed or build-failed) and opens the next generation. +The independent shutdown handle cancels all tracked process groups and prevents +further starts for that manager. Ordinary owner drop cancels execution; SIGINT and +SIGTERM cancel process groups before the executable exits. SIGKILL and machine +failure cannot promise synchronous cleanup. + +`status` carries, besides definition, state, generation, error, history, owner, +resources, managed processes, instance info and inspection: + +- `revision`: the live instance's committed revision (an in-memory counter, present in + summary inventories too), or null when no instance is live. +- `started_at_unix_ms`: the `at_unix_ms` of the latest `starting` history entry of the + current generation, or null. +- `build`: `{generation, log_tail, instrumented}` while `starting` or after a failed + build, otherwise null. `log_tail` is the last 60 lines of + `///build-1/build.log` and null until the driver creates + that file. `instrumented` is whether the bundled build driver's `install-observer.py` + completed for that build (it records `program_ddlog/observer-install.json`): the + native observer hook is present and, when the program imports the built-in star + library, its generated copy carries the `large-star`, `small-star` and + `minimum-label` phase regions. That patch is applied to the generated project at build + time; `src/star/lemmalog_star.rs` and the registry content hashes are unchanged by it. +- `persistence`: `{status: "not_configured", reason: "world checkpoints are not wired"}` + in this slice; nothing claims durable world state. +- `test`: for test worlds, `{phase: building|applying|done|failed, scenario_index, + results: [{name, passed, revision, expected, observed, missing, unexpected, error}], + passed: bool|null, error}`, persisted in `world.json`. `expected`, `observed`, + `missing` and `unexpected` are keyed by relation name. A build failure reports + `phase: failed` with the build error; a scenario whose changes or reads fail carries + that error in its result and fails. + +Definitions, generation, state, errors, metadata and transition history are written +into each world's record. History is retained across owner restart, but running +processes are never adopted. Previously active records become `interrupted` with an +explicit error. Historical native event files can still be inspected; their presence +is not evidence that a world is currently running. Failed durable writes surface as +errors and do not suppress subsequent persistence retries. + +Resource samples report a timestamp, PID, CPU percentage and resident bytes, with +missing/unsupported states and a suggested maximum age. CPU comes from `ps`: a +lifetime average on Linux and a decaying average on macOS, not an interval delta. +The primary sample covers only the native child. Managed-process entries also show +tracked compiler/startup process leaders; descendants are excluded. The embedding +owner's memory is shared and not attributed to individual worlds. These are not +operator-level memory measurements. Samples are synchronous observations and can +race process exit; neither measurements nor cached graphs prove continuing liveness. + +Native graph inspection is optional build-driver support. The bundled driver +installs the opt-in hook described in `inspection.md`; worlds start the native child +with `DDLOG_OBSERVER_DETAIL=full` and `DDLOG_OBSERVER_ROTATE=1` so schedule, message, +progress and arrangement events are captured and a long-lived world rotates its +capture at the worker byte budget instead of going silent. Capture can be missing, +pending, truncated, failed, or available; lag is reported in `inspection.activity` +(`lag_bytes`, `complete`, `rotations`) and never makes a world unavailable. Mapping +errors remain explicit. Authored metadata binds native operators either explicitly or +through `member_match`/`native_match` resolved at snapshot time (`inspection.md`); +source/debug labels are never converted into fabricated membership. Consumers must +show lifecycle state alongside captured topology. + +**Tailer.** Each `start` spawns one capture tailer thread for the new generation. It +polls the capture file every 100 ms, ingests at most 4 MiB per poll into the shared +state that `status`/`inspect` snapshot, and exits on `stop`, on owner drop (both +join it after a final poll), when `status` observes the native child dead, or after +50 unchanged polls once the world has no live instance and no pending start (a test +world that dropped its instance, a failed install). Recovered `stopped`/`interrupted` +worlds read their last generation's capture once, on their first full `status`, and +hold that state without a thread; `inventory {summary: true}` never reads a capture. + +**Retained topology.** Once per generation, on the first full `status` where +`inspection.state == "available"`, `unresolved_channels == 0` and at least one +`Schedule` event has been ingested, the runtime writes +`/captures//.json`: +`{schema_version: 1, processor: {processor_id, version}, world_id, generation, +captured_at_unix_ms, unresolved_channels, graph: {nodes, edges}, metadata, +mapping_error}` — topology and resolved authored metadata only, no activity. A later +generation of any world pinned to that version replaces it. `world.json` records +`capture_generation` so a recovered world does not capture the same generation twice. + +This slice supplies the runtime contract and lifecycle owner. A later library +extraction/integration slice can migrate independent applications onto registration +and their own supported APIs without merging their repositories or claiming those +services are already managed. X0 and Holocron provenance registration alone does +not instantiate their programs or imply integration coverage. diff --git a/native/observer.rs b/native/observer.rs new file mode 100644 index 0000000..9893573 --- /dev/null +++ b/native/observer.rs @@ -0,0 +1,103 @@ +//! Opt-in observer over actual native Timely logging hooks. +//! Dormant unless DDLOG_OBSERVER_FILE is supplied; never fabricates topology. +use std::io::Write; +use std::sync::{Arc, Mutex}; +use serde_json::{json, Value}; +use timely::communication::Allocate; +use timely::logging::{TimelyEvent, TimelyProgressEvent}; +use timely::worker::Worker; +use differential_dataflow::logging::DifferentialEvent; + +const DEFAULT_BUDGET: u64 = 64 * 1024 * 1024; +/// One worker's sink. `written` counts this worker's bytes since its last +/// rotation; the file itself is shared by every worker in append mode. +struct Sink { file: std::fs::File, written: u64, budget: u64, rotate: bool } +type Shared = Arc>; +fn emit(sink: &Shared, value: Value) { + if let Ok(mut state) = sink.lock() { + if !state.rotate && state.written >= state.budget { return; } + if let Ok(mut bytes) = serde_json::to_vec(&value) { + bytes.push(b'\n'); + if state.written + bytes.len() as u64 >= state.budget { + if !state.rotate { + let _ = state.file.write_all(b"{\"stream\":\"capture_status\",\"status\":\"truncated\",\"reason\":\"worker_byte_limit\"}\n"); + state.written = state.budget; + return; + } + // Rotation: drop the retained bytes, then record the rotation as the + // first line of the fresh file so a reader sees it after the shrink. + // A failed truncation falls back to the truncated behaviour. + if state.file.set_len(0).is_err() { + let _ = state.file.write_all(b"{\"stream\":\"capture_status\",\"status\":\"truncated\",\"reason\":\"rotation_failed\"}\n"); + state.rotate = false; + state.written = state.budget; + return; + } + let rotated = format!("{{\"stream\":\"capture_status\",\"status\":\"rotated\",\"bytes\":{}}}\n", state.written); + state.written = 0; + if state.file.write_all(rotated.as_bytes()).is_ok() { state.written += rotated.len() as u64; } + } + if state.file.write_all(&bytes).is_ok() { state.written += bytes.len() as u64; } + } + } +} + +pub(crate) fn install(worker: &mut Worker) -> Result<(), String> { + let path = match std::env::var("DDLOG_OBSERVER_FILE") { + Ok(path) => path, + Err(_) => return Ok(()), + }; + let file = std::fs::OpenOptions::new().create(true).append(true).open(path) + .map_err(|e| e.to_string())?; + let budget = match std::env::var("DDLOG_OBSERVER_BYTES") { + Ok(text) => text.trim().parse::().ok().filter(|bytes| *bytes >= 4096) + .ok_or_else(|| format!("DDLOG_OBSERVER_BYTES must be an integer byte budget of at least 4096, got {text:?}"))?, + Err(_) => DEFAULT_BUDGET, + }; + let rotate = std::env::var("DDLOG_OBSERVER_ROTATE").ok().as_deref() == Some("1"); + let sink: Shared = Arc::new(Mutex::new(Sink { file, written: 0, budget, rotate })); + // Default capture is topology-only; full event tracing is explicitly opt-in. + let detailed = std::env::var("DDLOG_OBSERVER_DETAIL").ok().as_deref() == Some("full"); + let timely_sink = sink.clone(); + worker.log_register().insert::("timely", move |_, data| { + for (time, worker, event) in data.drain(..) { + if !detailed && !matches!(event, TimelyEvent::Operates(_) | TimelyEvent::Channels(_)) { continue; } + let debug = if matches!(event, TimelyEvent::Operates(_)) { + ddlog_profiler::get_prof_context().map(|c| format!("{:?}", c)) + } else { None }; + emit(&timely_sink, json!({"stream":"timely","time_ns":time.as_nanos() as u64, + "worker":worker,"event":event,"debug":debug})); + } + }); + if !detailed { return Ok(()); } + let progress_sink = sink.clone(); + worker.log_register().insert::("timely/progress", move |_, data| { + for (time, worker, event) in data.drain(..) { + let messages: Vec = event.messages.iter().map(|(node,port,timestamp,delta)| + json!({"node":node,"port":port,"timestamp":format!("{:?}", timestamp), + "timestamp_type":timestamp.type_name(),"delta":delta})).collect(); + let internal: Vec = event.internal.iter().map(|(node,port,timestamp,delta)| + json!({"node":node,"port":port,"timestamp":format!("{:?}", timestamp), + "timestamp_type":timestamp.type_name(),"delta":delta})).collect(); + emit(&progress_sink,json!({"stream":"progress","time_ns":time.as_nanos() as u64, + "worker":worker,"event":{"is_send":event.is_send,"source":event.source, + "channel":event.channel,"sequence":event.seq_no,"address":event.addr, + "messages":messages,"internal":internal}})); + } + }); + worker.log_register().insert::("differential/arrange", move |_, data| { + for (time, worker, event) in data.drain(..) { + let (kind,operator,length) = match &event { + DifferentialEvent::Batch(e) => ("Batch", e.operator, Some(e.length)), + DifferentialEvent::Drop(e) => ("Drop", e.operator, Some(e.length)), + DifferentialEvent::Merge(e) => ("Merge", e.operator, e.complete), + DifferentialEvent::MergeShortfall(e) => ("MergeShortfall", e.operator, None), + DifferentialEvent::TraceShare(e) => ("TraceShare", e.operator, None), + }; + emit(&sink,json!({"stream":"differential","time_ns":time.as_nanos() as u64, + "worker":worker,"event":{"kind":kind,"operator":operator,"length":length, + "detail":format!("{:?}",event)}})); + } + }); + Ok(()) +} diff --git a/scripts/build-ddlog.sh b/scripts/build-ddlog.sh index 5ac8418..4b4ce77 100755 --- a/scripts/build-ddlog.sh +++ b/scripts/build-ddlog.sh @@ -1,17 +1,26 @@ #!/bin/sh # Trusted build driver: source.dl output-executable. DDlog v1.2.3 emits Rust. set -eu +runtime_root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) source_path=$1 output_path=$2 : "${DDLOG_HOME:?Set DDLOG_HOME to the DDlog distribution}" cd "$(dirname "$source_path")" "$DDLOG_HOME/bin/ddlog" -i "$(basename "$source_path")" cd program_ddlog +python3 "$runtime_root/scripts/install-observer.py" . # Optional offline packaging overrides; these are operator configuration. if [ -n "${DDLOG_CARGO_CONFIG:-}" ]; then cp "$DDLOG_CARGO_CONFIG" .cargo/config.toml fi if [ -n "${DDLOG_CARGO_LOCK:-}" ]; then + # An explicit lock wins, but a program that imports lemmalog_star adds the + # types__lemmalog_star workspace member; a lock without it cannot build + # --locked, so refuse here with the cause instead of failing inside cargo. + if [ -d types/lemmalog_star ] && ! grep -q '^name = "types__lemmalog_star"$' "$DDLOG_CARGO_LOCK"; then + echo "build-ddlog.sh: DDLOG_CARGO_LOCK=$DDLOG_CARGO_LOCK does not lock types__lemmalog_star, which this program's lemmalog_star import requires; unset DDLOG_CARGO_LOCK and set DDLOG_LOCK_DIR to a directory holding star.Cargo.lock (for example $runtime_root/native)" >&2 + exit 1 + fi cp "$DDLOG_CARGO_LOCK" Cargo.lock elif [ -n "${DDLOG_LOCK_DIR:-}" ]; then if [ -d types/lemmalog_star ]; then diff --git a/scripts/install-observer.py b/scripts/install-observer.py new file mode 100644 index 0000000..eca8621 --- /dev/null +++ b/scripts/install-observer.py @@ -0,0 +1,122 @@ +#!/usr/bin/env python3 +"""Install opt-in native logging into a pinned generated DDlog project. + +Validate every patch site before any write. A changed generator fails closed. +The installed code is dormant unless DDLOG_OBSERVER_FILE is set at execution. + +When the program imports the built-in star library, its generated copy +(`types/lemmalog_star/lemmalog_star.rs`) is also patched with the three named +phase regions (`large-star`, `small-star`, `minimum-label`). That is build-time +observation support: it changes the native binary, never the registered +definition or its content hash. `observer-install.json` records what was done. +""" +from pathlib import Path +import argparse +import json + +ROOT = Path(__file__).resolve().parent.parent +WORKER_SITE = ' self.worker.log_register().remove("differential/arrange");\n };\n Ok(())' +WORKER_PATCH = WORKER_SITE.replace(' Ok(())', ' crate::observer::install(self.worker)?;\n Ok(())') +CLI_SITE = ' differential_idle_merge_effort: args.idle_merge_effort,' +CLI_PATCH = CLI_SITE + '\n enable_debug_regions: std::env::var_os("DDLOG_OBSERVER_FILE").is_some(),' +STAR_LIBRARY = Path('types/lemmalog_star/lemmalog_star.rs') +# Pinned to src/star/lemmalog_star.rs; the patched text equals the reference +# observer implementation (observer/examples/connected_components/lemmalog_star.rs). +STAR_SITES = [ + ('large-star open', + ' let stars = pairs.filter(|(u, v)| u != v).iterate(|current| {\n' + ' let symmetric = current.concat(¤t.map(|(u, v)| (v, u)));\n' + ' let large = symmetric\n', + ' let stars = pairs.filter(|(u, v)| u != v).iterate(|current| {\n' + ' let large = current.scope().region_named("large-star", |region| {\n' + ' let current = current.enter_region(region);\n' + ' let symmetric = current.concat(¤t.map(|(u, v)| (v, u)));\n' + ' symmetric\n'), + ('large-star close / small-star open', + ' .map(|(_, edge)| edge)\n' + ' .distinct_core::();\n' + ' // Every large-star edge already points from larger to smaller.\n' + ' // Keep the orientation explicit to document the small-star map phase.\n' + ' large\n' + ' .map(|(u, v)| if u > v { (u, v) } else { (v, u) })\n', + ' .map(|(_, edge)| edge)\n' + ' .distinct_core::().leave_region()\n' + ' });\n' + ' // Every large-star edge already points from larger to smaller.\n' + ' // Keep the orientation explicit to document the small-star map phase.\n' + ' large.scope().region_named("small-star", |region| {\n' + ' large.enter_region(region)\n' + ' .map(|(u, v)| if u > v { (u, v) } else { (v, u) })\n'), + ('small-star close / minimum-label', + ' // must dissipate, otherwise an empty logical delta can circulate.\n' + ' .distinct_core::()\n' + ' });\n' + ' stars\n' + ' .concat(&nodes.map(|node| (node, node)))\n' + ' .reduce(|_, labels, output| output.push((*labels[0].0, 1)))\n' + ' .map(move |(node, label)| pack_label(ddlog_std::tuple2(node, label)))\n' + '}', + ' // must dissipate, otherwise an empty logical delta can circulate.\n' + ' .distinct_core::().leave_region()\n' + ' })\n' + ' });\n' + ' stars.scope().region_named("minimum-label", |region| {\n' + ' let nodes = nodes.enter_region(region);\n' + ' stars.enter_region(region)\n' + ' .concat(&nodes.map(|node| (node, node)))\n' + ' .reduce(|_, labels, output| output.push((*labels[0].0, 1)))\n' + ' .map(move |(node, label)| pack_label(ddlog_std::tuple2(node, label))).leave_region()\n' + ' })\n' + '}'), +] + + +def patch_once(source, original, patched, label): + # Worker replacement removes the original; CLI replacement contains it. + expected_original_count = patched.count(original) + if source.count(patched) == 1 and source.count(original) == expected_original_count: + return source + if patched in source or source.count(original) != 1: + raise ValueError(f"Pinned {label} hook site changed or duplicated") + return source.replace(original, patched, 1) + + +def patch_star(source): + """Wrap the three star phases in named regions; every site must pin exactly.""" + for label, original, patched in STAR_SITES: + source = patch_once(source, original, patched, f'star {label}') + return source + + +def install(project): + project = Path(project) + lib = project / 'differential_datalog/src/lib.rs' + worker = project / 'differential_datalog/src/program/worker.rs' + cli = project / 'src/main.rs' + source = lib.read_text() + if source.count('mod observer;') > 1: + raise ValueError('Duplicate observer module declaration') + module = source if 'mod observer;' in source else source + '\nmod observer;\n' + worker_text = patch_once(worker.read_text(), WORKER_SITE, WORKER_PATCH, 'worker') + cli_text = patch_once(cli.read_text(), CLI_SITE, CLI_PATCH, 'native CLI') + native_text = (ROOT / 'native/observer.rs').read_text() + writes = [(lib, module), (worker, worker_text), (cli, cli_text), + (project / 'differential_datalog/src/observer.rs', native_text)] + star = project / STAR_LIBRARY + star_phases = None + if star.exists(): + writes.append((star, patch_star(star.read_text()))) + star_phases = True + for path, text in writes: + if not path.exists() or path.read_text() != text: + path.write_text(text) + marker = {'schema_version': 1, 'observer_hook': True, 'star_phases': star_phases, + 'phases': ['large-star', 'small-star', 'minimum-label'] if star_phases else []} + (project / 'observer-install.json').write_text(json.dumps(marker, indent=1) + '\n') + return marker + + +if __name__ == '__main__': + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('project', type=Path) + install(parser.parse_args().project) diff --git a/scripts/test_install_observer.py b/scripts/test_install_observer.py new file mode 100644 index 0000000..8d8ec8f --- /dev/null +++ b/scripts/test_install_observer.py @@ -0,0 +1,43 @@ +import importlib.util +from pathlib import Path +import tempfile +import unittest + +spec = importlib.util.spec_from_file_location('install_observer', Path(__file__).with_name('install-observer.py')) +hook = importlib.util.module_from_spec(spec) +spec.loader.exec_module(hook) + +class InstallObserverTests(unittest.TestCase): + def test_patch_is_exact_and_idempotent(self): + for original, patched in [(hook.WORKER_SITE, hook.WORKER_PATCH), (hook.CLI_SITE, hook.CLI_PATCH)]: + source = 'prefix\n' + original + '\nsuffix' + result = hook.patch_once(source, original, patched, 'test') + self.assertEqual(result, 'prefix\n' + patched + '\nsuffix') + self.assertEqual(hook.patch_once(result, original, patched, 'test'), result) + for invalid in ['', original + original, patched + patched, patched + original]: + with self.assertRaises(ValueError): + hook.patch_once(invalid, original, patched, 'test') + + def test_failed_validation_does_not_partially_patch(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + paths = {'differential_datalog/src/lib.rs':'library', + 'differential_datalog/src/program/worker.rs':hook.WORKER_SITE, + 'src/main.rs':'changed CLI'} + for name, content in paths.items(): + path = root / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content) + with self.assertRaises(ValueError): + hook.install(root) + for name, content in paths.items(): + self.assertEqual((root/name).read_text(),content) + self.assertFalse((root/'differential_datalog/src/observer.rs').exists()) + (root/'src/main.rs').write_text(hook.CLI_SITE) + hook.install(root) + first = {str(p.relative_to(root)):p.read_bytes() for p in root.rglob('*.rs')} + hook.install(root) + second = {str(p.relative_to(root)):p.read_bytes() for p in root.rglob('*.rs')} + self.assertEqual(first,second) + +if __name__ == '__main__': unittest.main() diff --git a/src/bin/ddlog-worlds.rs b/src/bin/ddlog-worlds.rs new file mode 100644 index 0000000..82001aa --- /dev/null +++ b/src/bin/ddlog-worlds.rs @@ -0,0 +1,146 @@ +//! Explicitly launched local control-plane owner; one manager per process. +use ddlog_runtime::worlds::{ + ImportRequest, InventoryQuery, RegisterRequest, TestRequest, WorldDefinition, WorldManager, +}; +use serde_json::{json, Value}; +use std::io::{self, BufRead, Read, Write}; +fn request(manager: &mut WorldManager, request: Value) -> Result { + let args = &request["args"]; + let id = || { + args["id"] + .as_str() + .ok_or_else(|| "Missing world id".to_string()) + }; + let pin = || -> Result<(&str, &str), String> { + Ok(( + args["processor_id"] + .as_str() + .ok_or("Missing processor id")?, + args["version"].as_str().ok_or("Missing version")?, + )) + }; + match request["operation"].as_str().ok_or("Missing operation")? { + "runtime_info" => Ok(ddlog_runtime::runtime_info()), + "inventory" => { + let query: InventoryQuery = if args.is_null() { + InventoryQuery::default() + } else { + serde_json::from_value(args.clone()).map_err(|e| e.to_string())? + }; + manager.inventory(&query) + } + "libraries" => manager.libraries(), + "library_create" => { + manager.create_library(serde_json::from_value(args.clone()).map_err(|e| e.to_string())?) + } + "create" => { + let definition: WorldDefinition = + serde_json::from_value(args.clone()).map_err(|e| e.to_string())?; + let id = manager.create(definition)?; + manager.status(&id) + } + "start" => manager.start_async(id()?), + "stop" => manager.stop(id()?), + "status" | "inspect" => manager.status(id()?), + "register" => { + let request: RegisterRequest = + serde_json::from_value(args.clone()).map_err(|e| e.to_string())?; + manager.register(request) + } + "definition" => { + let (processor_id, version) = pin()?; + serde_json::to_value(manager.registry()?.get(processor_id, Some(version))?) + .map_err(|e| e.to_string()) + } + "definitions" => manager.definitions(), + "import" => { + let request: ImportRequest = + serde_json::from_value(args.clone()).map_err(|e| e.to_string())?; + manager.import(request) + } + "scenarios_set" => { + let (processor_id, version) = pin()?; + let scenarios = + serde_json::from_value(args["scenarios"].clone()).map_err(|e| e.to_string())?; + manager.scenarios_set(processor_id, version, scenarios) + } + "scenarios_get" => { + let (processor_id, version) = pin()?; + manager.scenarios_get(processor_id, version) + } + "capture_get" => { + let (processor_id, version) = pin()?; + manager.capture_get(processor_id, version) + } + "test" => { + let request: TestRequest = + serde_json::from_value(args.clone()).map_err(|e| e.to_string())?; + manager.test(request) + } + "execute" => manager.execute( + id()?, + args["operation"] + .as_str() + .ok_or("Missing world operation")?, + &args["args"], + ), + _ => Err("Unknown control-plane operation".into()), + } +} +fn main() -> Result<(), Box> { + let args: Vec<_> = std::env::args().skip(1).collect(); + if args.len() != 3 { + return Err("Usage: ddlog-worlds REGISTRY BUILD_ROOT BUILD_DRIVER".into()); + } + let mut manager = WorldManager::new( + args[0].clone().into(), + args[1].clone().into(), + args[2].clone().into(), + )?; + #[cfg(unix)] + { + use std::sync::atomic::{AtomicBool, Ordering}; + static STOP: AtomicBool = AtomicBool::new(false); + extern "C" fn signal(_: libc::c_int) { + STOP.store(true, Ordering::SeqCst); + } + unsafe { + libc::signal(libc::SIGTERM, signal as *const () as libc::sighandler_t); + libc::signal(libc::SIGINT, signal as *const () as libc::sighandler_t); + } + let shutdown = manager.shutdown_handle(); + std::thread::spawn(move || loop { + if STOP.load(Ordering::SeqCst) { + shutdown.stop_all(); + std::process::exit(143); + } + std::thread::sleep(std::time::Duration::from_millis(20)); + }); + } + let mut input = io::stdin().lock(); + let mut output = io::stdout().lock(); + loop { + // Limit framing without allocating an arbitrarily long caller line. + let mut bytes = Vec::new(); + let count = std::io::Read::by_ref(&mut input) + .take(1024 * 1024 + 1) + .read_until(b'\n', &mut bytes)?; + if count == 0 { + break; + } + if count > 1024 * 1024 || bytes.last() != Some(&b'\n') { + return Err("Oversized or incomplete control request".into()); + } + let response = match serde_json::from_slice(&bytes) + .map_err(|e| e.to_string()) + .and_then(|value| request(&mut manager, value)) + { + Ok(value) => json!({"ok":true,"result":value}), + Err(error) => json!({"ok":false,"error":error}), + }; + serde_json::to_writer(&mut output, &response)?; + output.write_all(b"\n")?; + output.flush()?; + } + Ok(()) +} diff --git a/src/inspection.rs b/src/inspection.rs new file mode 100644 index 0000000..9049b76 --- /dev/null +++ b/src/inspection.rs @@ -0,0 +1,801 @@ +//! Versioned inspection contracts. Layout is client-owned; native IDs are opaque. +use serde::{Deserialize, Serialize}; +use std::collections::{BTreeMap, BTreeSet}; +pub const INSPECTION_SCHEMA_VERSION: u32 = 1; +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Provenance { + pub repository: String, + pub revision: String, + pub source: Option, +} +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SourceLocation { + pub path: String, + pub start_line: u32, + pub start_column: u32, + pub end_line: u32, + pub end_column: u32, +} +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PortDirection { + Input, + Output, +} +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct NativePort { + pub operator_id: String, + pub index: u64, +} +/// Snapshot-time port binding: the one member operator whose `debug` matches +/// `debug_pattern` (a regular expression), at native port `index`. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct NativeMatch { + pub debug_pattern: String, + pub index: u64, +} +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct AuthoredPort { + pub id: String, + pub name: String, + pub direction: PortDirection, + pub data_type: String, + #[serde(default)] + pub native_ports: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub native_match: Option, +} +/// Snapshot-time membership: `scope_name` claims every native scope of that +/// name with its whole subtree; `debug_pattern` (a regular expression over the +/// operator `debug` text) claims matching operators nobody else claimed first. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct MemberMatch { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub scope_name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub debug_pattern: Option, +} +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct AuthoredGroup { + pub id: String, + pub name: String, + pub member_key: String, + pub provenance: Provenance, + #[serde(default, rename = "memberIds")] + pub member_ids: Vec, + #[serde(default)] + pub ports: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub member_match: Option, +} +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct InspectionMetadata { + pub schema_version: u32, + #[serde(default, rename = "authoredGroups")] + pub authored_groups: Vec, +} +impl Default for InspectionMetadata { + fn default() -> Self { + Self { + schema_version: 1, + authored_groups: vec![], + } + } +} +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct NativeOperator { + pub id: String, + pub operator_id: u64, + pub worker: u64, + pub address: Vec, + pub name: String, + pub debug: String, +} +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct NativeChannel { + pub id: String, + pub channel_id: u64, + pub source_address: Vec, + pub target_address: Vec, + pub worker: u64, + pub scope: Vec, + pub source: String, + pub target: String, + pub source_port: u64, + pub target_port: u64, +} +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct NativeGraph { + pub nodes: Vec, + pub edges: Vec, +} +fn nonempty(s: &str, label: &str) -> Result<(), String> { + if s.trim().is_empty() { + Err(format!("{label} must not be empty")) + } else { + Ok(()) + } +} +fn unique(set: &mut BTreeSet, id: &str, label: &str) -> Result<(), String> { + nonempty(id, label)?; + if !set.insert(id.to_owned()) { + return Err(format!("duplicate {label}: {id}")); + } + Ok(()) +} +fn pattern(text: &str, label: &str) -> Result { + nonempty(text, label)?; + if text.len() > 1024 { + return Err(format!("{label} exceeds 1024 bytes")); + } + regex::RegexBuilder::new(text) + .size_limit(1 << 20) + .build() + .map_err(|e| format!("invalid {label}: {e}")) +} +/// Parent/child containment by worker and address prefix, as the layout does. +struct Family<'a> { + graph: &'a NativeGraph, + parent: Vec>, + children: Vec>, +} +impl<'a> Family<'a> { + fn new(graph: &'a NativeGraph) -> Self { + let mut parent = vec![None; graph.nodes.len()]; + let mut children = vec![vec![]; graph.nodes.len()]; + for (index, node) in graph.nodes.iter().enumerate() { + let mut best: Option = None; + for (candidate, other) in graph.nodes.iter().enumerate() { + if other.worker != node.worker + || other.address.is_empty() + || other.address.len() >= node.address.len() + || !node.address.starts_with(&other.address) + { + continue; + } + if best.is_none_or(|b| other.address.len() > graph.nodes[b].address.len()) { + best = Some(candidate); + } + } + parent[index] = best; + if let Some(best) = best { + children[best].push(index); + } + } + Self { + graph, + parent, + children, + } + } + fn subtree(&self, index: usize) -> Vec { + let scope = &self.graph.nodes[index]; + (0..self.graph.nodes.len()) + .filter(|&i| { + i == index + || (self.graph.nodes[i].worker == scope.worker + && self.graph.nodes[i].address.len() > scope.address.len() + && self.graph.nodes[i].address.starts_with(&scope.address)) + }) + .collect() + } +} +impl InspectionMetadata { + /// Validate definitions before registration without launching a process. + /// Match-based groups and ports are checked for shape only; their + /// membership is resolved against a live graph by [`Self::resolve`]. + pub fn validate(&self) -> Result<(), String> { + if self.schema_version != INSPECTION_SCHEMA_VERSION { + return Err("unsupported inspection schema version".into()); + } + let (mut groups, mut keys, mut members) = + (BTreeSet::new(), BTreeSet::new(), BTreeSet::new()); + for g in &self.authored_groups { + unique(&mut groups, &g.id, "group id")?; + unique(&mut keys, &g.member_key, "member key")?; + nonempty(&g.name, "group name")?; + nonempty(&g.provenance.repository, "repository")?; + nonempty(&g.provenance.revision, "revision")?; + if let Some(s) = &g.provenance.source { + nonempty(&s.path, "source path")?; + if s.start_line == 0 + || s.start_column == 0 + || s.end_line == 0 + || s.end_column == 0 + || (s.end_line, s.end_column) < (s.start_line, s.start_column) + { + return Err("invalid source location".into()); + } + } + if let Some(m) = &g.member_match { + if m.scope_name.is_none() && m.debug_pattern.is_none() { + return Err(format!( + "group {}: member_match needs scope_name or debug_pattern", + g.id + )); + } + if let Some(name) = &m.scope_name { + nonempty(name, "scope_name")?; + } + if let Some(text) = &m.debug_pattern { + pattern(text, "debug_pattern")?; + } + } + for id in &g.member_ids { + unique(&mut members, id, "native group member")?; + } + let mut ports = BTreeSet::new(); + for p in &g.ports { + unique(&mut ports, &p.id, "port id")?; + nonempty(&p.name, "port name")?; + nonempty(&p.data_type, "port type")?; + if let Some(m) = &p.native_match { + pattern(&m.debug_pattern, "native_match debug_pattern")?; + } + let mut mappings = BTreeSet::new(); + for n in &p.native_ports { + if g.member_match.is_none() && !g.member_ids.contains(&n.operator_id) { + return Err("port maps outside authored group".into()); + } + if !mappings.insert((&n.operator_id, n.index)) { + return Err("duplicate native port mapping".into()); + } + } + } + } + Ok(()) + } + /// Resolve match-based membership against a live graph, deterministically: + /// groups in declaration order; explicit `memberIds` and `scope_name` + /// matches claim first (a matched scope claims its whole subtree), then + /// `debug_pattern` matches claim only unclaimed operators. Zero matches, a + /// port matching zero or several operators, a split native scope or members + /// under different native parents are mapping errors naming the group. + pub fn resolve(&self, graph: &NativeGraph) -> Result { + self.validate()?; + let family = Family::new(graph); + let index_of: BTreeMap<&str, usize> = graph + .nodes + .iter() + .enumerate() + .map(|(i, n)| (n.id.as_str(), i)) + .collect(); + let mut owner: Vec> = vec![None; graph.nodes.len()]; + let mut members: Vec> = vec![BTreeSet::new(); self.authored_groups.len()]; + let mut claim = + |group: usize, node: usize, owner: &mut Vec>| match owner[node] { + Some(other) if other != group => Err(format!( + "group {}: native operator {} is already claimed by group {}", + self.authored_groups[group].id, + graph.nodes[node].id, + self.authored_groups[other].id + )), + _ => { + owner[node] = Some(group); + members[group].insert(node); + Ok(()) + } + }; + for (gi, g) in self.authored_groups.iter().enumerate() { + for id in &g.member_ids { + let node = *index_of + .get(id.as_str()) + .ok_or_else(|| format!("group {}: unknown native operator: {id}", g.id))?; + claim(gi, node, &mut owner)?; + } + if let Some(name) = g + .member_match + .as_ref() + .and_then(|m| m.scope_name.as_deref()) + { + let scopes: Vec = (0..graph.nodes.len()) + .filter(|&i| graph.nodes[i].name == name && !family.children[i].is_empty()) + .collect(); + if scopes.is_empty() { + return Err(format!( + "group {}: scope_name {name:?} matched no native scope", + g.id + )); + } + for scope in scopes { + for node in family.subtree(scope) { + claim(gi, node, &mut owner)?; + } + } + } + } + for (gi, g) in self.authored_groups.iter().enumerate() { + let Some(text) = g + .member_match + .as_ref() + .and_then(|m| m.debug_pattern.as_deref()) + else { + continue; + }; + let regex = pattern(text, "debug_pattern")?; + let matched: Vec = (0..graph.nodes.len()) + .filter(|&i| regex.is_match(&graph.nodes[i].debug)) + .collect(); + if matched.is_empty() { + return Err(format!( + "group {}: debug_pattern {text:?} matched no native operator", + g.id + )); + } + for node in matched { + if owner[node].is_none() { + claim(gi, node, &mut owner)?; + } + } + } + let mut resolved = self.clone(); + for (gi, g) in resolved.authored_groups.iter_mut().enumerate() { + let set = &members[gi]; + if set.is_empty() { + return Err(format!("group {}: no native members", g.id)); + } + for &node in set { + if let Some(child) = family.children[node] + .iter() + .find(|child| !set.contains(child)) + { + return Err(format!( + "group {}: would split native scope {} (child {} is not a member)", + g.id, graph.nodes[node].id, graph.nodes[*child].id + )); + } + } + let parents: BTreeSet> = set + .iter() + .map(|&node| family.parent[node]) + .filter(|parent| parent.is_none_or(|p| !set.contains(&p))) + .collect(); + if parents.len() > 1 { + return Err(format!( + "group {}: members cross native parent boundaries", + g.id + )); + } + g.member_ids = set + .iter() + .map(|&node| graph.nodes[node].id.clone()) + .collect(); + for p in &mut g.ports { + if let Some(m) = &p.native_match { + let regex = pattern(&m.debug_pattern, "native_match debug_pattern")?; + let hits: Vec = set + .iter() + .copied() + .filter(|&node| regex.is_match(&graph.nodes[node].debug)) + .collect(); + if hits.len() != 1 { + return Err(format!( + "group {}: port {} native_match matched {} member operators, expected exactly one", + g.id, p.id, hits.len() + )); + } + let mapping = NativePort { + operator_id: graph.nodes[hits[0]].id.clone(), + index: m.index, + }; + if !p.native_ports.contains(&mapping) { + p.native_ports.push(mapping); + } + } + if let Some(n) = p + .native_ports + .iter() + .find(|n| !g.member_ids.contains(&n.operator_id)) + { + return Err(format!( + "group {}: port {} maps outside the group ({})", + g.id, p.id, n.operator_id + )); + } + } + } + resolved.validate_with_graph(graph)?; + Ok(resolved) + } + /// Verify live mappings against actual channels; unconnected ports are unverified. + pub fn validate_with_graph(&self, graph: &NativeGraph) -> Result<(), String> { + self.validate()?; + graph.validate()?; + for g in &self.authored_groups { + if graph.nodes.iter().any(|n| n.id == g.id) { + return Err("authored group id collides with native operator".into()); + } + for id in &g.member_ids { + if !graph.nodes.iter().any(|n| &n.id == id) { + return Err(format!("unknown native operator: {id}")); + } + } + for p in &g.ports { + for n in &p.native_ports { + if !graph.edges.iter().any(|e| match p.direction { + PortDirection::Input => { + e.target == n.operator_id && e.target_port == n.index + } + PortDirection::Output => { + e.source == n.operator_id && e.source_port == n.index + } + }) { + return Err("native port is not observed".into()); + } + } + } + } + Ok(()) + } +} +impl NativeGraph { + pub fn validate(&self) -> Result<(), String> { + let (mut ids, mut addresses, mut channels) = + (BTreeSet::new(), BTreeSet::new(), BTreeSet::new()); + for n in &self.nodes { + unique(&mut ids, &n.id, "operator id")?; + if n.address.is_empty() || !addresses.insert((n.worker, &n.address)) { + return Err("empty or duplicate worker address".into()); + } + } + for e in &self.edges { + unique(&mut channels, &e.id, "channel id")?; + for id in [&e.source, &e.target] { + let n = self + .nodes + .iter() + .find(|n| &n.id == id) + .ok_or_else(|| format!("unknown channel endpoint: {id}"))?; + if n.worker != e.worker { + return Err("channel endpoint worker mismatch".into()); + } + } + } + Ok(()) + } +} +#[cfg(test)] +mod tests { + use super::*; + fn fixture() -> (InspectionMetadata, NativeGraph) { + let g = NativeGraph { + nodes: vec![NativeOperator { + id: "0:1".into(), + operator_id: 1, + worker: 0, + address: vec![0, 1], + name: "Input".into(), + debug: "exact".into(), + }], + edges: vec![NativeChannel { + id: "0:9".into(), + channel_id: 9, + source_address: vec![0, 1], + target_address: vec![0, 1], + worker: 0, + scope: vec![0], + source: "0:1".into(), + target: "0:1".into(), + source_port: 3, + target_port: 4, + }], + }; + let m = InspectionMetadata { + schema_version: 1, + authored_groups: vec![AuthoredGroup { + id: "group".into(), + name: "Group".into(), + member_key: "root/group".into(), + provenance: Provenance { + repository: "repo".into(), + revision: "commit".into(), + source: None, + }, + member_ids: vec!["0:1".into()], + ports: vec![AuthoredPort { + id: "in".into(), + name: "Input".into(), + direction: PortDirection::Input, + data_type: "u64".into(), + native_ports: vec![NativePort { + operator_id: "0:1".into(), + index: 4, + }], + native_match: None, + }], + member_match: None, + }], + }; + (m, g) + } + fn operator(id: &str, address: &[u64], name: &str, debug: &str) -> NativeOperator { + NativeOperator { + id: id.into(), + operator_id: id[2..].parse().unwrap(), + worker: 0, + address: address.to_vec(), + name: name.into(), + debug: debug.into(), + } + } + fn matched(id: &str, member_match: MemberMatch) -> AuthoredGroup { + AuthoredGroup { + id: id.into(), + name: id.into(), + member_key: id.into(), + provenance: Provenance { + repository: "repo".into(), + revision: "commit".into(), + source: None, + }, + member_ids: vec![], + ports: vec![], + member_match: Some(member_match), + } + } + fn scoped_graph() -> NativeGraph { + NativeGraph { + nodes: vec![ + operator("0:0", &[0], "Dataflow", ""), + operator("0:1", &[0, 1], "Input", "Input { rel: \"R_edge\" }"), + operator( + "0:2", + &[0, 2], + "large-star", + "ApplyTransformer { transformer: \"Star\" } scope", + ), + operator( + "0:3", + &[0, 2, 1], + "Map", + "ApplyTransformer { transformer: \"Star\" } map", + ), + operator( + "0:4", + &[0, 2, 2], + "Reduce", + "ApplyTransformer { transformer: \"Star\" }", + ), + operator( + "0:5", + &[0, 3], + "Probe", + "ApplyTransformer { transformer: \"Star\" }", + ), + operator("0:6", &[0, 4], "Map", "other"), + ], + edges: vec![NativeChannel { + id: "0:9".into(), + channel_id: 9, + source_address: vec![0, 1], + target_address: vec![0, 2], + worker: 0, + scope: vec![0], + source: "0:1".into(), + target: "0:2".into(), + source_port: 0, + target_port: 0, + }], + } + } + #[test] + fn match_based_groups_validate_by_shape_and_resolve_deterministically() { + let graph = scoped_graph(); + let mut m = InspectionMetadata::default(); + m.authored_groups.push(matched( + "phase", + MemberMatch { + scope_name: Some("large-star".into()), + debug_pattern: None, + }, + )); + m.authored_groups.push(matched( + "transformer", + MemberMatch { + scope_name: None, + debug_pattern: Some("ApplyTransformer \\{ transformer: \"Star\"".into()), + }, + )); + m.validate().unwrap(); + let wire = serde_json::to_value(&m).unwrap(); + assert_eq!( + wire["authoredGroups"][0]["memberIds"], + serde_json::json!([]) + ); + assert_eq!( + wire["authoredGroups"][0]["member_match"], + serde_json::json!({"scope_name":"large-star"}) + ); + assert!(wire["authoredGroups"][0]["ports"] + .as_array() + .unwrap() + .is_empty()); + let resolved = m.resolve(&graph).unwrap(); + // The scope claims its subtree first; the pattern then claims only 0:5. + assert_eq!( + resolved.authored_groups[0].member_ids, + vec!["0:2", "0:3", "0:4"] + ); + assert_eq!(resolved.authored_groups[1].member_ids, vec!["0:5"]); + assert_eq!(resolved.resolve(&graph).unwrap(), resolved); + // Zero matches, split scopes and overlapping claims are named errors. + let mut zero = m.clone(); + zero.authored_groups[0].member_match = Some(MemberMatch { + scope_name: Some("missing".into()), + debug_pattern: None, + }); + assert!(zero.resolve(&graph).unwrap_err().contains("phase")); + let mut split = InspectionMetadata::default(); + let mut explicit = matched( + "split", + MemberMatch { + scope_name: None, + debug_pattern: Some("^ApplyTransformer".into()), + }, + ); + explicit.member_ids = vec!["0:3".into()]; + split.authored_groups.push(explicit); + split.authored_groups.push(matched( + "rest", + MemberMatch { + scope_name: Some("large-star".into()), + debug_pattern: None, + }, + )); + assert!(split + .resolve(&graph) + .unwrap_err() + .contains("already claimed")); + let mut torn = InspectionMetadata::default(); + torn.authored_groups.push(matched( + "torn", + MemberMatch { + scope_name: None, + debug_pattern: Some("scope$".into()), + }, + )); + assert!(torn + .resolve(&graph) + .unwrap_err() + .contains("split native scope")); + let mut crossing = InspectionMetadata::default(); + crossing.authored_groups.push(matched( + "crossing", + MemberMatch { + scope_name: None, + debug_pattern: Some("R_edge|map$".into()), + }, + )); + assert!(crossing + .resolve(&graph) + .unwrap_err() + .contains("cross native parent")); + // Ports resolve to exactly one member operator on an observed channel. + let mut ported = InspectionMetadata::default(); + let mut group = matched( + "ported", + MemberMatch { + scope_name: Some("large-star".into()), + debug_pattern: None, + }, + ); + group.ports.push(AuthoredPort { + id: "in".into(), + name: "in".into(), + direction: PortDirection::Input, + data_type: "edge".into(), + native_ports: vec![], + native_match: Some(NativeMatch { + debug_pattern: "Star".into(), + index: 0, + }), + }); + ported.authored_groups.push(group.clone()); + assert!(ported + .resolve(&graph) + .unwrap_err() + .contains("expected exactly one")); + ported.authored_groups[0].ports[0].native_match = Some(NativeMatch { + debug_pattern: "Star".into(), + index: 0, + }); + ported.authored_groups[0].member_match = Some(MemberMatch { + scope_name: Some("large-star".into()), + debug_pattern: None, + }); + let mut single = graph.clone(); + single.nodes[3].debug = "plain".into(); + single.nodes[4].debug = "plain".into(); + let resolved = ported.resolve(&single).unwrap(); + assert_eq!( + resolved.authored_groups[0].ports[0].native_ports, + vec![NativePort { + operator_id: "0:2".into(), + index: 0 + }] + ); + ported.authored_groups[0].ports[0] + .native_match + .as_mut() + .unwrap() + .index = 7; + assert!(ported + .resolve(&single) + .unwrap_err() + .contains("not observed")); + let mut malformed = m; + malformed.authored_groups[1].member_match = Some(MemberMatch { + scope_name: None, + debug_pattern: Some("(".into()), + }); + assert!(malformed.validate().unwrap_err().contains("debug_pattern")); + malformed.authored_groups[1].member_match = Some(MemberMatch { + scope_name: None, + debug_pattern: None, + }); + assert!(malformed.validate().is_err()); + } + #[test] + fn lossless_contract() { + let (m, g) = fixture(); + let before = g.clone(); + m.validate_with_graph(&g).unwrap(); + assert_eq!(g, before); + let wire = serde_json::to_value(&m).unwrap(); + assert_eq!(wire["authoredGroups"][0]["memberIds"][0], "0:1"); + assert_eq!( + serde_json::from_value::(wire).unwrap(), + m + ); + assert_eq!( + serde_json::from_value::(serde_json::to_value(&g).unwrap()).unwrap(), + g + ); + } + #[test] + fn rejects_invalid_metadata() { + let (m, g) = fixture(); + let mut x = m.clone(); + x.schema_version = 2; + assert!(x.validate().is_err()); + let mut x = m.clone(); + x.authored_groups.push(x.authored_groups[0].clone()); + assert!(x.validate().is_err()); + let mut x = m.clone(); + x.authored_groups[0].member_ids.push("0:1".into()); + assert!(x.validate().is_err()); + let mut x = m.clone(); + x.authored_groups[0].ports[0].native_ports[0].index = 99; + assert!(x.validate_with_graph(&g).is_err()); + let mut x = m.clone(); + x.authored_groups[0].member_ids.push("missing".into()); + assert!(x.validate_with_graph(&g).is_err()); + let mut x = m; + x.authored_groups[0].ports[0].data_type.clear(); + assert!(x.validate().is_err()); + } + #[test] + fn rejects_invalid_native_graph() { + let (_, g) = fixture(); + let mut x = g.clone(); + x.nodes.push(x.nodes[0].clone()); + assert!(x.validate().is_err()); + let mut x = g.clone(); + x.edges[0].target = "missing".into(); + assert!(x.validate().is_err()); + let mut x = g.clone(); + x.edges[0].worker = 1; + assert!(x.validate().is_err()); + let mut x = g; + x.edges.push(x.edges[0].clone()); + assert!(x.validate().is_err()); + } +} diff --git a/src/instance.rs b/src/instance.rs index b75746e..1bbf4dd 100644 --- a/src/instance.rs +++ b/src/instance.rs @@ -1,10 +1,92 @@ //! Program state and semantic operations, independent of MCP and connections. use crate::composition::CompositionResolution; -use crate::registry::{GitProvenance, ProcessorDefinition, ProcessorRegistry}; -use crate::{AgentProgram, Backend, Operation}; +use crate::registry::{GitProvenance, ProcessorDefinition, ProcessorRegistry, ProcessorVersion}; +use crate::{AgentProgram, Backend, Operation, Schema}; +use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use std::collections::BTreeMap; +/// One relation addressable through a world's public tools. `physical` is the +/// generated native relation name; every other field is the public contract. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PublicRelation { + pub name: String, + pub input: bool, + pub fields: Vec, + pub physical: String, +} + +/// Public relations of one registered definition, without a live instance. +/// A program without an interface exposes every declared schema; a program with +/// an interface exposes its interface inputs and outputs; a composition exposes +/// its resolution inputs and outputs. Inputs precede outputs, each sorted by name. +pub fn public_relations(record: &ProcessorVersion) -> Result, String> { + match &record.definition { + ProcessorDefinition::Program(program) => { + let schemas: BTreeMap = + serde_json::from_value(program.schemas.clone()).map_err(|e| e.to_string())?; + let relation = |name: &str| -> Result { + let schema = schemas + .get(name) + .ok_or_else(|| format!("Interface relation {name} is not declared"))?; + Ok(PublicRelation { + name: name.to_string(), + input: schema.input, + fields: schema.fields.clone(), + physical: name.to_string(), + }) + }; + let mut names: Vec<&str> = match &program.interface { + Some(interface) => interface + .inputs + .iter() + .chain(&interface.outputs) + .map(String::as_str) + .collect(), + None => schemas.keys().map(String::as_str).collect(), + }; + names.sort_by_key(|name| (!schemas.get(*name).is_some_and(|s| s.input), *name)); + names.iter().map(|name| relation(name)).collect() + } + ProcessorDefinition::Composition(_) => { + let resolution = record + .composition + .as_ref() + .ok_or("Composition record lacks its resolution")?; + let mut relations = Vec::new(); + for (input, ports) in [(true, &resolution.inputs), (false, &resolution.outputs)] { + for (public, physical) in ports { + let fields = resolution + .relations + .get(physical) + .and_then(|r| r.get("fields")) + .cloned() + .ok_or_else(|| format!("Composition relation {physical} lacks fields"))?; + relations.push(PublicRelation { + name: public.clone(), + input, + fields: serde_json::from_value(fields).map_err(|e| e.to_string())?, + physical: physical.clone(), + }); + } + } + Ok(relations) + } + } +} + +/// Continuation for paged reads of retained input facts. Output continuations +/// are the native-bound [`crate::QueryCursor`]; both are opaque to callers. +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct InputCursor { + kind: String, + revision: u64, + predicate: String, + offset: usize, +} + /// One semantic owner whose caller controls its lifetime independently of connections. pub struct ProgramInstance { pub(super) backend: Backend, @@ -13,6 +95,7 @@ pub struct ProgramInstance { pub(crate) registry: Option, pub(crate) instance_id: Option, processor: Option, + record: Option, interface: Option, composition: Option, } @@ -31,6 +114,7 @@ impl ProgramInstance { instance_id, agent: None, processor: None, + record: None, interface: None, composition: None, } @@ -45,9 +129,14 @@ impl ProgramInstance { /// An error never authorizes automatic retry of an uncertain mutation. pub fn execute(&mut self, name: &str, a: &Value) -> Result { if name == "instance_info" && self.instance_id.is_some() { - return Ok( - json!({"instance_id":self.instance_id,"health":self.backend.health(),"processor":self.processor,"composition":self.composition}), - ); + let live = self.backend.health() == "ready"; + return Ok(json!({ + "instance_id":self.instance_id,"health":self.backend.health(), + "processor":self.processor,"composition":self.composition, + "revision":live.then(|| self.backend.revision()), + "program_version":live.then_some(self.backend.version), + "source_sha256":live.then(|| self.backend.source_sha256()), + })); } if name.starts_with("processor_") && name != "processor_install" { return self.execute_registry(name, a); @@ -143,15 +232,43 @@ impl ProgramInstance { "Registered operation relations must use the operation tools".into(), ); } - if let Some(interface) = &self.interface { + let mut result = if let Some(interface) = &self.interface { let changes = interface.changes(&a["changes"])?; let mut result = self.backend.apply(&changes)?; result["deltas"] = json!(interface.outputs(result["deltas"].as_str().unwrap_or(""))); - Ok(result) + result } else { - self.backend.apply(&a["changes"]) + self.backend.apply(&a["changes"])? + }; + result["revision"] = json!(self.backend.revision()); + Ok(result) + } + "relations" => { + let mut relations = Vec::new(); + for relation in self.public_relations()? { + let count = if relation.input { + self.backend + .export_inputs()? + .get(&relation.physical) + .map_or(0, |rows| rows.len() as u64) + } else { + self.backend.count_rows(&relation.physical)? + }; + relations.push(json!({"name":relation.name,"input":relation.input,"fields":relation.fields,"count":count})); } + Ok(json!({"revision":self.backend.revision(),"relations":relations})) + } + "query_rows" => self.query_rows(a), + "program_source" => { + if self.backend.health() != "ready" { + return Err( + "Program source is available from a healthy installed instance".into(), + ); + } + Ok( + json!({"source":self.backend.program_source(),"source_sha256":self.backend.source_sha256()}), + ) } "lemmalog_query" => { let predicate = string(a, "predicate")?; @@ -186,6 +303,102 @@ impl ProgramInstance { } } + /// Public relations of the running program: the pinned record's contract + /// when installed from the registry, otherwise every declared schema. + pub fn public_relations(&self) -> Result, String> { + if let Some(record) = &self.record { + return public_relations(record); + } + if self.backend.health() != "ready" { + return Err("Install a program first".into()); + } + let mut relations: Vec = self + .backend + .schemas() + .iter() + .map(|(name, schema)| PublicRelation { + name: name.clone(), + input: schema.input, + fields: schema.fields.clone(), + physical: name.clone(), + }) + .collect(); + relations.sort_by_key(|relation| (!relation.input, relation.name.clone())); + Ok(relations) + } + + /// Typed page of one public relation. Outputs stream through the bounded + /// native reader; inputs page over retained facts. `total` is always the + /// full row count at the reported revision. + fn query_rows(&mut self, a: &Value) -> Result { + let predicate = string(a, "predicate")?; + let max_rows = match a.get("max_rows") { + None | Some(Value::Null) => 500, + Some(value) => usize::try_from(value.as_u64().ok_or("max_rows must be an integer")?) + .map_err(|e| e.to_string())?, + }; + if !(1..=crate::MAX_QUERY_ROWS).contains(&max_rows) { + return Err(format!("max_rows must be in 1..={}", crate::MAX_QUERY_ROWS)); + } + let relation = self + .public_relations()? + .into_iter() + .find(|relation| relation.name == predicate) + .ok_or_else(|| format!("Unknown public relation {predicate}"))?; + let continuation = a.get("continuation").filter(|value| !value.is_null()); + if relation.input { + let offset = match continuation { + Some(cursor) => { + let cursor: InputCursor = + serde_json::from_value(cursor.clone()).map_err(|e| e.to_string())?; + if cursor.kind != "input" + || cursor.revision != self.backend.revision() + || cursor.predicate != predicate + { + return Err( + "Continuation does not match the live owner, revision or query".into(), + ); + } + cursor.offset + } + None => 0, + }; + let inputs = self.backend.export_inputs()?; + let all = inputs + .get(&relation.physical) + .ok_or("Retained input schema missing")?; + if offset > all.len() { + return Err("Continuation offset exceeds the selected relation".into()); + } + let rows: Vec> = all.iter().skip(offset).take(max_rows).cloned().collect(); + let end = offset + rows.len(); + let complete = end >= all.len(); + let revision = self.backend.revision(); + return Ok(json!({ + "predicate":predicate,"revision":revision,"fields":relation.fields,"rows":rows, + "total":all.len(),"complete":complete, + "continuation":(!complete).then(|| json!(InputCursor{kind:"input".into(),revision,predicate:predicate.into(),offset:end})), + })); + } + let query = crate::BoundedQuery { + filters: BTreeMap::new(), + max_rows, + max_bytes: crate::MAX_QUERY_BYTES, + continuation: continuation + .map(|cursor| serde_json::from_value(cursor.clone()).map_err(|e| e.to_string())) + .transpose()?, + }; + let page = self + .backend + .query_typed_bounded(&relation.physical, &query)?; + let total = self.backend.count_rows(&relation.physical)?; + Ok(json!({ + "predicate":predicate,"revision":page.revision,"fields":relation.fields,"rows":page.rows, + "total":total,"complete":!page.truncated, + "continuation":page.continuation.map(|cursor| serde_json::to_value(cursor).unwrap_or(Value::Null)), + })) + } + fn execute_registry(&self, name: &str, a: &Value) -> Result { let registry = self .registry @@ -287,6 +500,7 @@ impl ProgramInstance { string(a, "processor_id")?, a.get("version").map(|_| string(a, "version")).transpose()?, )?; + let pinned = record.clone(); let mut result = match record.definition { ProcessorDefinition::Composition(definition) => { let compiled = self @@ -353,6 +567,7 @@ impl ProgramInstance { } }; self.processor = Some(json!({"processor_id":record.processor_id,"version":record.version})); + self.record = Some(pinned); result["processor"] = self.processor.clone().unwrap(); if let Some(composition) = &self.composition { result["composition"] = serde_json::to_value(composition).map_err(|e| e.to_string())?; diff --git a/src/lib.rs b/src/lib.rs index 633da44..bf652f2 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4,7 +4,10 @@ //! [`ProgramInstance::execute`] without JSON-RPC or a transport connection. //! Compilation is delegated to an operator-configured external DDlog driver. pub use lemmalog_syntax as syntax; +pub mod inspection; pub mod instance; +mod telemetry; +pub mod worlds; pub use instance::ProgramInstance; mod bounded; mod checkpoint; @@ -27,6 +30,18 @@ pub mod star; pub use lower::{lower, lower_with_operators, Schema}; pub use operations::{AgentProgram, Operation}; +/// Build-time identity of this runtime binary, recorded by `build.rs`. +/// `commit` is null when the crate was not built from a git checkout. +pub fn runtime_info() -> Value { + let commit = env!("DDLOG_RUNTIME_COMMIT"); + json!({ + "commit": if commit.is_empty() { Value::Null } else { Value::from(commit) }, + "dirty": env!("DDLOG_RUNTIME_DIRTY") == "true", + "crate_version": env!("CARGO_PKG_VERSION"), + "schema_version": 1, + }) +} + use serde_json::{json, Value}; use std::collections::BTreeMap; use std::io::{BufRead, BufReader, Read, Write}; @@ -44,12 +59,29 @@ struct Runtime { group: processes::Group, } impl Runtime { - fn start(binary: &Path, control: &processes::ProcessControl) -> Result { + fn start( + binary: &Path, + control: &processes::ProcessControl, + inspection: Option<&Path>, + observer: &ObserverOptions, + ) -> Result { let mut command = Command::new(binary); command .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::inherit()); + // Only configured capture options are added; the inherited environment + // is otherwise left alone, so an operator's own DDLOG_OBSERVER_* exports + // still reach a library-hosted child. + if let Some(path) = inspection { + command.env("DDLOG_OBSERVER_FILE", path); + } + if observer.detail_full { + command.env("DDLOG_OBSERVER_DETAIL", "full"); + } + if observer.rotate { + command.env("DDLOG_OBSERVER_ROTATE", "1"); + } processes::separate_group(&mut command, control); let mut child = command.spawn().map_err(|e| e.to_string())?; let group = control.track(child.id()); @@ -152,6 +184,17 @@ impl Drop for Runtime { } } +/// Native capture options passed to the child as `DDLOG_OBSERVER_DETAIL=full` +/// and `DDLOG_OBSERVER_ROTATE=1`. Both are off for library users (the child +/// then inherits whatever the operator exported); managed worlds turn both on +/// so activity keeps flowing for long-lived instances. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct ObserverOptions { + /// Schedule, message, progress and arrangement events, not only topology. + pub detail_full: bool, + /// Rotate the capture at the worker byte budget instead of truncating. + pub rotate: bool, +} /// One session, one typed program. Compilation and replay happen before activation. /// The external build driver receives source path and desired executable path. /// Only a trusted operator configures this executable; MCP callers cannot select it. @@ -167,6 +210,8 @@ pub struct Backend { active_source: String, failed: bool, control: processes::ProcessControl, + inspection_log: Option, + observer: ObserverOptions, } impl Backend { pub fn new(root: PathBuf, driver: PathBuf) -> Self { @@ -182,7 +227,28 @@ impl Backend { active_source: String::new(), failed: false, control: processes::ProcessControl::default(), + inspection_log: None, + observer: ObserverOptions::default(), + } + } + /// Capture options for the native child; only meaningful with an + /// inspection log. Takes effect at the next install. + pub fn set_observer(&mut self, observer: ObserverOptions) { + self.observer = observer; + } + /// Refresh child liveness without performing a graph operation. + pub fn observed_health(&mut self) -> &'static str { + if let Some(runtime) = &mut self.runtime { + match runtime.child.try_wait() { + Ok(None) => (), + Ok(Some(_)) | Err(_) => self.failed = true, + } } + self.health() + } + /// Native execution process only; not the shared embedding host. + pub fn runtime_pid(&self) -> Option { + self.runtime.as_ref().map(|runtime| runtime.child.id()) } pub fn health(&self) -> &'static str { if self.failed { @@ -200,6 +266,11 @@ impl Backend { pub fn program_source(&self) -> &str { &self.active_source } + /// SHA-256 of the lowered `program.dl` that the native child was built from. + pub fn source_sha256(&self) -> String { + use sha2::{Digest, Sha256}; + format!("{:x}", Sha256::digest(self.active_source.as_bytes())) + } pub fn schemas(&self) -> &BTreeMap { &self.schema } @@ -279,7 +350,12 @@ impl Backend { dir.join("build.log").display() )); } - let mut runtime = Runtime::start(&binary, &self.control)?; + let mut runtime = Runtime::start( + &binary, + &self.control, + self.inspection_log.as_deref(), + &self.observer, + )?; let mut replay = String::from("start;\n"); for (_, fact) in self.facts.keys() { replay.push_str(&format!("insert {fact};\n")); @@ -455,6 +531,41 @@ impl Backend { } Ok(result) } + /// Streamed row count of one maintained output relation. Rows are decoded + /// one native record at a time and never accumulated; a malformed record + /// fails the count after the response is drained, keeping the owner usable. + pub fn count_rows(&mut self, predicate: &str) -> Result { + let schema = self + .schema + .get(predicate) + .ok_or("Unknown relation")? + .clone(); + if schema.input { + return Err( + "Row counts scan output relations; inputs are counted from retained facts".into(), + ); + } + let runtime = self.runtime.as_mut().ok_or("Install a program first")?; + let mut count = 0_u64; + let mut error = None; + let exchange = runtime.exchange_stream(&format!("dump R_{predicate};"), |line| { + if error.is_some() { + return; + } + match line.and_then(|line| rows::decode_rows(line, predicate, &schema.fields)) { + Ok(decoded) => count += decoded.len() as u64, + Err(reason) => error = Some(reason), + } + }); + if let Err(error) = exchange { + self.runtime = None; + self.failed = true; + return Err(format!( + "Runtime unavailable; reconcile outstanding work: {error}" + )); + } + error.map_or(Ok(count), Err) + } pub fn why(&mut self, rule: usize) -> Result { // Evidence relation names are discovered in the lowered source, preventing command injection. let source = &self.active_source; diff --git a/src/processes.rs b/src/processes.rs index 72a3da8..acd0fa6 100644 --- a/src/processes.rs +++ b/src/processes.rs @@ -17,7 +17,7 @@ struct Control { groups: Mutex>, } impl ProcessControl { - #[cfg(all(feature = "mcp", unix))] + #[cfg(unix)] pub fn hosted() -> Self { Self { inner: Arc::new(Control { @@ -26,10 +26,13 @@ impl ProcessControl { }), } } + pub fn tracked_pids(&self) -> Vec { + self.inner.groups.lock().unwrap().iter().copied().collect() + } pub fn stopped(&self) -> bool { self.inner.stopped.load(Ordering::SeqCst) } - #[cfg(all(feature = "mcp", unix))] + #[cfg(unix)] pub fn stop(&self) { self.inner.stopped.store(true, Ordering::SeqCst); for &pid in self.inner.groups.lock().unwrap().iter() { diff --git a/src/registry.rs b/src/registry.rs index 649e34d..5426d29 100644 --- a/src/registry.rs +++ b/src/registry.rs @@ -40,6 +40,8 @@ pub enum ProcessorDefinition { #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(deny_unknown_fields)] pub struct ProgramDefinition { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub inspection: Option, pub rules: String, pub schemas: Value, #[serde(default)] @@ -53,6 +55,8 @@ pub struct ProgramDefinition { #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(deny_unknown_fields)] pub struct CompositionDefinition { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub inspection: Option, pub composition: CompositionManifest, } @@ -466,7 +470,7 @@ impl ProcessorRegistry { version: record.version, content_sha256: record.content_sha256, created_at_unix_ms: record.created_at_unix_ms, - kind: ProcessorKind::Program, + kind: kind_of(&record.definition), status: lifecycle.status, lifecycle_revision: lifecycle.lifecycle_revision, archived_at_unix_ms: if lifecycle.status == ProcessorStatus::Archived { @@ -564,7 +568,17 @@ impl ProcessorRegistry { &self, manifest: &CompositionManifest, ) -> Result { - let nodes = self.resolve_nodes(manifest, &mut Vec::new(), &mut 0)?; + self.compile_composition_with(manifest, &|reference| { + self.read_version(&reference.processor_id, &reference.version) + }) + } + + fn compile_composition_with( + &self, + manifest: &CompositionManifest, + reader: &dyn Fn(&ProcessorReference) -> Result, + ) -> Result { + let nodes = self.resolve_nodes(manifest, &mut Vec::new(), &mut 0, reader)?; super::composition::compile_resolved(manifest, &nodes) } @@ -573,6 +587,7 @@ impl ProcessorRegistry { manifest: &CompositionManifest, stack: &mut Vec, expanded: &mut usize, + reader: &dyn Fn(&ProcessorReference) -> Result, ) -> Result> { let mut nodes = BTreeMap::new(); for (alias, reference) in &manifest.nodes { @@ -587,7 +602,7 @@ impl ProcessorRegistry { } *expanded += 1; stack.push(reference.clone()); - let record = self.read_version(&reference.processor_id, &reference.version)?; + let record = reader(reference)?; let children = match &record.definition { ProcessorDefinition::Program(program) => { if program.operation.is_some() { @@ -600,7 +615,8 @@ impl ProcessorRegistry { BTreeMap::new() } ProcessorDefinition::Composition(definition) => { - let children = self.resolve_nodes(&definition.composition, stack, expanded)?; + let children = + self.resolve_nodes(&definition.composition, stack, expanded, reader)?; let compiled = super::composition::compile_resolved(&definition.composition, &children)?; if record.composition.as_ref() != Some(&compiled.resolution) { @@ -619,18 +635,270 @@ impl ProcessorRegistry { &self, definition: &ProcessorDefinition, ) -> Result> { + self.validate_definition_with(definition, &|reference| { + self.read_version(&reference.processor_id, &reference.version) + }) + } + + fn validate_definition_with( + &self, + definition: &ProcessorDefinition, + reader: &dyn Fn(&ProcessorReference) -> Result, + ) -> Result> { + let metadata = match definition { + ProcessorDefinition::Program(p) => &p.inspection, + ProcessorDefinition::Composition(c) => &c.inspection, + }; + if let Some(metadata) = metadata { + metadata.validate()?; + } match definition { ProcessorDefinition::Program(program) => { validate_program(program)?; Ok(None) } ProcessorDefinition::Composition(composition) => Ok(Some( - self.compile_composition(&composition.composition)? + self.compile_composition_with(&composition.composition, reader)? .resolution, )), } } + /// Every immutable version of one processor, oldest publication first. + /// Each record is envelope- and hash-checked exactly as an exact read. + pub fn versions(&self, processor_id: &str) -> Result> { + validate_processor_id(processor_id)?; + let directory = self.root.join(processor_id).join("versions"); + let mut records = Vec::new(); + for entry in fs::read_dir(&directory).map_err(io_error)? { + let entry = entry.map_err(io_error)?; + let name = entry.file_name().to_string_lossy().into_owned(); + let Some(hex) = name.strip_suffix(".json") else { + continue; + }; + if !is_hex(hex, 64) { + continue; + } + records.push(self.read_version(processor_id, &format!("sha256:{hex}"))?); + } + records.sort_by(|a, b| { + a.created_at_unix_ms + .cmp(&b.created_at_unix_ms) + .then_with(|| a.version.cmp(&b.version)) + }); + Ok(records) + } + + /// Admit one foreign version record while preserving its identity. The + /// envelope, content hash, lineage and definition are verified exactly as + /// an exact read verifies them; composition children must already exist + /// here. A processor new to this registry gets `current.json` pointing at + /// this version; an existing processor keeps its current pointer. + pub fn import_version(&self, record: ProcessorVersion) -> Result { + verify_envelope( + &record, + &record.processor_id.clone(), + &record.version.clone(), + )?; + let composition = self.validate_definition(&record.definition)?; + if record.composition != composition { + return Err(composition_mismatch(&record)); + } + let _lock = UpdateLock::acquire(&self.root)?; + self.import_locked(&record) + } + + fn import_locked(&self, record: &ProcessorVersion) -> Result { + let directory = self.root.join(&record.processor_id); + let path = self.version_path(&record.processor_id, &record.version); + match fs::symlink_metadata(&path) { + Ok(_) => { + return if self.read_version(&record.processor_id, &record.version)? == *record { + Ok(ImportStatus::Present) + } else { + Err(format!("Processor {} version {} already exists with different content; imports never overwrite an immutable version", record.processor_id, record.version)) + }; + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => (), + Err(error) => return Err(io_error(error)), + } + let new_processor = match fs::symlink_metadata(directory.join("current.json")) { + Ok(_) => false, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => true, + Err(error) => return Err(io_error(error)), + }; + if new_processor { + create_private_dir(&directory)?; + check_private_dir(&directory)?; + create_private_dir(&directory.join("versions"))?; + check_private_dir(&directory.join("versions"))?; + } + atomic_json(&path, record, false)?; + if new_processor { + atomic_json( + &directory.join("current.json"), + &Current { + format_version: FORMAT_VERSION, + processor_id: record.processor_id.clone(), + version: record.version.clone(), + lineage: record.lineage.clone(), + }, + true, + )?; + } + Ok(ImportStatus::Imported) + } + + /// Import every version of every processor of a foreign registry directory + /// (or one processor and its dependency closure) with identities preserved. + /// The source is read with plain JSON reads and never written or locked. + /// All records are validated before anything is written; any validation + /// error leaves this registry untouched. `dry_run` reports the plan only. + pub fn import_registry( + &self, + source: &Path, + processor_id: Option<&str>, + dry_run: bool, + ) -> Result { + if !source.is_absolute() || !source.is_dir() { + return Err("source_registry must be an absolute path to an existing directory".into()); + } + if source == self.root { + return Err("source_registry must differ from this registry".into()); + } + let mut plan = ImportPlan { + source: source.to_path_buf(), + processors: BTreeMap::new(), + order: Vec::new(), + report: ImportReport::default(), + }; + let roots: Vec = match processor_id { + Some(id) => { + validate_processor_id(id)?; + vec![id.to_string()] + } + None => { + let mut ids = Vec::new(); + for entry in fs::read_dir(source).map_err(io_error)? { + let entry = entry.map_err(io_error)?; + let name = entry.file_name().to_string_lossy().into_owned(); + if validate_processor_id(&name).is_ok() + && entry.file_type().map_err(io_error)?.is_dir() + && entry.path().join("current.json").is_file() + { + ids.push(name); + } + } + ids.sort(); + if ids.is_empty() { + return Err(format!("No processors found in {}", source.display())); + } + ids + } + }; + for id in roots { + plan.load_processor(self, &id, &mut Vec::new())?; + } + if !plan.report.errors.is_empty() { + return Ok(plan.report); + } + let reader = |reference: &ProcessorReference| -> Result { + match plan + .processors + .get(&reference.processor_id) + .and_then(|source| source.records.get(&reference.version)) + { + Some(record) => Ok(record.clone()), + None => self.read_version(&reference.processor_id, &reference.version), + } + }; + let mut statuses = Vec::new(); + for (processor_id, version) in &plan.order { + let record = &plan.processors[processor_id].records[version]; + let status = (|| -> Result { + let composition = self.validate_definition_with(&record.definition, &reader)?; + if record.composition != composition { + return Err(composition_mismatch(record)); + } + match fs::symlink_metadata(self.version_path(processor_id, version)) { + Ok(_) if self.read_version(processor_id, version)? == *record => { + Ok(ImportStatus::Present) + } + Ok(_) => Err(format!("Processor {processor_id} version {version} already exists with different content; imports never overwrite an immutable version")), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + Ok(ImportStatus::Imported) + } + Err(error) => Err(io_error(error)), + } + })(); + match status { + Ok(status) => statuses.push(status), + Err(error) => plan.report.errors.push(ImportError { + processor_id: processor_id.clone(), + version: version.clone(), + error, + }), + } + } + if !plan.report.errors.is_empty() { + return Ok(plan.report); + } + let new_processors: Vec = plan + .processors + .keys() + .filter(|id| !self.root.join(id).join("current.json").exists()) + .cloned() + .collect(); + if dry_run { + for ((processor_id, version), status) in plan.order.iter().zip(statuses) { + let source = &plan.processors[processor_id]; + plan.report.imported.push(ImportOutcome { + record: source.records[version].clone(), + status, + current: source.current.version == *version, + }); + } + return Ok(plan.report); + } + let lock = UpdateLock::acquire(&self.root)?; + for (processor_id, version) in &plan.order { + let source = &plan.processors[processor_id]; + let record = &source.records[version]; + match self.import_locked(record) { + Ok(status) => plan.report.imported.push(ImportOutcome { + record: record.clone(), + status, + current: source.current.version == *version, + }), + Err(error) => { + plan.report.errors.push(ImportError { + processor_id: processor_id.clone(), + version: version.clone(), + error: format!( + "{error}; earlier records in this import were written and remain valid" + ), + }); + return Ok(plan.report); + } + } + } + for processor_id in new_processors { + let current = &plan.processors[&processor_id].current; + atomic_json( + &self.root.join(&processor_id).join("current.json"), + &Current { + format_version: FORMAT_VERSION, + processor_id: processor_id.clone(), + version: current.version.clone(), + lineage: current.lineage.clone(), + }, + true, + )?; + } + drop(lock); + Ok(plan.report) + } + /// Read identity, integrity, and publication metadata without dependency /// traversal. Callers decide whether semantic validation may recurse. fn read_version(&self, processor_id: &str, selected: &str) -> Result { @@ -638,18 +906,7 @@ impl ProcessorRegistry { validate_version(selected)?; let record: ProcessorVersion = read_json(&self.version_path(processor_id, selected)) .map_err(|error| format!("Cannot read processor {processor_id} version {selected}: {error}. Use processor_list/search to discover identities and processor_get to inspect an available version"))?; - if record.format_version != FORMAT_VERSION - || record.processor_id != processor_id - || record.version != selected - || record.validation != DefinitionValidation::checked() - { - return Err("Invalid processor version envelope; inspect the requested identity/version and record metadata and reconcile before continuing".into()); - } - validate_lineage(&record.lineage)?; - let hash = definition_hash(&record.definition)?; - if record.content_sha256 != hash || record.version != format!("sha256:{hash}") { - return Err("Processor definition content hash mismatch; inspect the exact version file and reconcile its authored definition before continuing".into()); - } + verify_envelope(&record, processor_id, selected)?; Ok(record) } @@ -743,6 +1000,198 @@ impl ProcessorRegistry { } } +/// Identity, metadata and content-hash checks shared by exact reads and imports. +fn verify_envelope(record: &ProcessorVersion, processor_id: &str, selected: &str) -> Result<()> { + validate_processor_id(processor_id)?; + validate_version(selected)?; + if record.format_version != FORMAT_VERSION + || record.processor_id != processor_id + || record.version != selected + || record.validation != DefinitionValidation::checked() + { + return Err("Invalid processor version envelope; inspect the requested identity/version and record metadata and reconcile before continuing".into()); + } + validate_lineage(&record.lineage)?; + let hash = definition_hash(&record.definition)?; + if record.content_sha256 != hash || record.version != format!("sha256:{hash}") { + return Err("Processor definition content hash mismatch; inspect the exact version file and reconcile its authored definition before continuing".into()); + } + Ok(()) +} +fn composition_mismatch(record: &ProcessorVersion) -> String { + format!("Processor composition resolution mismatch for {} version {}; inspect the exact dependency versions and generated-source metadata and reconcile before installation", record.processor_id, record.version) +} +pub fn kind_of(definition: &ProcessorDefinition) -> ProcessorKind { + match definition { + ProcessorDefinition::Program(_) => ProcessorKind::Program, + ProcessorDefinition::Composition(_) => ProcessorKind::Composition, + } +} + +#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum ImportStatus { + Imported, + Present, +} +#[derive(Clone, Debug)] +pub struct ImportOutcome { + pub record: ProcessorVersion, + pub status: ImportStatus, + /// Whether the source registry's current pointer selected this version. + pub current: bool, +} +#[derive(Clone, Debug, Serialize, PartialEq, Eq)] +pub struct ImportError { + pub processor_id: String, + pub version: String, + pub error: String, +} +/// Either `imported` (all records admitted or already present) or `errors` +/// (nothing written unless an error message says otherwise). +#[derive(Clone, Debug, Default)] +pub struct ImportReport { + pub imported: Vec, + pub errors: Vec, +} +struct SourceProcessor { + current: Current, + records: BTreeMap, +} +struct ImportPlan { + source: PathBuf, + processors: BTreeMap, + /// Dependency-closure order of (processor_id, version). + order: Vec<(String, String)>, + report: ImportReport, +} +impl ImportPlan { + /// Plain JSON reads of `//current.json` and `versions/*.json`; + /// no lock, no permission check, no write. Envelope and hash failures are + /// collected per record so the caller sees every problem at once. + fn load_processor( + &mut self, + destination: &ProcessorRegistry, + processor_id: &str, + stack: &mut Vec, + ) -> Result<()> { + if self.processors.contains_key(processor_id) { + return Ok(()); + } + if stack.iter().any(|id| id == processor_id) { + return Err(format!( + "Cyclic processor dependency through {processor_id} in {}", + self.source.display() + )); + } + let directory = self.source.join(processor_id); + let read = |path: &Path| -> Result { + serde_json::from_slice( + &fs::read(path) + .map_err(|error| format!("Cannot read {}: {error}", path.display()))?, + ) + .map_err(|error| format!("Invalid JSON in {}: {error}", path.display())) + }; + let current: Current = serde_json::from_value(read(&directory.join("current.json"))?) + .map_err(|error| format!("Invalid current.json for {processor_id}: {error}"))?; + if current.format_version != FORMAT_VERSION || current.processor_id != processor_id { + return Err(format!( + "Invalid processor current-version envelope for {processor_id} in {}", + self.source.display() + )); + } + validate_version(¤t.version)?; + validate_lineage(¤t.lineage)?; + let mut records = BTreeMap::new(); + let versions = directory.join("versions"); + let mut names: Vec = fs::read_dir(&versions) + .map_err(|error| format!("Cannot read {}: {error}", versions.display()))? + .filter_map(|entry| entry.ok()) + .map(|entry| entry.file_name().to_string_lossy().into_owned()) + .filter(|name| { + name.strip_suffix(".json") + .is_some_and(|hex| is_hex(hex, 64)) + }) + .collect(); + names.sort(); + for name in names { + let version = format!("sha256:{}", &name[..64]); + let record: std::result::Result = read(&versions.join(&name)) + .and_then(|value| { + serde_json::from_value(value) + .map_err(|error| format!("Invalid version record: {error}")) + }) + .and_then(|record: ProcessorVersion| { + verify_envelope(&record, processor_id, &version)?; + Ok(record) + }); + match record { + Ok(record) => { + records.insert(version, record); + } + Err(error) => self.report.errors.push(ImportError { + processor_id: processor_id.to_string(), + version, + error, + }), + } + } + if records.is_empty() && self.report.errors.is_empty() { + return Err(format!( + "Processor {processor_id} has no version files in {}", + self.source.display() + )); + } + if !records.contains_key(¤t.version) && self.report.errors.is_empty() { + return Err(format!( + "Processor {processor_id} current version {} has no version file in {}", + current.version, + self.source.display() + )); + } + stack.push(processor_id.to_string()); + let mut dependencies: Vec = Vec::new(); + for record in records.values() { + if let Some(resolution) = &record.composition { + dependencies.extend(resolution.dependencies.values().cloned()); + } + } + for dependency in &dependencies { + if dependency.processor_id == processor_id { + continue; + } + if self + .source + .join(&dependency.processor_id) + .join("current.json") + .is_file() + { + self.load_processor(destination, &dependency.processor_id, stack)?; + } else if destination + .read_version(&dependency.processor_id, &dependency.version) + .is_err() + { + return Err(format!( + "Dependency {} version {} exists neither in {} nor in this registry", + dependency.processor_id, + dependency.version, + self.source.display() + )); + } + } + stack.pop(); + // Dependencies were pushed first; the processor's own versions follow. + for version in records.keys() { + self.order.push((processor_id.to_string(), version.clone())); + } + self.processors.insert( + processor_id.to_string(), + SourceProcessor { current, records }, + ); + Ok(()) + } +} + fn validate_program(definition: &ProgramDefinition) -> Result<()> { super::composition::validate_interface(definition)?; if let Some(operation) = &definition.operation { diff --git a/src/telemetry.rs b/src/telemetry.rs new file mode 100644 index 0000000..b221ce1 --- /dev/null +++ b/src/telemetry.rs @@ -0,0 +1,698 @@ +//! Incremental bounded reader for the opt-in native hook. Never synthesizes nodes. +//! +//! `Reader` is owned by whoever reads the capture file (a tailer thread or a +//! one-shot pass); `State` is the shared, ingested view that `status` snapshots. +use crate::inspection::{InspectionMetadata, NativeChannel, NativeGraph, NativeOperator}; +use serde_json::{json, Value}; +use std::{ + collections::BTreeMap, + io::{Read, Seek, SeekFrom}, + path::PathBuf, + sync::{ + atomic::{AtomicBool, Ordering}, + Arc, Mutex, + }, + time::{SystemTime, UNIX_EPOCH}, +}; +/// Tailer poll interval and the most bytes one poll ingests. +pub(crate) const POLL_INTERVAL_MS: u64 = 100; +pub(crate) const TICK_BUDGET: u64 = 4 * 1024 * 1024; +/// Unchanged polls after which a tailer without a live instance exits. +pub(crate) const IDLE_POLLS: u32 = 50; +const LINE_LIMIT: usize = 1024 * 1024; +const DRAIN_TICKS: u32 = 64; + +struct NodeActivity { + schedule_count: u64, + busy_ns: u64, + last_seen_ns: u64, + active: bool, + arrangement_events: u64, + last_arrangement_event: Option, + open_start_ns: Option, +} +impl Default for NodeActivity { + fn default() -> Self { + Self { + schedule_count: 0, + busy_ns: 0, + last_seen_ns: 0, + active: true, + arrangement_events: 0, + last_arrangement_event: None, + open_start_ns: None, + } + } +} +impl NodeActivity { + fn json(&self) -> Value { + json!({"schedule_count":self.schedule_count,"busy_ns":self.busy_ns,"last_seen_ns":self.last_seen_ns, + "active":self.active,"arrangement_events":self.arrangement_events,"last_arrangement_event":self.last_arrangement_event}) + } +} +/// Everything ingested so far. Errors are sticky: a malformed or conflicting +/// record stops ingestion and is reported, never repaired. +#[derive(Default)] +pub(crate) struct State { + nodes: BTreeMap, + channels: BTreeMap<(u64, u64), Value>, + node_activity: BTreeMap, + channel_activity: BTreeMap, + events: u64, + timely: u64, + progress: u64, + differential: u64, + last_event_ns: Option, + truncated: bool, + truncated_at_bytes: Option, + rotations: u64, + expect_rotation_record: bool, + error: Option, + lag_bytes: u64, + pending_bytes: u64, + last_ingest_unix_ms: u128, + file_seen: bool, + schedule_seen: bool, +} +impl State { + /// Whether at least one `Schedule` event was ingested (the capture moment). + pub fn schedule_seen(&self) -> bool { + self.schedule_seen + } + fn ingest(&mut self, event: Value, at: u64) -> Result<(), String> { + self.events += 1; + if let Some(time) = event["time_ns"].as_u64() { + self.last_event_ns = Some(self.last_event_ns.map_or(time, |t| t.max(time))); + } + match event["stream"].as_str().unwrap_or("") { + "capture_status" => { + if event["status"] == "rotated" { + if self.expect_rotation_record { + self.expect_rotation_record = false; + } else { + self.rotations += 1; + } + } else { + self.truncated = true; + self.truncated_at_bytes.get_or_insert(at); + } + return Ok(()); + } + "progress" => { + self.progress += 1; + return Ok(()); + } + "differential" => { + self.differential += 1; + let worker = event["worker"].as_u64().ok_or("Missing native worker")?; + let operator = event["event"]["operator"] + .as_u64() + .ok_or("Missing arrangement operator")?; + let activity = self + .node_activity + .entry(format!("{worker}:{operator}")) + .or_default(); + activity.arrangement_events += 1; + activity.last_arrangement_event = Some(json!({"kind":event["event"]["kind"], + "time_ns":event["time_ns"],"length":event["event"]["length"]})); + } + "timely" => { + self.timely += 1; + let worker = event["worker"].as_u64().ok_or("Missing native worker")?; + let time = event["time_ns"].as_u64().unwrap_or(0); + if let Some(node) = event["event"].get("Operates") { + let operator_id = node["id"].as_u64().ok_or("Missing operator id")?; + let id = format!("{worker}:{operator_id}"); + let observed = NativeOperator { + id, + operator_id, + worker, + address: serde_json::from_value(node["addr"].clone()) + .map_err(|e| e.to_string())?, + name: node["name"].as_str().ok_or("Missing native name")?.into(), + debug: event["debug"].as_str().unwrap_or("").into(), + }; + if let Some(previous) = self.nodes.get(&observed.id) { + if previous != &observed { + return Err("Conflicting native operator identity".into()); + } + } + self.node_activity.entry(observed.id.clone()).or_default(); + self.nodes.insert(observed.id.clone(), observed); + } + if let Some(channel) = event["event"].get("Channels") { + let channel_id = channel["id"].as_u64().ok_or("Missing channel id")?; + let key = (worker, channel_id); + let _: Vec = serde_json::from_value(channel["scope_addr"].clone()) + .map_err(|e| e.to_string())?; + for endpoint in ["source", "target"] { + let pair: Vec = serde_json::from_value(channel[endpoint].clone()) + .map_err(|e| e.to_string())?; + if pair.len() != 2 { + return Err("Invalid native port pair".into()); + } + } + if let Some(previous) = self.channels.get(&key) { + if previous != channel { + return Err("Conflicting native channel identity".into()); + } + } + self.channel_activity + .entry(format!("{worker}:{channel_id}")) + .or_default(); + self.channels.insert(key, channel.clone()); + } + if let Some(schedule) = event["event"].get("Schedule") { + let id = schedule["id"].as_u64().ok_or("Missing schedule operator")?; + let activity = self + .node_activity + .entry(format!("{worker}:{id}")) + .or_default(); + activity.last_seen_ns = activity.last_seen_ns.max(time); + self.schedule_seen = true; + match schedule["start_stop"].as_str() { + Some("Start") => activity.open_start_ns = Some(time), + Some("Stop") => { + if let Some(start) = activity.open_start_ns.take() { + activity.busy_ns += time.saturating_sub(start); + activity.schedule_count += 1; + } + } + _ => return Err("Invalid schedule event".into()), + } + } + if let Some(message) = event["event"].get("Messages") { + if message["is_send"] == json!(true) { + let channel = message["channel"] + .as_u64() + .ok_or("Missing message channel")?; + let length = message["length"].as_u64().unwrap_or(0); + let activity = self + .channel_activity + .entry(format!("{worker}:{channel}")) + .or_default(); + activity.0 += 1; + activity.1 += length; + } + } + if let Some(shutdown) = event["event"].get("Shutdown") { + let id = shutdown["id"].as_u64().ok_or("Missing shutdown operator")?; + self.node_activity + .entry(format!("{worker}:{id}")) + .or_default() + .active = false; + } + } + _ => (), + } + if self.nodes.len() > 100_000 + || self.channels.len() > 200_000 + || self.node_activity.len() > 100_000 + || self.channel_activity.len() > 200_000 + { + return Err("Native topology exceeds inspection capacity".into()); + } + Ok(()) + } + /// Graph and edges from ingested topology; channels whose endpoints are not + /// (yet) known are counted, never emitted. + fn graph(&self) -> (NativeGraph, u64) { + let addresses: BTreeMap<_, _> = self + .nodes + .values() + .map(|n| ((n.worker, n.address.clone()), n.id.clone())) + .collect(); + let mut edges = vec![]; + let mut unresolved = 0; + for (&(worker, channel_id), channel) in &self.channels { + let parsed = (|| -> Result { + let scope: Vec = serde_json::from_value(channel["scope_addr"].clone()) + .map_err(|e| e.to_string())?; + let source: Vec = + serde_json::from_value(channel["source"].clone()).map_err(|e| e.to_string())?; + let target: Vec = + serde_json::from_value(channel["target"].clone()).map_err(|e| e.to_string())?; + if source.len() != 2 || target.len() != 2 { + return Err("Invalid native port pair".into()); + } + let address = |local: u64| { + let mut a = scope.clone(); + if local != 0 { + a.push(local); + } + a + }; + let source_address = address(source[0]); + let target_address = address(target[0]); + Ok(NativeChannel { + id: format!("{worker}:{channel_id}"), + channel_id, + worker, + scope, + source: addresses + .get(&(worker, source_address.clone())) + .ok_or("unresolved")? + .clone(), + target: addresses + .get(&(worker, target_address.clone())) + .ok_or("unresolved")? + .clone(), + source_port: source[1], + target_port: target[1], + source_address, + target_address, + }) + })(); + match parsed { + Ok(edge) => edges.push(edge), + Err(_) => unresolved += 1, + } + } + ( + NativeGraph { + nodes: self.nodes.values().cloned().collect(), + edges, + }, + unresolved, + ) + } + /// `available` means topology-complete: no error, not truncated, operators + /// observed. Lag and rotations are reported in `activity`, never as a state. + pub fn snapshot(&self, metadata: Option<&InspectionMetadata>) -> Value { + if !self.file_seen { + return json!({"schema_version":1,"state":"missing","reason":"No native capture available; build driver may not support inspection","metadata":metadata}); + } + let (graph, unresolved) = self.graph(); + let mut error = self.error.clone(); + if error.is_none() { + error = graph.validate().err(); + } + let (metadata, mapping_error) = match metadata { + None => (Value::Null, Value::Null), + Some(m) => match m.resolve(&graph) { + Ok(resolved) => (json!(resolved), Value::Null), + Err(e) => (json!(m), json!(e)), + }, + }; + let state = if error.is_some() { + "failed" + } else if self.truncated { + "truncated" + } else if graph.nodes.is_empty() { + "pending" + } else { + "available" + }; + let nodes: BTreeMap<&str, Value> = self + .nodes + .keys() + .map(|id| { + ( + id.as_str(), + self.node_activity + .get(id) + .map(NodeActivity::json) + .unwrap_or_else(|| NodeActivity::default().json()), + ) + }) + .chain( + self.node_activity + .iter() + .filter(|(id, _)| !self.nodes.contains_key(*id)) + .map(|(id, a)| (id.as_str(), a.json())), + ) + .collect(); + let channels: BTreeMap<&str, Value> = self + .channel_activity + .iter() + .map(|(id, (messages, records))| { + ( + id.as_str(), + json!({"message_count":messages,"records":records}), + ) + }) + .collect(); + let activity = json!({ + "nodes":nodes,"channels":channels, + "totals":{"events":self.events,"timely":self.timely,"progress":self.progress,"differential":self.differential, + "operators":self.nodes.len(),"channels":self.channels.len(),"unresolved_channels":unresolved}, + "last_event_ns":self.last_event_ns,"progress_events":self.progress, + "complete":error.is_none() && !self.truncated && self.lag_bytes == 0 && self.pending_bytes == 0, + "truncated_at_bytes":self.truncated_at_bytes,"rotations":self.rotations, + "lag_bytes":self.lag_bytes,"last_ingest_unix_ms":self.last_ingest_unix_ms}); + json!({"schema_version":1,"state":state,"error":error,"graph":graph,"metadata":metadata,"mapping_error":mapping_error,"unresolved_channels":unresolved,"activity":activity}) + } +} +/// File position of one capture. Reading is bounded per tick; a shrunken file +/// is a rotation: the reader restarts at zero and keeps everything ingested. +pub(crate) struct Reader { + path: PathBuf, + offset: u64, + pending: Vec, +} +impl Reader { + pub fn new(path: PathBuf) -> Self { + Self { + path, + offset: 0, + pending: vec![], + } + } + /// Ingest up to `TICK_BUDGET` bytes. Returns whether anything changed. + pub fn tick(&mut self, state: &Mutex) -> bool { + let Ok(mut file) = std::fs::File::open(&self.path) else { + return false; + }; + let length = file.metadata().map(|m| m.len()).unwrap_or(0); + if state.lock().map_or(true, |s| s.error.is_some()) { + return false; + } + let shrunk = length < self.offset; + if shrunk { + self.offset = 0; + self.pending.clear(); + } + let mut chunk = vec![]; + let read = match file + .seek(SeekFrom::Start(self.offset)) + .and_then(|_| file.take(TICK_BUDGET).read_to_end(&mut chunk)) + { + Ok(n) => n, + Err(e) => { + if let Ok(mut s) = state.lock() { + s.error = Some(e.to_string()); + s.file_seen = true; + } + return true; + } + }; + let chunk_start = self.offset.saturating_sub(self.pending.len() as u64); + self.offset += read as u64; + self.pending.extend_from_slice(&chunk); + let completed = self + .pending + .iter() + .rposition(|b| *b == b'\n') + .map(|i| i + 1) + .unwrap_or(0); + let rows: Vec = self.pending.drain(..completed).collect(); + let mut events = Vec::new(); + let mut position = 0usize; + for line in rows.split(|b| *b == b'\n') { + let at = chunk_start + position as u64; + position += line.len() + 1; + if line.is_empty() { + continue; + } + events.push(( + serde_json::from_slice::(line).map_err(|e| e.to_string()), + at, + )); + } + let Ok(mut s) = state.lock() else { + return false; + }; + s.file_seen = true; + if shrunk { + s.rotations += 1; + s.expect_rotation_record = true; + } + for (event, at) in events { + if let Err(e) = event.and_then(|event| s.ingest(event, at)) { + s.error = Some(e); + break; + } + } + if s.error.is_none() && self.pending.len() > LINE_LIMIT { + s.error = Some("Native event exceeds size limit".into()); + } + s.lag_bytes = length.saturating_sub(self.offset); + s.pending_bytes = self.pending.len() as u64; + if read > 0 || shrunk { + s.last_ingest_unix_ms = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis(); + } + read > 0 || shrunk + } + /// Tick until nothing changes, bounded so a live writer cannot pin the caller. + pub fn drain(&mut self, state: &Mutex) { + for _ in 0..DRAIN_TICKS { + if !self.tick(state) { + return; + } + } + } +} +/// One tailer per running generation. It exits on `stop` (after a final +/// tick) or after `IDLE_POLLS` unchanged polls once `live` is cleared. +pub(crate) fn spawn_tailer( + mut reader: Reader, + state: Arc>, + stop: Arc, + live: Arc, +) -> std::thread::JoinHandle<()> { + std::thread::spawn(move || { + let mut idle = 0u32; + loop { + if stop.load(Ordering::SeqCst) { + reader.tick(&state); + return; + } + if reader.tick(&state) { + idle = 0; + } else { + idle += 1; + if idle >= IDLE_POLLS && !live.load(Ordering::SeqCst) { + return; + } + } + std::thread::sleep(std::time::Duration::from_millis(POLL_INTERVAL_MS)); + } + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + struct Capture(PathBuf); + impl Capture { + fn new(contents: &[u8]) -> Self { + use std::sync::atomic::{AtomicUsize, Ordering}; + static SEQUENCE: AtomicUsize = AtomicUsize::new(0); + let path = std::env::temp_dir().join(format!( + "ddlog-telemetry-{}-{}", + std::process::id(), + SEQUENCE.fetch_add(1, Ordering::Relaxed) + )); + std::fs::write(&path, contents).unwrap(); + Self(path) + } + fn reader(&self) -> (Reader, Mutex) { + (Reader::new(self.0.clone()), Mutex::new(State::default())) + } + fn snapshot(&self) -> Value { + let (mut reader, state) = self.reader(); + snapshot(&mut reader, &state) + } + } + impl Drop for Capture { + fn drop(&mut self) { + let _ = std::fs::remove_file(&self.0); + } + } + fn snapshot(reader: &mut Reader, state: &Mutex) -> Value { + reader.tick(state); + let value = state.lock().unwrap().snapshot(None); + value + } + fn operates(worker: u64, id: u64, addr: Vec) -> Value { + json!({"stream":"timely","worker":worker,"event":{"Operates":{"id":id,"addr":addr,"name":"Input"}},"debug":"authored source"}) + } + fn channel(worker: u64) -> Value { + json!({"stream":"timely","worker":worker,"event":{"Channels":{"id":9,"scope_addr":[0],"source":[1,3],"target":[2,4]}}}) + } + fn records(events: Vec) -> Vec { + events + .iter() + .map(|v| format!("{v}\n")) + .collect::() + .into_bytes() + } + #[test] + fn actual_event_shape_preserves_workers_addresses_and_ports() { + let capture = Capture::new(&records( + (0..2) + .flat_map(|w| { + vec![ + operates(w, 1, vec![0, 1]), + operates(w, 2, vec![0, 2]), + channel(w), + ] + }) + .collect(), + )); + let (mut reader, state) = capture.reader(); + let value = snapshot(&mut reader, &state); + assert_eq!(value["state"], "available"); + let graph: NativeGraph = serde_json::from_value(value["graph"].clone()).unwrap(); + assert_eq!(graph.nodes.len(), 4); + assert_eq!(graph.edges.len(), 2); + for (worker, edge) in graph.edges.iter().enumerate() { + assert_eq!(edge.id, format!("{worker}:9")); + assert_eq!(edge.channel_id, 9); + assert_eq!(edge.source, format!("{worker}:1")); + assert_eq!(edge.target, format!("{worker}:2")); + assert_eq!(edge.source_address, vec![0, 1]); + assert_eq!(edge.target_address, vec![0, 2]); + assert_eq!((edge.source_port, edge.target_port), (3, 4)); + } + assert_eq!(snapshot(&mut reader, &state)["graph"], value["graph"]); + assert_eq!(value["activity"]["totals"]["operators"], 4); + assert_eq!(value["activity"]["totals"]["channels"], 2); + assert_eq!(value["activity"]["complete"], true); + assert_eq!(value["activity"]["lag_bytes"], 0); + assert_eq!( + value["activity"]["nodes"]["0:1"], + json!({"schedule_count":0,"busy_ns":0,"last_seen_ns":0,"active":true,"arrangement_events":0,"last_arrangement_event":null}) + ); + assert_eq!( + value["activity"]["channels"]["1:9"], + json!({"message_count":0,"records":0}) + ); + } + #[test] + fn malformed_channel_is_failed_not_unresolved() { + let mut event = channel(0); + event["event"]["Channels"]["source"] = json!([1]); + let capture = Capture::new(&records(vec![event])); + assert_eq!(capture.snapshot()["state"], "failed"); + let capture = Capture::new(b"not json\n"); + assert_eq!(capture.snapshot()["state"], "failed"); + } + #[test] + fn incomplete_tail_is_pending_activity_not_a_state() { + let capture = Capture::new(b""); + let (mut reader, state) = capture.reader(); + assert_eq!(snapshot(&mut reader, &state)["state"], "pending"); + let line = format!("{}", operates(0, 1, vec![0, 1])); + std::fs::write(&capture.0, line.as_bytes()).unwrap(); + let value = snapshot(&mut reader, &state); + assert_eq!(value["state"], "pending"); + assert_eq!(value["activity"]["complete"], false); + std::fs::OpenOptions::new() + .append(true) + .open(&capture.0) + .unwrap() + .write_all(b"\n") + .unwrap(); + let value = snapshot(&mut reader, &state); + assert_eq!(value["state"], "available"); + assert_eq!(value["activity"]["complete"], true); + let missing = Capture::new(b""); + std::fs::remove_file(&missing.0).unwrap(); + assert_eq!(missing.snapshot()["state"], "missing"); + } + #[test] + fn truncation_and_bad_mapping_are_explicit() { + let capture = Capture::new(&records(vec![ + operates(0, 1, vec![0, 1]), + json!({"stream":"capture_status","status":"truncated","reason":"worker_byte_limit"}), + ])); + let metadata = InspectionMetadata { + schema_version: 999, + ..InspectionMetadata::default() + }; + let (mut reader, state) = capture.reader(); + reader.tick(&state); + let result = state.lock().unwrap().snapshot(Some(&metadata)); + assert_eq!(result["state"], "truncated"); + assert_eq!(result["activity"]["complete"], false); + assert!(result["activity"]["truncated_at_bytes"].as_u64().unwrap() > 0); + assert!(result["mapping_error"] + .as_str() + .unwrap() + .contains("unsupported")); + } + #[test] + fn duplicate_identity_conflicts_fail_and_empty_channel_scope_is_valid() { + let capture = Capture::new(&records(vec![ + operates(0, 1, vec![0, 1]), + operates(0, 1, vec![0, 2]), + ])); + assert_eq!(capture.snapshot()["state"], "failed"); + let mut edge = channel(0); + edge["event"]["Channels"]["scope_addr"] = json!([]); + let capture = Capture::new(&records(vec![ + operates(0, 1, vec![1]), + operates(0, 2, vec![2]), + edge, + ])); + let value = capture.snapshot(); + assert_eq!(value["state"], "available"); + assert_eq!(value["unresolved_channels"], 0); + } + #[test] + fn activity_events_accumulate_and_rotation_keeps_topology() { + let capture = Capture::new(&records(vec![ + operates(0, 1, vec![0, 1]), + operates(0, 2, vec![0, 2]), + channel(0), + json!({"stream":"timely","worker":0,"time_ns":100,"event":{"Schedule":{"id":2,"start_stop":"Start"}}}), + json!({"stream":"timely","worker":0,"time_ns":250,"event":{"Schedule":{"id":2,"start_stop":"Stop"}}}), + json!({"stream":"timely","worker":0,"time_ns":300,"event":{"Messages":{"is_send":true,"channel":9,"source":0,"target":0,"seq_no":1,"length":7}}}), + json!({"stream":"timely","worker":0,"time_ns":301,"event":{"Messages":{"is_send":false,"channel":9,"source":0,"target":0,"seq_no":1,"length":7}}}), + json!({"stream":"progress","worker":0,"time_ns":310,"event":{}}), + json!({"stream":"differential","worker":0,"time_ns":320,"event":{"kind":"Batch","operator":2,"length":3}}), + json!({"stream":"timely","worker":0,"time_ns":400,"event":{"Shutdown":{"id":1}}}), + ])); + let (mut reader, state) = capture.reader(); + let value = snapshot(&mut reader, &state); + assert_eq!(value["state"], "available"); + assert!(state.lock().unwrap().schedule_seen()); + let activity = &value["activity"]; + assert_eq!( + activity["totals"], + json!({"events":10,"timely":8,"progress":1,"differential":1,"operators":2,"channels":1,"unresolved_channels":0}) + ); + assert_eq!(activity["last_event_ns"], 400); + assert_eq!(activity["progress_events"], 1); + assert_eq!( + activity["nodes"]["0:2"], + json!({"schedule_count":1,"busy_ns":150,"last_seen_ns":250,"active":true,"arrangement_events":1,"last_arrangement_event":{"kind":"Batch","time_ns":320,"length":3}}) + ); + assert_eq!(activity["nodes"]["0:1"]["active"], false); + assert_eq!( + activity["channels"]["0:9"], + json!({"message_count":1,"records":7}) + ); + assert_eq!(activity["rotations"], 0); + // The hook truncates the file and records the rotation as its first line. + std::fs::write( + &capture.0, + records(vec![ + json!({"stream":"capture_status","status":"rotated","bytes":4096}), + json!({"stream":"timely","worker":0,"time_ns":500,"event":{"Schedule":{"id":2,"start_stop":"Start"}}}), + json!({"stream":"timely","worker":0,"time_ns":600,"event":{"Schedule":{"id":2,"start_stop":"Stop"}}}), + ]), + ) + .unwrap(); + let value = snapshot(&mut reader, &state); + assert_eq!(value["state"], "available"); + assert_eq!(value["graph"]["nodes"].as_array().unwrap().len(), 2); + assert_eq!(value["activity"]["rotations"], 1); + assert_eq!(value["activity"]["nodes"]["0:2"]["schedule_count"], 2); + assert_eq!(value["activity"]["nodes"]["0:2"]["busy_ns"], 250); + assert_eq!(value["activity"]["complete"], true); + // A rotation record read from offset zero counts once, not twice. + let fresh = Capture::new(&records(vec![ + json!({"stream":"capture_status","status":"rotated","bytes":1}), + operates(0, 1, vec![0, 1]), + ])); + let value = fresh.snapshot(); + assert_eq!(value["activity"]["rotations"], 1); + assert_eq!(value["state"], "available"); + } +} diff --git a/src/worlds.rs b/src/worlds.rs new file mode 100644 index 0000000..51d10dc --- /dev/null +++ b/src/worlds.rs @@ -0,0 +1,1476 @@ +//! Runtime-owned inventory of explicitly created worlds. No host-process discovery. +//! +//! The manager owns existing `ProgramInstance`s; attaching a client must reuse this +//! manager, never instantiate a second manager as a discovery mechanism. +use crate::instance::{public_relations, PublicRelation}; +use crate::registry::{ + kind_of, GitProvenance, ImportStatus, ProcessorDefinition, ProcessorReference, + ProcessorRegistry, ProcessorVersion, +}; +use crate::telemetry::{spawn_tailer, Reader, State}; +use crate::{Backend, ObserverOptions, ProgramInstance}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use std::collections::{BTreeMap, BTreeSet}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::{SystemTime, UNIX_EPOCH}; + +pub const SCHEMA_VERSION: u32 = 1; +/// Implicit library holding every registered definition without an explicit one. +pub const UNASSIGNED_LIBRARY: &str = "unassigned"; +const BUILD_LOG_TAIL_LINES: usize = 60; +/// Owner lock acquisition retries a released-but-not-yet-visible lock for +/// about 100 ms before reporting an existing owner. +const OWNER_LOCK_ATTEMPTS: u32 = 10; +const OWNER_LOCK_RETRY_MS: u64 = 10; +/// Saved source-library registration. This does not claim executable definitions. +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct LibraryDefinition { + pub name: String, + pub repository: String, + pub revision: String, +} +/// One input mutation of a scenario, addressed by public relation name. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct ScenarioChange { + pub op: String, + pub predicate: String, + pub values: Vec, +} +/// Cumulative test step: apply `changes`, then every relation in `expect` must +/// equal the listed rows as a set. Validated against the definition's public +/// relations when stored and again when a test world is created. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct Scenario { + pub name: String, + #[serde(default)] + pub description: String, + pub changes: Vec, + pub expect: BTreeMap>>, +} +fn default_purpose() -> String { + "instance".into() +} +fn is_instance(purpose: &str) -> bool { + purpose == "instance" +} +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct WorldDefinition { + pub label: String, + pub processor: ProcessorReference, + /// `instance` (default) or `test`; test worlds run their scenarios after install. + #[serde(default = "default_purpose", skip_serializing_if = "is_instance")] + pub purpose: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub scenarios: Vec, +} +/// Message from a start thread: a live instance, a completed test run whose +/// instance was dropped, or the install/test failure. +type StartOutcome = Result, String>; +/// The capture tailer of one generation. `live` tells it whether an instance +/// (or a pending start) still exists; without one it exits after idle polls. +/// Dropping a tailer stops and joins its thread, so no thread outlives its +/// world (a test world removed after a failed start, a manager going away). +struct Tailer { + stop: Arc, + live: Arc, + handle: Option>, +} +impl Tailer { + fn stop(&mut self) { + self.stop.store(true, Ordering::SeqCst); + if let Some(handle) = self.handle.take() { + let _ = handle.join(); + } + } +} +impl Drop for Tailer { + fn drop(&mut self) { + self.stop(); + } +} +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct World { + schema_version: u32, + definition: WorldDefinition, + #[serde(skip)] + instance: Option, + state: String, + error: Option, + generation: u64, + metadata: Option, + /// Ingested capture of the current generation; shared with its tailer. + #[serde(skip)] + telemetry: Option>>, + #[serde(skip)] + tailer: Option, + /// Recovered worlds read their last capture once, on the first full status. + #[serde(skip)] + reader: Option, + /// Generation whose compiled topology has been retained (A8), once each. + #[serde(default, skip_serializing_if = "Option::is_none")] + capture_generation: Option, + history: Vec, + #[serde(skip)] + pending: Option>, + #[serde(skip)] + stop_requested: bool, + /// Last observed scenario run of a test world; copied from the start thread. + #[serde(default, skip_serializing_if = "Option::is_none")] + test: Option, + #[serde(skip)] + test_progress: Option>>, +} +/// Filter and shape of an `inventory` reply. +#[derive(Clone, Debug, Default, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct InventoryQuery { + #[serde(default)] + pub processor_id: Option, + #[serde(default)] + pub version: Option, + /// Omit `inspection`, `instance` and `managed_processes` and never call the + /// live instance; this is what a sidebar polls. + #[serde(default)] + pub summary: bool, +} +/// `import` verb arguments. +#[derive(Clone, Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ImportRequest { + pub source_registry: PathBuf, + #[serde(default)] + pub processor_id: Option, + #[serde(default)] + pub library_id: Option, + #[serde(default)] + pub names: BTreeMap, + #[serde(default)] + pub dry_run: bool, +} +/// `register` verb arguments: every registered definition carries a name. +#[derive(Clone, Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RegisterRequest { + pub name: String, + #[serde(default)] + pub description: String, + #[serde(default)] + pub library_id: Option, + pub definition: Value, + #[serde(default)] + pub git_provenance: Option, +} +/// `test` verb arguments. +#[derive(Clone, Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct TestRequest { + pub processor_id: String, + pub version: String, + #[serde(default)] + pub scenarios: Option>, + #[serde(default)] + pub keep_world: bool, +} +/// One embedding owner. Dropping it stops all its worlds. Registry definitions +/// survive, but live inventory does not automatically recover across owner exit. +pub struct WorldManager { + registry_root: PathBuf, + build_root: PathBuf, + driver: PathBuf, + worlds: BTreeMap, + shutdown: WorldShutdown, + _owner_lock: std::fs::File, +} +impl WorldManager { + pub fn new( + registry_root: PathBuf, + build_root: PathBuf, + driver: PathBuf, + ) -> Result { + if !build_root.is_absolute() || !driver.is_absolute() || !registry_root.is_absolute() { + return Err( + "Registry, build root and driver must be absolute operator-configured paths".into(), + ); + } + ProcessorRegistry::open(registry_root.clone())?; + // Reuse the registry's same-user private-directory checks. + ProcessorRegistry::open(build_root.clone())?; + let owner_lock = lock_owner(&build_root)?; + let mut worlds = BTreeMap::new(); + for entry in std::fs::read_dir(&build_root).map_err(|e| e.to_string())? { + let entry = entry.map_err(|e| e.to_string())?; + let id = entry.file_name().to_string_lossy().into_owned(); + if !id.bytes().all(|b| b.is_ascii_digit() || b == b'-') { + continue; + } + if !entry.file_type().map_err(|e| e.to_string())?.is_dir() { + return Err("Invalid world directory".into()); + } + let path = entry.path().join("world.json"); + if !path.exists() { + continue; + } + let mut world: World = + serde_json::from_slice(&std::fs::read(&path).map_err(|e| e.to_string())?) + .map_err(|e| e.to_string())?; + if world.schema_version != SCHEMA_VERSION { + return Err("Unsupported world record schema".into()); + } + if !matches!( + world.state.as_str(), + "created" + | "starting" + | "running" + | "stopping" + | "stopped" + | "failed" + | "interrupted" + ) { + return Err("Invalid persisted world state".into()); + } + if matches!(world.state.as_str(), "starting" | "running" | "stopping") { + world.state = "interrupted".into(); + world.error = + Some("Previous owner exited; no process was adopted or restarted".into()); + persist(&build_root, &id, &mut world)?; + } + if world.generation > 0 { + world.telemetry = Some(Arc::new(Mutex::new(State::default()))); + world.reader = Some(Reader::new( + entry + .path() + .join(world.generation.to_string()) + .join("native-events.jsonl"), + )); + } + worlds.insert(id, world); + } + Ok(Self { + registry_root, + build_root, + driver, + worlds, + _owner_lock: owner_lock, + shutdown: WorldShutdown::default(), + }) + } + pub fn shutdown_handle(&self) -> WorldShutdown { + self.shutdown.clone() + } + /// The persisted catalog with every entry normalized to carry `processors`. + fn stored_catalog(&self) -> Result { + let path = self.build_root.join("libraries.json"); + let mut catalog = if path.exists() { + let value: Value = + serde_json::from_slice(&std::fs::read(path).map_err(|e| e.to_string())?) + .map_err(|e| e.to_string())?; + if value["schema_version"] != 1 || !value["libraries"].is_array() { + return Err("Invalid library catalog".into()); + } + value + } else { + json!({"schema_version":1,"libraries":[]}) + }; + for entry in catalog["libraries"].as_array_mut().unwrap() { + if !entry["processors"].is_array() { + entry["processors"] = json!([]); + } + } + Ok(catalog) + } + /// Full library entries including their registered definitions, plus the + /// implicit `unassigned` library (name "Unassigned"), always listed last. + pub fn libraries(&self) -> Result { + let mut catalog = self.stored_catalog()?; + let libraries = catalog["libraries"].as_array_mut().unwrap(); + let unassigned = libraries + .iter() + .position(|entry| entry["id"] == UNASSIGNED_LIBRARY) + .map(|index| libraries.remove(index)) + .unwrap_or_else(|| json!({"id":UNASSIGNED_LIBRARY,"name":"Unassigned","repository":null,"revision":null,"registered_at_unix_ms":null,"definitions":"registered_separately","provenance":"implicit","processors":[]})); + libraries.push(unassigned); + Ok(catalog) + } + /// Move one (processor, version) into `library_id`, naming it. A pin belongs + /// to exactly one library; the implicit library is created on first use. + pub fn associate( + &self, + processor_id: &str, + version: &str, + library_id: &str, + name: &str, + description: &str, + ) -> Result { + validate_name_description(name, description)?; + let mut catalog = self.libraries()?; + let libraries = catalog["libraries"].as_array_mut().unwrap(); + if !libraries.iter().any(|entry| entry["id"] == library_id) { + return Err(format!( + "Unknown library {library_id}; call library_create first" + )); + } + for entry in libraries.iter_mut() { + let processors = entry["processors"].as_array_mut().unwrap(); + processors.retain(|p| !(p["processor_id"] == processor_id && p["version"] == version)); + } + let association = json!({"processor_id":processor_id,"version":version,"name":name,"description":description}); + libraries + .iter_mut() + .find(|entry| entry["id"] == library_id) + .unwrap()["processors"] + .as_array_mut() + .unwrap() + .push(association.clone()); + atomic_json(&self.build_root.join("libraries.json"), &catalog)?; + Ok(association) + } + /// Library id, name and description of one pin; derived when unassociated. + fn association(&self, record: &ProcessorVersion) -> Result<(String, String, String), String> { + let catalog = self.libraries()?; + for entry in catalog["libraries"].as_array().unwrap() { + for p in entry["processors"].as_array().unwrap() { + if p["processor_id"] == record.processor_id.as_str() + && p["version"] == record.version.as_str() + { + return Ok(( + entry["id"].as_str().unwrap_or(UNASSIGNED_LIBRARY).into(), + p["name"].as_str().unwrap_or("").into(), + p["description"].as_str().unwrap_or("").into(), + )); + } + } + } + Ok(( + UNASSIGNED_LIBRARY.into(), + derived_name(record)?, + String::new(), + )) + } + /// Register a named definition into a library (default: `unassigned`). + pub fn register(&self, request: RegisterRequest) -> Result { + let definition = if request.definition.get("composition").is_some() { + serde_json::from_value(request.definition) + .map(ProcessorDefinition::Composition) + .map_err(|e| e.to_string())? + } else { + serde_json::from_value(request.definition) + .map(ProcessorDefinition::Program) + .map_err(|e| e.to_string())? + }; + let library_id = request + .library_id + .unwrap_or_else(|| UNASSIGNED_LIBRARY.into()); + validate_name_description(&request.name, &request.description)?; + if !self.libraries()?["libraries"] + .as_array() + .unwrap() + .iter() + .any(|entry| entry["id"] == library_id.as_str()) + { + return Err(format!( + "Unknown library {library_id}; call library_create first" + )); + } + let record = self + .registry()? + .create(definition, request.git_provenance)?; + let association = self.associate( + &record.processor_id, + &record.version, + &library_id, + &request.name, + &request.description, + )?; + let mut result = serde_json::to_value(&record).map_err(|e| e.to_string())?; + result["name"] = association["name"].clone(); + result["description"] = association["description"].clone(); + result["library_id"] = json!(library_id); + Ok(result) + } + /// Every version of every processor with its control-plane name and library. + pub fn definitions(&self) -> Result { + let registry = self.registry()?; + let mut cursor: Option = None; + let mut processors = Vec::new(); + loop { + let page = registry.list(100, cursor.as_deref(), true)?; + for summary in &page.processors { + for record in registry.versions(&summary.processor_id)? { + let (library_id, name, description) = self.association(&record)?; + processors.push(json!({ + "processor_id":record.processor_id,"version":record.version, + "current":record.version == summary.version, + "kind":kind_of(&record.definition),"status":summary.status, + "name":name,"description":description,"library_id":library_id, + "created_at_unix_ms":record.created_at_unix_ms, + })); + } + } + cursor = page.next_cursor; + if cursor.is_none() { + break; + } + } + Ok(json!({"processors":processors})) + } + /// Import a foreign registry (or one processor with its closure) with + /// preserved identities, then name and file the admitted definitions. + pub fn import(&self, request: ImportRequest) -> Result { + if let Some(library_id) = &request.library_id { + if !self.libraries()?["libraries"] + .as_array() + .unwrap() + .iter() + .any(|entry| entry["id"] == library_id.as_str()) + { + return Err(format!( + "Unknown library {library_id}; call library_create first" + )); + } + } + let report = self.registry()?.import_registry( + &request.source_registry, + request.processor_id.as_deref(), + request.dry_run, + )?; + let mut imported = Vec::new(); + for outcome in &report.imported { + let record = &outcome.record; + let (existing_library, existing_name, existing_description) = + self.association(record)?; + let associated = existing_library != UNASSIGNED_LIBRARY + || !existing_name.is_empty() && outcome.status == ImportStatus::Present; + let name = request + .names + .get(&record.processor_id) + .cloned() + .unwrap_or(existing_name); + let library_id = request.library_id.clone().unwrap_or(existing_library); + if !request.dry_run + && report.errors.is_empty() + && (!associated + || request.library_id.is_some() + || request.names.contains_key(&record.processor_id)) + { + self.associate( + &record.processor_id, + &record.version, + &library_id, + &name, + &existing_description, + )?; + } + imported.push(json!({"processor_id":record.processor_id,"version":record.version,"kind":kind_of(&record.definition),"status":outcome.status,"current":outcome.current,"name":name,"library_id":library_id})); + } + Ok(json!({"imported":imported,"errors":report.errors,"dry_run":request.dry_run})) + } + fn scenarios_path(&self, processor_id: &str, version: &str) -> PathBuf { + self.build_root + .join("scenarios") + .join(processor_id) + .join(format!("{}.json", &version[7..])) + } + /// Validate and store the scenarios of one exact definition version. + pub fn scenarios_set( + &self, + processor_id: &str, + version: &str, + scenarios: Vec, + ) -> Result { + let record = self.registry()?.get(processor_id, Some(version))?; + validate_scenarios(&public_relations(&record)?, &scenarios)?; + let directory = self.build_root.join("scenarios").join(processor_id); + std::fs::create_dir_all(&directory).map_err(|e| e.to_string())?; + let document = json!({"schema_version":1,"processor":{"processor_id":processor_id,"version":version},"scenarios":scenarios,"updated_at_unix_ms":timestamp()}); + atomic_json(&self.scenarios_path(processor_id, version), &document)?; + Ok(document) + } + pub fn scenarios_get(&self, processor_id: &str, version: &str) -> Result { + self.registry()?.get(processor_id, Some(version))?; + let path = self.scenarios_path(processor_id, version); + if !path.exists() { + return Ok( + json!({"schema_version":1,"processor":{"processor_id":processor_id,"version":version},"scenarios":[],"updated_at_unix_ms":null}), + ); + } + serde_json::from_slice(&std::fs::read(path).map_err(|e| e.to_string())?) + .map_err(|e| e.to_string()) + } + pub fn create_library(&self, definition: LibraryDefinition) -> Result { + for value in [ + &definition.name, + &definition.repository, + &definition.revision, + ] { + if value.trim().is_empty() || value.len() > 4096 { + return Err("Library fields must be nonempty and bounded".into()); + } + } + use sha2::{Digest, Sha256}; + let id = format!( + "{:x}", + Sha256::digest(serde_json::to_vec(&definition).map_err(|e| e.to_string())?) + ); + let mut catalog = self.stored_catalog()?; + let libraries = catalog["libraries"].as_array_mut().unwrap(); + if let Some(existing) = libraries.iter().find(|entry| entry["id"] == id) { + return Ok(existing.clone()); + } + let entry = json!({"id":id,"name":definition.name,"repository":definition.repository,"revision":definition.revision,"registered_at_unix_ms":timestamp(),"definitions":"registered_separately","provenance":"operator_supplied","processors":[]}); + libraries.push(entry.clone()); + atomic_json(&self.build_root.join("libraries.json"), &catalog)?; + Ok(entry) + } + /// Existing immutable registry admission. Registration never launches a world. + pub fn registry(&self) -> Result { + ProcessorRegistry::open(self.registry_root.clone()) + } + pub fn create(&mut self, definition: WorldDefinition) -> Result { + if definition.label.trim().is_empty() || definition.label.len() > 256 { + return Err("World label must contain 1–256 bytes".into()); + } + if !matches!(definition.purpose.as_str(), "instance" | "test") { + return Err("World purpose must be instance or test".into()); + } + if definition.purpose == "instance" && !definition.scenarios.is_empty() { + return Err("Scenarios belong to test worlds; create with purpose test".into()); + } + let registry = self.registry()?; + registry.ensure_active(&definition.processor.processor_id)?; + let record = registry.get( + &definition.processor.processor_id, + Some(&definition.processor.version), + )?; + if definition.purpose == "test" { + if definition.scenarios.is_empty() { + return Err("A test world requires at least one scenario".into()); + } + validate_scenarios(&public_relations(&record)?, &definition.scenarios)?; + } + // Exclusive build-directory creation gives every manager/generation its own + // identity and prevents two owners from sharing native build artifacts. + let id = crate::bounded::owner_identity(); + std::fs::create_dir(self.build_root.join(&id)).map_err(|e| e.to_string())?; + self.worlds.insert( + id.clone(), + World { + schema_version: SCHEMA_VERSION, + definition, + instance: None, + state: "created".into(), + error: None, + generation: 0, + metadata: match record.definition { + crate::registry::ProcessorDefinition::Program(p) => p.inspection, + crate::registry::ProcessorDefinition::Composition(c) => c.inspection, + }, + telemetry: None, + tailer: None, + reader: None, + capture_generation: None, + history: vec![], + pending: None, + stop_requested: false, + test: None, + test_progress: None, + }, + ); + if let Err(error) = persist(&self.build_root, &id, self.worlds.get_mut(&id).unwrap()) { + // The reply and the inventory must agree: a world whose record was + // never written is not inventory, and its directory has no world.json. + self.worlds.remove(&id); + if let Err(cleanup) = std::fs::remove_dir_all(self.build_root.join(&id)) { + return Err(format!( + "{error}; world directory {id} could not be removed: {cleanup}" + )); + } + return Err(error); + } + Ok(id) + } + /// Create an ephemeral test world for stored scenarios and start it. The + /// reply is the immediate `starting` status; progress is `status.test`. + pub fn test(&mut self, request: TestRequest) -> Result { + let record = self + .registry()? + .get(&request.processor_id, Some(&request.version))?; + let stored = self.scenarios_get(&request.processor_id, &request.version)?; + let stored: Vec = + serde_json::from_value(stored["scenarios"].clone()).map_err(|e| e.to_string())?; + if stored.is_empty() { + return Err( + "No scenarios are stored for this definition version; call scenarios_set first" + .into(), + ); + } + let scenarios = match &request.scenarios { + None => stored, + Some(names) => { + let mut selected = Vec::new(); + for name in names { + selected.push( + stored + .iter() + .find(|scenario| scenario.name == *name) + .cloned() + .ok_or_else(|| format!("Unknown scenario {name}"))?, + ); + } + selected + } + }; + let (_, name, _) = self.association(&record)?; + self.ensure_starting_allowed()?; + let id = self.create(WorldDefinition { + label: format!("Test · {name}"), + processor: ProcessorReference { + processor_id: request.processor_id.clone(), + version: request.version.clone(), + }, + purpose: "test".into(), + scenarios, + })?; + match self.start_with(&id, request.keep_world) { + Ok(status) => Ok(status), + Err(error) => { + // A test world that never started is not inventory: the reply + // and the world store must agree. A world whose start thread is + // already running is kept and reports through status. + if self + .worlds + .get(&id) + .is_some_and(|world| world.pending.is_none()) + { + self.worlds.remove(&id); + if let Err(cleanup) = std::fs::remove_dir_all(self.build_root.join(&id)) { + return Err(format!( + "{error}; test world {id} could not be removed: {cleanup}" + )); + } + } + Err(error) + } + } + } + fn ensure_starting_allowed(&self) -> Result<(), String> { + if self.shutdown.stopped.load(Ordering::SeqCst) { + return Err( + "World manager is shutting down; create a new owner before starting".into(), + ); + } + Ok(()) + } + /// Synchronous library convenience. Control-plane clients use start_async. + pub fn start(&mut self, id: &str) -> Result { + self.start_async(id)?; + loop { + let status = self.status(id)?; + match status["state"].as_str() { + Some("starting") => std::thread::sleep(std::time::Duration::from_millis(100)), + Some("running") => return Ok(status), + _ => { + return Err(status["error"] + .as_str() + .unwrap_or("World did not start") + .into()) + } + } + } + } + /// Publish starting state before compiling; inventory and stop remain available. + /// A restarted test world keeps its instance after re-running its scenarios. + pub fn start_async(&mut self, id: &str) -> Result { + self.start_with(id, true) + } + fn start_with(&mut self, id: &str, keep_world: bool) -> Result { + let registry = self.registry()?; + let controls = self.shutdown.controls.clone(); + let mut controls = controls.lock().map_err(|_| "Shutdown lock poisoned")?; + self.ensure_starting_allowed()?; + let world = self.worlds.get_mut(id).ok_or("Unknown world")?; + if world.instance.is_some() || world.pending.is_some() { + return Err("World already owns an instance; stop it before restarting".into()); + } + if let Some(mut tailer) = world.tailer.take() { + tailer.stop(); + } + world.generation += 1; + world.stop_requested = false; + let mut backend = Backend::new( + self.build_root.join(id).join(world.generation.to_string()), + self.driver.clone(), + ); + #[cfg(unix)] + { + backend.control = crate::processes::ProcessControl::hosted(); + } + let log = self + .build_root + .join(id) + .join(world.generation.to_string()) + .join("native-events.jsonl"); + backend.inspection_log = Some(log.clone()); + backend.set_observer(ObserverOptions { + detail_full: true, + rotate: true, + }); + let state = Arc::new(Mutex::new(State::default())); + let (stop, live) = ( + Arc::new(AtomicBool::new(false)), + Arc::new(AtomicBool::new(true)), + ); + world.telemetry = Some(state.clone()); + world.reader = None; + world.tailer = Some(Tailer { + stop: stop.clone(), + live: live.clone(), + handle: Some(spawn_tailer(Reader::new(log), state, stop, live)), + }); + controls.insert(id.into(), backend.control.clone()); + let mut instance = + ProgramInstance::new(backend, BTreeMap::new(), Some(registry), Some(id.into())); + world.state = "starting".into(); + world.error = None; + let scenarios = if world.definition.purpose == "test" { + world.definition.scenarios.clone() + } else { + Vec::new() + }; + let progress = if scenarios.is_empty() { + world.test = None; + world.test_progress = None; + None + } else { + let progress = Arc::new(Mutex::new( + json!({"phase":"building","scenario_index":0,"results":[],"passed":null,"error":null}), + )); + world.test = Some(progress.lock().unwrap().clone()); + world.test_progress = Some(progress.clone()); + Some(progress) + }; + if let Err(error) = persist(&self.build_root, id, world) { + // No start thread exists yet: stop the tailer spawned above and + // forget the process control so neither outlives this attempt. + if let Some(mut tailer) = world.tailer.take() { + tailer.stop(); + } + controls.remove(id); + world.state = "failed".into(); + world.error = Some(format!("Cannot persist starting state: {error}")); + return Err(error); + } + let pin = world.definition.processor.clone(); + let (sender, receiver) = std::sync::mpsc::channel(); + world.pending = Some(receiver); + std::thread::spawn(move || { + let installed = instance.execute( + "processor_install", + &json!({"processor_id":pin.processor_id,"version":pin.version}), + ); + let result: StartOutcome = match (installed, progress) { + (Err(error), Some(progress)) => { + let mut status = progress.lock().unwrap(); + status["phase"] = json!("failed"); + status["passed"] = json!(false); + status["error"] = json!(error); + Err(error) + } + (Err(error), None) => Err(error), + (Ok(_), None) => Ok(Some(instance)), + (Ok(_), Some(progress)) => { + run_scenarios(&mut instance, &scenarios, &progress); + if keep_world { + Ok(Some(instance)) + } else { + drop(instance); + Ok(None) + } + } + }; + let _ = sender.send(result); + }); + drop(controls); + self.status(id) + } + pub fn stop(&mut self, id: &str) -> Result { + let world = self.worlds.get_mut(id).ok_or("Unknown world")?; + if let Some(instance) = world.instance.take() { + #[cfg(unix)] + instance.backend.control.stop(); + drop(instance); // Existing Backend/Runtime drop kills and reaps the native child. + } + if let Some(mut tailer) = world.tailer.take() { + tailer.stop(); + } + world.stop_requested = true; + if let Some(control) = self + .shutdown + .controls + .lock() + .map_err(|_| "Shutdown lock poisoned")? + .get(id) + { + #[cfg(unix)] + control.stop(); + } + world.state = if world.pending.is_some() { + "stopping" + } else { + "stopped" + } + .into(); + persist(&self.build_root, id, world)?; + self.status(id) + } + pub fn execute(&mut self, id: &str, operation: &str, args: &Value) -> Result { + self.status(id)?; + if operation.starts_with("processor_") + || matches!( + operation, + "lemmalog_install_rules" | "install_agent_program" + ) + { + return Err( + "Manage definitions through the registry; a world is pinned at creation".into(), + ); + } + self.worlds + .get_mut(id) + .ok_or("Unknown world")? + .instance + .as_mut() + .ok_or("World is not running")? + .execute(operation, args) + } + pub fn status(&mut self, id: &str) -> Result { + self.status_with(id, false) + } + fn status_with(&mut self, id: &str, summary: bool) -> Result { + let world = self.worlds.get_mut(id).ok_or("Unknown world")?; + let completion = world.pending.as_ref().map(|receiver| receiver.try_recv()); + match completion { + Some(Ok(result)) => { + world.pending = None; + if world.stop_requested { + drop(result); + world.state = "stopped".into(); + } else { + match result { + Ok(Some(instance)) => { + world.instance = Some(instance); + world.state = "running".into(); + world.error = None; + } + Ok(None) => { + world.state = "stopped".into(); + world.error = None; + } + Err(error) => { + world.state = "failed".into(); + world.error = Some(error); + } + } + } + if world.instance.is_none() { + if let Some(tailer) = &world.tailer { + tailer.live.store(false, Ordering::SeqCst); + } + } + } + Some(Err(std::sync::mpsc::TryRecvError::Disconnected)) => { + world.pending = None; + world.state = "failed".into(); + world.error = Some("World start worker exited without a result".into()); + if let Some(tailer) = &world.tailer { + tailer.live.store(false, Ordering::SeqCst); + } + } + _ => (), + } + if let Some(progress) = &world.test_progress { + let observed = progress + .lock() + .map_err(|_| "Test progress lock poisoned")? + .clone(); + if world.test.as_ref() != Some(&observed) { + world.test = Some(observed); + persist(&self.build_root, id, world)?; + } + } + let mut pid = None; + let mut revision = Value::Null; + let mut instance_info = Value::Null; + let mut health_failed = false; + if let Some(instance) = &mut world.instance { + if instance.backend.observed_health() == "failed" { + world.state = "failed".into(); + world.error.get_or_insert_with(|| { + "Native execution process exited unexpectedly; start begins a new generation" + .into() + }); + health_failed = true; + } else { + pid = instance.backend.runtime_pid(); + // The committed revision is an in-memory counter: no native exchange, + // so summary inventories can carry it for every live world. + revision = json!(instance.backend.revision()); + if !summary { + instance_info = instance.execute("instance_info", &json!({}))?; + } + } + } + if health_failed { + // The child is gone: dropping the instance reaps it and its process + // group, so `start` (valid for `failed`) opens a fresh generation. + if let Some(instance) = world.instance.take() { + #[cfg(unix)] + instance.backend.control.stop(); + drop(instance); + } + if let Some(mut tailer) = world.tailer.take() { + tailer.stop(); + } + } + persist_if_changed(&self.build_root, id, world)?; + let generation_dir = self.build_root.join(id).join(world.generation.to_string()); + let build = if world.state == "starting" + || (world.state == "failed" && world.instance.is_none() && world.generation > 0) + { + let log = generation_dir.join("build-1").join("build.log"); + json!({"generation":world.generation,"log_tail":tail_lines(&log, BUILD_LOG_TAIL_LINES),"instrumented":instrumented(&generation_dir)}) + } else { + Value::Null + }; + let started_at = world + .history + .iter() + .rev() + .find(|entry| { + entry["state"] == "starting" && entry["generation"] == json!(world.generation) + }) + .map(|entry| entry["at_unix_ms"].clone()) + .unwrap_or(Value::Null); + let mut status = json!({"schema_version":SCHEMA_VERSION,"id":id,"definition":world.definition, + "state":world.state,"generation":world.generation,"error":world.error,"history":world.history, + "owner":{"kind":"embedded","pid":std::process::id(),"memory_attribution":"shared_not_attributable"}, + "resources":sample_process(pid),"started_at_unix_ms":started_at,"build":build,"revision":revision, + "persistence":{"status":"not_configured","reason":"world checkpoints are not wired"}, + "test":world.test}); + if !summary { + let processes = self.shutdown.controls.lock().map_err(|_|"Shutdown lock poisoned")?.get(id).map(|control|control.tracked_pids()).unwrap_or_default().into_iter().map(|process|json!({"role":if Some(process)==pid {"native"} else {"compiler_or_startup"},"sample":sample_process(Some(process)),"descendants_included":false})).collect::>(); + status["managed_processes"] = json!(processes); + status["instance"] = instance_info; + // A recovered world reads its last capture once; nothing tails it. + if let (Some(reader), Some(state)) = (world.reader.as_mut(), &world.telemetry) { + reader.drain(state); + } + world.reader = None; + let (inspection, schedule_seen) = match &world.telemetry { + Some(state) => { + let state = state.lock().map_err(|_| "Capture state lock poisoned")?; + ( + state.snapshot(world.metadata.as_ref()), + state.schedule_seen(), + ) + } + None => (json!({"state":"missing","metadata":world.metadata}), false), + }; + if world.capture_generation != Some(world.generation) + && inspection["state"] == "available" + && inspection["unresolved_channels"] == 0 + && schedule_seen + { + retain_capture(&self.build_root, id, world, &inspection)?; + world.capture_generation = Some(world.generation); + persist(&self.build_root, id, world)?; + } + status["inspection"] = inspection; + } + Ok(status) + } + /// The retained compiled topology of one definition version, or `{state: missing}`. + pub fn capture_get(&self, processor_id: &str, version: &str) -> Result { + self.registry()?.get(processor_id, Some(version))?; + let path = capture_path(&self.build_root, processor_id, version); + if !path.exists() { + return Ok(json!({"state":"missing"})); + } + serde_json::from_slice(&std::fs::read(path).map_err(|e| e.to_string())?) + .map_err(|e| e.to_string()) + } + pub fn inventory(&mut self, query: &InventoryQuery) -> Result { + let ids: Vec<_> = self + .worlds + .iter() + .filter(|(_, world)| { + query + .processor_id + .as_ref() + .is_none_or(|p| *p == world.definition.processor.processor_id) + && query + .version + .as_ref() + .is_none_or(|v| *v == world.definition.processor.version) + }) + .map(|(id, _)| id.clone()) + .collect(); + let worlds: Result, _> = ids + .iter() + .map(|id| self.status_with(id, query.summary)) + .collect(); + Ok( + json!({"schema_version":SCHEMA_VERSION,"scope":"owned_by_this_manager_with_history","summary":query.summary,"worlds":worlds?}), + ) + } +} +/// Default definition name from its public interface: `outputs ← inputs`, or +/// `Composition of ` for compositions. +pub fn derived_name(record: &ProcessorVersion) -> Result { + if let ProcessorDefinition::Composition(definition) = &record.definition { + let aliases: Vec<&str> = definition + .composition + .nodes + .keys() + .map(String::as_str) + .collect(); + return Ok(format!("Composition of {}", aliases.join(", "))); + } + let relations = public_relations(record)?; + let names = |input: bool| -> String { + relations + .iter() + .filter(|relation| relation.input == input) + .map(|relation| relation.name.as_str()) + .collect::>() + .join(", ") + }; + let (inputs, outputs) = (names(true), names(false)); + Ok(match (inputs.is_empty(), outputs.is_empty()) { + (false, false) => format!("{outputs} ← {inputs}"), + (true, false) => outputs, + (false, true) => inputs, + (true, true) => record.processor_id.clone(), + }) +} +fn typed(value: &Value, field: &str) -> bool { + match field { + "int" => value.as_i64().is_some(), + "string" => value.is_string(), + _ => false, + } +} +fn row_matches(row: &[Value], fields: &[String], what: &str) -> Result<(), String> { + if row.len() != fields.len() { + return Err(format!( + "{what} has {} values; relation arity is {}", + row.len(), + fields.len() + )); + } + for (index, (value, field)) in row.iter().zip(fields).enumerate() { + if !typed(value, field) { + return Err(format!("{what} value {index} must be {field}")); + } + } + Ok(()) +} +/// Scenario predicates must be public inputs (changes) or public relations +/// (expect); rows must match arity and types; expected rows are sets. +pub fn validate_scenarios( + relations: &[PublicRelation], + scenarios: &[Scenario], +) -> Result<(), String> { + let mut names = BTreeSet::new(); + for scenario in scenarios { + if scenario.name.trim().is_empty() || scenario.name.len() > 256 { + return Err("Scenario name must contain 1–256 bytes".into()); + } + if !names.insert(scenario.name.clone()) { + return Err(format!("Duplicate scenario name {}", scenario.name)); + } + for (index, change) in scenario.changes.iter().enumerate() { + if !matches!(change.op.as_str(), "insert" | "delete") { + return Err(format!( + "Scenario {}: change {index} op must be insert or delete", + scenario.name + )); + } + let relation = relations + .iter() + .find(|relation| relation.name == change.predicate && relation.input) + .ok_or_else(|| { + format!( + "Scenario {}: change {index} predicate {} is not a public input", + scenario.name, change.predicate + ) + })?; + row_matches( + &change.values, + &relation.fields, + &format!("Scenario {}: change {index}", scenario.name), + )?; + } + for (name, rows) in &scenario.expect { + let relation = relations + .iter() + .find(|relation| relation.name == *name) + .ok_or_else(|| { + format!( + "Scenario {}: expected relation {name} is not public", + scenario.name + ) + })?; + let mut seen = BTreeSet::new(); + for (index, row) in rows.iter().enumerate() { + row_matches( + row, + &relation.fields, + &format!("Scenario {}: expected {name} row {index}", scenario.name), + )?; + if !seen.insert(serde_json::to_string(row).map_err(|e| e.to_string())?) { + return Err(format!("Scenario {}: expected {name} row {index} is a duplicate; relations are sets", scenario.name)); + } + } + } + } + Ok(()) +} +/// Read one whole public relation through paged `query_rows`. +fn read_relation( + instance: &mut ProgramInstance, + predicate: &str, +) -> Result<(u64, Vec>), String> { + let mut rows = Vec::new(); + let mut continuation = Value::Null; + let mut revision = 0; + loop { + let page = instance.execute("query_rows", &json!({"predicate":predicate,"max_rows":crate::MAX_QUERY_ROWS,"continuation":continuation}))?; + revision = page["revision"].as_u64().unwrap_or(revision); + rows.extend( + serde_json::from_value::>>(page["rows"].clone()) + .map_err(|e| e.to_string())?, + ); + if page["complete"] == json!(true) { + return Ok((revision, rows)); + } + continuation = page["continuation"].clone(); + } +} +/// Apply every scenario cumulatively on the freshly installed instance and +/// compare each expected relation as a set. Progress is published per scenario. +fn run_scenarios( + instance: &mut ProgramInstance, + scenarios: &[Scenario], + progress: &Arc>, +) { + let publish = |phase: &str, index: usize, results: &[Value], passed: Value| { + if let Ok(mut status) = progress.lock() { + *status = json!({"phase":phase,"scenario_index":index,"results":results,"passed":passed,"error":null}); + } + }; + let mut results: Vec = Vec::new(); + publish("applying", 0, &results, Value::Null); + for (index, scenario) in scenarios.iter().enumerate() { + let mut result = json!({"name":scenario.name,"passed":false,"revision":null,"expected":{},"observed":{},"missing":{},"unexpected":{},"error":null}); + let outcome = (|| -> Result<(), String> { + let applied = + instance.execute("apply_changes", &json!({"changes":scenario.changes}))?; + result["revision"] = applied["revision"].clone(); + let mut passed = true; + for (name, expected) in &scenario.expect { + let (revision, observed) = read_relation(instance, name)?; + result["revision"] = json!(revision); + let key = |row: &Vec| serde_json::to_string(row).unwrap_or_default(); + let expected_keys: BTreeSet = expected.iter().map(key).collect(); + let observed_keys: BTreeSet = observed.iter().map(key).collect(); + let missing: Vec<&Vec> = expected + .iter() + .filter(|row| !observed_keys.contains(&key(row))) + .collect(); + let unexpected: Vec<&Vec> = observed + .iter() + .filter(|row| !expected_keys.contains(&key(row))) + .collect(); + passed &= missing.is_empty() && unexpected.is_empty(); + result["expected"][name] = json!(expected); + result["observed"][name] = json!(observed); + result["missing"][name] = json!(missing); + result["unexpected"][name] = json!(unexpected); + } + result["passed"] = json!(passed); + Ok(()) + })(); + if let Err(error) = outcome { + result["error"] = json!(error); + result["passed"] = json!(false); + } + results.push(result); + publish("applying", index + 1, &results, Value::Null); + if instance.backend.health() == "failed" { + for remaining in &scenarios[index + 1..] { + results.push(json!({"name":remaining.name,"passed":false,"revision":null,"expected":{},"observed":{},"missing":{},"unexpected":{},"error":"Instance runtime failed before this scenario ran"})); + } + break; + } + } + let passed = results.iter().all(|result| result["passed"] == json!(true)); + publish("done", results.len(), &results, json!(passed)); +} +fn capture_path(build_root: &Path, processor_id: &str, version: &str) -> PathBuf { + build_root + .join("captures") + .join(processor_id) + .join(format!("{}.json", &version[7..])) +} +/// Persist the compiled topology of a definition version (A8): the native +/// graph and resolved metadata, without activity. Later generations of any +/// world pinned to the same version replace it. +fn retain_capture( + build_root: &Path, + id: &str, + world: &World, + inspection: &Value, +) -> Result<(), String> { + let pin = &world.definition.processor; + std::fs::create_dir_all(build_root.join("captures").join(&pin.processor_id)) + .map_err(|e| e.to_string())?; + let document = json!({"schema_version":1,"processor":pin,"world_id":id,"generation":world.generation, + "captured_at_unix_ms":timestamp(),"unresolved_channels":inspection["unresolved_channels"], + "graph":inspection["graph"],"metadata":inspection["metadata"],"mapping_error":inspection["mapping_error"]}); + atomic_json( + &capture_path(build_root, &pin.processor_id, &pin.version), + &document, + ) +} +/// Last `count` lines of a build log, or null until the driver creates it. +/// Only the final 64 KiB are read so a verbose compiler cannot stall status. +fn tail_lines(path: &Path, count: usize) -> Value { + use std::io::{Read, Seek, SeekFrom}; + let Ok(mut file) = std::fs::File::open(path) else { + return Value::Null; + }; + let length = file.metadata().map(|m| m.len()).unwrap_or(0); + let window = 64 * 1024; + let start = length.saturating_sub(window); + if file.seek(SeekFrom::Start(start)).is_err() { + return Value::Null; + } + let mut bytes = Vec::new(); + if file.take(window).read_to_end(&mut bytes).is_err() { + return Value::Null; + } + let text = String::from_utf8_lossy(&bytes); + let lines: Vec<&str> = text.lines().collect(); + let skip = lines.len().saturating_sub(count); + json!(lines[skip..].join("\n")) +} +/// The build driver's `install-observer.py` records its completed patch set in +/// `program_ddlog/observer-install.json`; without that marker the native child +/// carries no observer hook or star phase regions. +fn instrumented(generation_dir: &Path) -> bool { + let path = generation_dir + .join("build-1") + .join("program_ddlog") + .join("observer-install.json"); + std::fs::read(path) + .ok() + .and_then(|bytes| serde_json::from_slice::(&bytes).ok()) + .is_some_and(|marker| marker["observer_hook"] == json!(true)) +} +/// Independent cancellation handle used by an embedding owner on shutdown. +/// Cancels managed compiler/native process groups even during a blocking install. +#[derive(Clone, Default)] +pub struct WorldShutdown { + stopped: std::sync::Arc, + controls: std::sync::Arc>>, +} +impl WorldShutdown { + pub fn stop_all(&self) { + if let Ok(controls) = self.controls.lock() { + self.stopped + .store(true, std::sync::atomic::Ordering::SeqCst); + for control in controls.values() { + #[cfg(unix)] + control.stop(); + } + } + } +} +impl Drop for WorldManager { + fn drop(&mut self) { + self.shutdown.stop_all(); + for (id, world) in self.worlds.iter_mut() { + if let Some(instance) = world.instance.take() { + #[cfg(unix)] + instance.backend.control.stop(); + drop(instance); + world.state = "stopped".into(); + let _ = persist(&self.build_root, id, world); + } + if let Some(mut tailer) = world.tailer.take() { + tailer.stop(); + } + } + } +} +fn timestamp() -> u128 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() +} +/// Measurements cover the native child only, excluding compiler descendants and +/// the embedding owner. CPU is ps's lifetime-average percentage, not an interval. +pub fn sample_process(pid: Option) -> Value { + let mut result = json!({"sampled_at_unix_ms":timestamp(),"state":"missing","pid":pid, + "scope":"process","cpu_percent":null,"cpu_basis":if cfg!(target_os="macos") {"ps_decaying_average"} else {"ps_lifetime_average"}, + "resident_bytes":null,"max_age_ms":5000}); + let Some(pid) = pid else { + return result; + }; + #[cfg(unix)] + { + let output = std::process::Command::new("/bin/ps") + .args(["-p", &pid.to_string(), "-o", "%cpu=", "-o", "rss="]) + .env("LC_ALL", "C") + .output(); + if let Ok(output) = output { + if output.status.success() { + let text = String::from_utf8_lossy(&output.stdout); + let mut fields = text.split_whitespace(); + if let (Some(cpu), Some(rss)) = ( + fields.next().and_then(|s| s.parse::().ok()), + fields.next().and_then(|s| s.parse::().ok()), + ) { + if cpu.is_finite() && cpu >= 0.0 { + result["state"] = json!("available"); + result["cpu_percent"] = json!(cpu); + result["resident_bytes"] = json!(rss.saturating_mul(1024)); + } + } + } + } + } + #[cfg(not(unix))] + { + result["state"] = json!("unsupported"); + } + result +} + +/// Checked before any registry or catalog write so a refused `register` leaves +/// nothing behind. +fn validate_name_description(name: &str, description: &str) -> Result<(), String> { + if name.trim().is_empty() || name.len() > 256 || description.len() > 4096 { + return Err("Definition name must contain 1–256 bytes; description at most 4096".into()); + } + Ok(()) +} +fn lock_owner(root: &std::path::Path) -> Result { + let mut options = std::fs::OpenOptions::new(); + options.read(true).write(true).create(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600).custom_flags(libc::O_NOFOLLOW); + } + let file = options + .open(root.join(".owner.lock")) + .map_err(|e| e.to_string())?; + #[cfg(unix)] + { + use std::os::fd::AsRawFd; + // A lock released by a dropped owner can still be held for a moment by a + // child spawned meanwhile (its copied descriptor table keeps the open file + // description until exec closes it), so a fresh owner retries briefly + // before reporting a genuine owner. + let mut attempt = 0; + loop { + if unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } == 0 { + break; + } + let error = std::io::Error::last_os_error(); + match error.raw_os_error() { + Some(libc::EWOULDBLOCK) | Some(libc::EINTR) => (), + _ => return Err(format!("Cannot lock world store: {error}")), + } + attempt += 1; + if attempt >= OWNER_LOCK_ATTEMPTS { + return Err("World store already has an owner; attach to its control plane".into()); + } + std::thread::sleep(std::time::Duration::from_millis(OWNER_LOCK_RETRY_MS)); + } + } + Ok(file) +} +fn persist_if_changed(root: &std::path::Path, id: &str, world: &mut World) -> Result<(), String> { + if world.history.last().is_none_or(|last| { + last["state"] != world.state + || last["generation"] != world.generation + || last["error"] != json!(world.error) + }) { + persist(root, id, world)?; + } + Ok(()) +} +fn persist(root: &std::path::Path, id: &str, world: &mut World) -> Result<(), String> { + let mut history = world.history.clone(); + if history.last().is_none_or(|last| { + last["state"] != world.state + || last["generation"] != world.generation + || last["error"] != json!(world.error) + }) { + history.push(json!({"at_unix_ms":timestamp(),"state":world.state,"generation":world.generation,"error":world.error})); + } + let path = root.join(id).join("world.json"); + let temp = path.with_extension("json.tmp"); + let mut record = serde_json::to_value(&*world).map_err(|e| e.to_string())?; + record["history"] = json!(history); + let bytes = serde_json::to_vec_pretty(&record).map_err(|e| e.to_string())?; + let mut options = std::fs::OpenOptions::new(); + options.write(true).create(true).truncate(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600).custom_flags(libc::O_NOFOLLOW); + } + let mut file = options.open(&temp).map_err(|e| e.to_string())?; + use std::io::Write; + file.write_all(&bytes) + .and_then(|_| file.sync_all()) + .map_err(|e| e.to_string())?; + std::fs::rename(&temp, &path).map_err(|e| e.to_string())?; + std::fs::File::open(root.join(id)) + .and_then(|f| f.sync_all()) + .map_err(|e| e.to_string())?; + world.history = history; + Ok(()) +} + +fn atomic_json(path: &std::path::Path, value: &Value) -> Result<(), String> { + let temp = path.with_extension("json.tmp"); + let mut options = std::fs::OpenOptions::new(); + options.create(true).truncate(true).write(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600).custom_flags(libc::O_NOFOLLOW); + } + let mut file = options.open(&temp).map_err(|e| e.to_string())?; + use std::io::Write; + file.write_all(&serde_json::to_vec_pretty(value).map_err(|e| e.to_string())?) + .and_then(|_| file.sync_all()) + .map_err(|e| e.to_string())?; + std::fs::rename(&temp, path).map_err(|e| e.to_string())?; + std::fs::File::open(path.parent().ok_or("Missing catalog parent")?) + .and_then(|file| file.sync_all()) + .map_err(|e| e.to_string()) +} diff --git a/tests/fixture_hashes.rs b/tests/fixture_hashes.rs new file mode 100644 index 0000000..bf4b771 --- /dev/null +++ b/tests/fixture_hashes.rs @@ -0,0 +1,56 @@ +//! Every upstream fixture byte is pinned. Serialized types that gain fields must +//! do so without changing these files or the records they decode to. +use sha2::{Digest, Sha256}; +use std::path::Path; + +const PINNED: &[(&str, &str)] = &[ + ("initialize.json", "9825c0897f45b7430dc18de6f796a37007737175fc7961fbf32c68843348c1ce"), + ("manifest.json", "7b2101a5a311956c0a36f14f04154e6625b04d41402e2aeb33700e9ea2b97958"), + ("nested-program.dl", "9f31f9f34970ec7bf0d4a87a352d50491c632f00811ee7e45b127eebeecfbe2d"), + ("operations.json", "14e221cf640e6db56795f2fa7927eae2c938029bd78bac0f10b1b3421f0ec49e"), + ("records.json", "50182c20b9c69e5a07cea2d0ec4745045a00589ac3ceb88412e1179eb12ad203"), + ("registry/processor_94a3652e1247018b2ba3249e8945e936/current.json", "1bcaee21af6967ac6882a6db0ed7034e20dbe8a13728e7155949bc755a9fafa3"), + ("registry/processor_94a3652e1247018b2ba3249e8945e936/versions/0a9c8173319f8107f9ad2c37446de12021176145edc6c7fbe2862dfac4a9c657.json", "3b75b619e6048b9fb5a0f13bc3fe4603e02510ff9c379c9d4903e31737beb18c"), + ("registry/processor_a5061511ae805a164da144671ba62ec2/current.json", "73a05252243458c4deba292a13f79f35513e671ee666b32588f630ca55b19955"), + ("registry/processor_a5061511ae805a164da144671ba62ec2/versions/3dbc92487f05ba94199713e681f2ed05ecacae35cc3f62c86fcfb56281d0fad1.json", "ea26bb40ebce18f63a91cb714dd548318fe5866bda79bbc38e8f9717dd16ca83"), + ("registry/processor_d27e6d76c6703fc3d498f7472356ddf4/current.json", "c42084cf1d5f5eb1d44de0f2b658503ec9845d49d8ab2ca227e888d4fa3636f3"), + ("registry/processor_d27e6d76c6703fc3d498f7472356ddf4/versions/c4779c5af7b16400c744cbcf06fdfab52894d04292230389a0899b99d5b2cb52.json", "12df53020e7da6fdbfab416573d161098f44c0548173cbef630efbd48fb435cc"), + ("registry/processor_e6952bf74116c7496625b9011b33742d/current.json", "244f3b8917fe4901981a89afb729f1c36bf2a9badc79feab23ec5664cef2e3b5"), + ("registry/processor_e6952bf74116c7496625b9011b33742d/versions/9837a519f04b5ce0182d6492e6d322ef6ad61d79bc1fea857912d02ce9d87c1c.json", "69f4676bdbf4fe64db3967d37dbc5be931d7f8f8a17a0cab62dfaab0d99ae15b"), + ("registry/processor_f73af36626b286b84d4d7ebf3ec6becf/current.json", "8075c1d95ada7d90745d29831b19915b371dfb489526eecc3c8b8c527f5dab71"), + ("registry/processor_f73af36626b286b84d4d7ebf3ec6becf/versions/75e4f50eae99a217292803f8bb9c41752fd1ecd193c3ebd5b6a0568ca9b4a226.json", "a7b31ad319a3adb927eb231d7610349c865b7b886dc4fa61406ea0814af2c380"), + ("tools.json", "3b0d126d1ad26bcadcb17cb449225d3a82f1470cec4aba4511bb1fc6538aab9d"), +]; + +fn walk(root: &Path, directory: &Path, found: &mut Vec) { + for entry in std::fs::read_dir(directory).unwrap() { + let path = entry.unwrap().path(); + if path.is_dir() { + walk(root, &path, found); + } else { + found.push( + path.strip_prefix(root) + .unwrap() + .to_string_lossy() + .into_owned(), + ); + } + } +} + +#[test] +fn upstream_fixture_hashes_are_unchanged() { + let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/upstream"); + let mut found = Vec::new(); + walk(&root, &root, &mut found); + found.sort(); + let pinned: Vec<&str> = PINNED.iter().map(|(name, _)| *name).collect(); + assert_eq!(found, pinned, "fixture inventory changed"); + for (name, expected) in PINNED { + let actual = format!( + "{:x}", + Sha256::digest(std::fs::read(root.join(name)).unwrap()) + ); + assert_eq!(actual, *expected, "{name} changed"); + } +} diff --git a/tests/processor_composition_registry.rs b/tests/processor_composition_registry.rs index 4e993bd..4a6a554 100644 --- a/tests/processor_composition_registry.rs +++ b/tests/processor_composition_registry.rs @@ -70,6 +70,7 @@ fn manifest(leaf: &ProcessorVersion) -> CompositionManifest { } fn composed(manifest: CompositionManifest) -> ProcessorDefinition { ProcessorDefinition::Composition(CompositionDefinition { + inspection: None, composition: manifest, }) } @@ -649,12 +650,14 @@ fn nested_program_interfaces_preserve_private_boundaries_and_exact_leaf_history( let second = registry.create(composed(wrapper), None).unwrap(); assert_eq!(second.version, outer.version); assert_eq!(second.composition, outer.composition); - assert!(registry - .list(10, None, false) - .unwrap() - .processors - .iter() - .all(|row| serde_json::to_value(row).unwrap()["kind"] == "program")); + for row in registry.list(10, None, false).unwrap().processors { + let expected = if row.processor_id == leaf.processor_id { + "program" + } else { + "composition" + }; + assert_eq!(serde_json::to_value(&row).unwrap()["kind"], expected); + } } #[test] diff --git a/tests/processor_registry.rs b/tests/processor_registry.rs index 9934556..c89153f 100644 --- a/tests/processor_registry.rs +++ b/tests/processor_registry.rs @@ -35,6 +35,7 @@ impl Drop for TestDirectory { } fn definition() -> ProcessorDefinition { ProcessorDefinition::Program(ProgramDefinition { + inspection: None, rules: "visible(X) :- item(X).".into(), schemas: json!({ "item": {"input":true,"fields":["string"]}, @@ -265,6 +266,7 @@ fn registered_validation_supports_public_results_and_rejects_private_protocol() let directory = TestDirectory::new(); let registry = directory.registry(); let valid = ProcessorDefinition::Program(ProgramDefinition { + inspection: None, rules: "reviewed(E,R,O) :- agent_result(E,R,O).".into(), schemas: json!({"reviewed":{"input":false,"fields":["string","int","string"]}}), operation: Some(RegisteredOperationBinding { @@ -993,3 +995,56 @@ fn independent_processes_cannot_both_commit_one_lifecycle_revision() { 2 ); } + +#[test] +fn inspection_metadata_is_optional_immutable_and_validated() { + use ddlog_runtime::inspection::InspectionMetadata; + use sha2::{Digest, Sha256}; + let directory = TestDirectory::new(); + let registry = directory.registry(); + let legacy = definition(); + let old_json = json!({"rules":"visible(X) :- item(X).", "schemas":{ + "item":{"input":true,"fields":["string"]}, + "visible":{"input":false,"fields":["string"]}},"operation":null}); + // Exact pre-metadata serialization and content address stay unchanged. + assert_eq!(serde_json::to_value(&legacy).unwrap(), old_json); + let expected = format!( + "{:x}", + Sha256::digest(serde_json::to_vec(&old_json).unwrap()) + ); + let first = registry.create(legacy.clone(), None).unwrap(); + assert_eq!(first.content_sha256, expected); + let mut annotated = legacy.clone(); + program(&mut annotated).inspection = Some(InspectionMetadata::default()); + let next = registry + .publish(&first.processor_id, annotated.clone(), &first.version, None) + .unwrap(); + assert_ne!(first.version, next.version); + assert_eq!( + registry + .get(&first.processor_id, Some(&first.version)) + .unwrap() + .definition, + legacy + ); + assert_eq!( + registry + .get(&first.processor_id, Some(&next.version)) + .unwrap() + .definition, + annotated + ); + program(&mut annotated) + .inspection + .as_mut() + .unwrap() + .schema_version = 999; + assert!(registry.create(annotated.clone(), None).is_err()); + assert!(registry + .publish(&first.processor_id, annotated, &next.version, None) + .is_err()); + assert_eq!( + registry.get(&first.processor_id, None).unwrap().version, + next.version + ); +} diff --git a/tests/test_native_packaging.py b/tests/test_native_packaging.py index 4528e34..8a9a9b7 100644 --- a/tests/test_native_packaging.py +++ b/tests/test_native_packaging.py @@ -1,6 +1,7 @@ """Packaging contracts; no downloads or native compiler required.""" import hashlib import importlib.util +import json import os from pathlib import Path import subprocess @@ -12,6 +13,12 @@ spec = importlib.util.spec_from_file_location('bootstrap', ROOT / 'scripts/bootstrap-native.py') bootstrap = importlib.util.module_from_spec(spec) spec.loader.exec_module(bootstrap) +spec = importlib.util.spec_from_file_location('install_observer', ROOT / 'scripts/install-observer.py') +install_observer = importlib.util.module_from_spec(spec) +spec.loader.exec_module(install_observer) +# observer/examples/connected_components/lemmalog_star.rs: the reference phase implementation. +REFERENCE_STAR_SHA256 = 'f3ce91b429dc6f8971a5f98e8f5c50b8b60921732c498b985ea07cc563117c0e' +GENERATED_PREAMBLE = '#![allow(dead_code)]\n// generated by ddlog\n' class NativePackaging(unittest.TestCase): @@ -48,8 +55,17 @@ def test_driver_selects_lock_and_preserves_explicit_override(self): cargo = root / 'cargo' cargo.write_text('#!/bin/sh\nprintf "%s\\n" "$@" > args\nmkdir -p target/debug\nprintf binary > target/debug/program_cli\n') cargo.chmod(0o755) + # Simulated pinned generator output needed by the native hook installer. + project = root / 'program_ddlog' + (project / 'differential_datalog/src/program').mkdir(parents=True) + (project / 'src').mkdir() + (project / 'differential_datalog/src/lib.rs').write_text('') + (project / 'differential_datalog/src/program/worker.rs').write_text( + ' self.worker.log_register().remove("differential/arrange");\n };\n Ok(())') + (project / 'src/main.rs').write_text(' differential_idle_merge_effort: args.idle_merge_effort,') (root / 'program.dl').write_text('fixture') - (root / 'override.lock').write_text('explicit override') + # An explicit lock must name the star member when the program imports it. + (root / 'override.lock').write_text('explicit override\n[[package]]\nname = "types__lemmalog_star"\n') env = {k: v for k, v in os.environ.items() if not k.startswith(('DDLOG_', 'CARGO_'))} env.update(DDLOG_HOME=str(root), DDLOG_CARGO=str(cargo), DDLOG_OFFLINE='1', DDLOG_LOCK_DIR=str(ROOT / 'native')) @@ -61,6 +77,67 @@ def test_driver_selects_lock_and_preserves_explicit_override(self): self.assertEqual((root / 'program_ddlog/Cargo.lock').read_bytes(), expected.read_bytes()) self.assertIn('--locked', (root / 'program_ddlog/args').read_text()) self.assertEqual((root / 'output').read_text(), 'binary') + self.assertEqual((project / 'differential_datalog/src/observer.rs').read_bytes(), (ROOT / 'native/observer.rs').read_bytes()) + marker = json.loads((project / 'observer-install.json').read_text()) + self.assertEqual(marker['observer_hook'], True) + self.assertEqual(marker['star_phases'], None) + + def test_driver_refuses_explicit_lock_without_star_member(self): + with tempfile.TemporaryDirectory() as d: + root = Path(d) + (root / 'bin').mkdir() + compiler = root / 'bin/ddlog' + compiler.write_text('#!/bin/sh\nmkdir -p program_ddlog/.cargo program_ddlog/types/lemmalog_star\n') + compiler.chmod(0o755) + cargo = root / 'cargo' + cargo.write_text('#!/bin/sh\nprintf "%s\\n" "$@" > args\n') + cargo.chmod(0o755) + project = root / 'program_ddlog' + (project / 'differential_datalog/src/program').mkdir(parents=True) + (project / 'src').mkdir() + (project / 'differential_datalog/src/lib.rs').write_text('') + (project / 'differential_datalog/src/program/worker.rs').write_text( + ' self.worker.log_register().remove("differential/arrange");\n };\n Ok(())') + (project / 'src/main.rs').write_text(' differential_idle_merge_effort: args.idle_merge_effort,') + (root / 'program.dl').write_text('fixture') + env = {k: v for k, v in os.environ.items() if not k.startswith(('DDLOG_', 'CARGO_'))} + env.update(DDLOG_HOME=str(root), DDLOG_CARGO=str(cargo), DDLOG_OFFLINE='1', + DDLOG_LOCK_DIR=str(ROOT / 'native'), DDLOG_CARGO_LOCK=str(ROOT / 'native/program.Cargo.lock')) + result = subprocess.run([str(ROOT / 'scripts/build-ddlog.sh'), str(root / 'program.dl'), str(root / 'output')], + env=env, capture_output=True, text=True) + self.assertEqual(result.returncode, 1, result.stderr) + self.assertIn('does not lock types__lemmalog_star', result.stderr) + self.assertIn('DDLOG_LOCK_DIR', result.stderr) + self.assertFalse((project / 'Cargo.lock').exists()) + self.assertFalse((project / 'args').exists()) + + def test_star_phases_patch_equals_reference_and_fails_closed(self): + pinned = (ROOT / 'src/star/lemmalog_star.rs').read_text() + patched = install_observer.patch_star(GENERATED_PREAMBLE + pinned) + self.assertTrue(patched.startswith(GENERATED_PREAMBLE)) + body = patched[len(GENERATED_PREAMBLE):] + self.assertEqual(hashlib.sha256(body.encode()).hexdigest(), REFERENCE_STAR_SHA256) + for phase in ('large-star', 'small-star', 'minimum-label'): + self.assertEqual(body.count(f'region_named("{phase}"'), 1) + self.assertEqual(install_observer.patch_star(patched), patched) + with self.assertRaisesRegex(ValueError, 'star large-star open'): + install_observer.patch_star(pinned.replace('let large = symmetric', 'let large = other')) + with self.assertRaisesRegex(ValueError, 'star'): + install_observer.patch_star(pinned + pinned) + with tempfile.TemporaryDirectory() as d: + project = Path(d) + (project / 'differential_datalog/src/program').mkdir(parents=True) + (project / 'src').mkdir() + (project / 'types/lemmalog_star').mkdir(parents=True) + (project / 'differential_datalog/src/lib.rs').write_text('') + (project / 'differential_datalog/src/program/worker.rs').write_text(install_observer.WORKER_SITE) + (project / 'src/main.rs').write_text(install_observer.CLI_SITE) + (project / 'types/lemmalog_star/lemmalog_star.rs').write_text(GENERATED_PREAMBLE + pinned) + marker = install_observer.install(project) + self.assertEqual(marker['star_phases'], True) + self.assertEqual((project / 'types/lemmalog_star/lemmalog_star.rs').read_text(), patched) + self.assertEqual(json.loads((project / 'observer-install.json').read_text()), marker) + self.assertEqual(install_observer.install(project), marker) if __name__ == '__main__': diff --git a/tests/worlds.rs b/tests/worlds.rs new file mode 100644 index 0000000..81680bb --- /dev/null +++ b/tests/worlds.rs @@ -0,0 +1,1436 @@ +#![cfg(unix)] +//! Simulated transport acceptance: these tests do not claim native DDLog evaluation. +use ddlog_runtime::registry::{ProcessorDefinition, ProcessorReference}; +use ddlog_runtime::worlds::{ + ImportRequest, InventoryQuery, RegisterRequest, Scenario, TestRequest, WorldDefinition, + WorldManager, +}; +use serde_json::{json, Value}; +use std::{fs, os::unix::fs::PermissionsExt, path::PathBuf}; +static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); +struct Fixture { + root: PathBuf, +} +impl Fixture { + fn new() -> Self { + let root = std::env::temp_dir().join(format!( + "world-test-{}-{}-{}", + std::process::id(), + NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + fs::create_dir(&root).unwrap(); + let template = + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/memory_fake_runtime.py"); + fs::write(root.join("build.py"),format!("#!/usr/bin/env python3\nimport sys\nfrom pathlib import Path\nroot=Path({})\nif (root/'reject_build').exists(): sys.exit(91)\nsource=Path({}).read_text().replace('__CONTROL__',{})\nPath(sys.argv[2]).write_text(source)\nPath(sys.argv[2]).chmod(0o700)\n",json!(root),json!(template),json!(serde_json::to_string(&root).unwrap()))).unwrap(); + fs::set_permissions(root.join("build.py"), fs::Permissions::from_mode(0o700)).unwrap(); + Self { root } + } + fn manager(&self) -> WorldManager { + WorldManager::new( + self.root.join("registry"), + self.root.join("worlds"), + self.root.join("build.py"), + ) + .unwrap() + } +} +impl Drop for Fixture { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.root); + } +} +fn definition(manager: &WorldManager) -> WorldDefinition { + let definition:ProcessorDefinition=serde_json::from_value(json!({"rules":"echo(N,S) :- source(N,S).","schemas":{"source":{"input":true,"fields":["int","string"]},"echo":{"input":false,"fields":["int","string"]}}})).unwrap(); + let record = manager + .registry() + .unwrap() + .create(definition, None) + .unwrap(); + WorldDefinition { + label: "Test world".into(), + processor: ProcessorReference { + processor_id: record.processor_id, + version: record.version, + }, + purpose: "instance".into(), + scenarios: vec![], + } +} +fn echo_definition() -> Value { + json!({"rules":"echo(N,S) :- source(N,S).","schemas":{"source":{"input":true,"fields":["int","string"]},"echo":{"input":false,"fields":["int","string"]}}}) +} +/// Register through the control-plane path so the definition carries a name. +fn named(manager: &WorldManager, name: &str) -> WorldDefinition { + let record = manager + .register(RegisterRequest { + name: name.into(), + description: String::new(), + library_id: None, + definition: echo_definition(), + git_provenance: None, + }) + .unwrap(); + WorldDefinition { + label: name.into(), + processor: ProcessorReference { + processor_id: record["processor_id"].as_str().unwrap().into(), + version: record["version"].as_str().unwrap().into(), + }, + purpose: "instance".into(), + scenarios: vec![], + } +} +fn inventory(manager: &mut WorldManager) -> Value { + manager.inventory(&InventoryQuery::default()).unwrap() +} +fn wait_until(manager: &mut WorldManager, id: &str, done: impl Fn(&Value) -> bool) -> Value { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(20); + loop { + let status = manager.status(id).unwrap(); + if done(&status) { + return status; + } + assert!(std::time::Instant::now() < deadline, "timed out: {status}"); + std::thread::sleep(std::time::Duration::from_millis(10)); + } +} +fn fixture_registry() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/upstream/registry") +} +fn scenario(name: &str, values: Vec, expect: Vec>) -> Scenario { + serde_json::from_value(json!({"name":name,"description":"","changes":[{"op":"insert","predicate":"source","values":values}],"expect":{"echo":expect}})).unwrap() +} +#[test] +fn registration_is_not_execution_and_two_worlds_are_independent() { + let f = Fixture::new(); + let mut manager = f.manager(); + let definition = definition(&manager); + assert_eq!(inventory(&mut manager)["worlds"], json!([])); + let a = manager.create(definition.clone()).unwrap(); + let b = manager.create(definition).unwrap(); + assert_eq!(manager.status(&a).unwrap()["state"], "created"); + let sa = manager.start(&a).unwrap(); + let sb = manager.start(&b).unwrap(); + let pid = sa["resources"]["pid"].as_u64().unwrap() as i32; + assert_ne!(sa["resources"]["pid"], sb["resources"]["pid"]); + assert_eq!(sa["resources"]["state"], "available"); + assert!(sa["resources"]["resident_bytes"].as_u64().unwrap() > 0); + assert!(manager.start(&a).is_err()); + assert_eq!(manager.status(&a).unwrap()["resources"]["pid"], pid); + assert_eq!(manager.stop(&a).unwrap()["state"], "stopped"); + assert_eq!(unsafe { libc::kill(pid, 0) }, -1); + assert_eq!(manager.status(&b).unwrap()["state"], "running"); + let sbpid = sb["resources"]["pid"].as_u64().unwrap() as i32; + drop(manager); + assert_eq!(unsafe { libc::kill(sbpid, 0) }, -1); +} +#[test] +fn build_failure_and_child_exit_remain_observable() { + let f = Fixture::new(); + let mut manager = f.manager(); + let def = definition(&manager); + let id = manager.create(def).unwrap(); + fs::write(f.root.join("reject_build"), "").unwrap(); + assert!(manager.start(&id).is_err()); + assert_eq!(manager.status(&id).unwrap()["state"], "failed"); + fs::remove_file(f.root.join("reject_build")).unwrap(); + let live = manager.start(&id).unwrap(); + let pid = live["resources"]["pid"].as_u64().unwrap() as i32; + unsafe { + libc::kill(pid, libc::SIGKILL); + } + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); + while manager.status(&id).unwrap()["state"] != "failed" { + assert!(std::time::Instant::now() < deadline); + std::thread::sleep(std::time::Duration::from_millis(5)); + } + // The dead child was reaped when its death was observed, so `start` (valid + // for `failed`) opens a fresh generation without an explicit `stop`. + let restarted = manager.start(&id).unwrap(); + assert_eq!(restarted["state"], "running"); + assert_eq!(restarted["generation"], 3); + assert_ne!(restarted["resources"]["pid"], json!(pid)); + assert_eq!(manager.stop(&id).unwrap()["resources"]["state"], "missing"); + assert!(manager.status("stale-world").is_err()); +} +#[test] +fn invalid_pin_never_creates_world() { + let f = Fixture::new(); + let mut manager = f.manager(); + let mut def = definition(&manager); + def.processor.version = "invalid".into(); + assert!(manager.create(def).is_err()); + assert_eq!(inventory(&mut manager)["worlds"], json!([])); +} + +#[test] +fn history_survives_owner_restart_and_second_owner_is_rejected() { + let f = Fixture::new(); + let mut manager = f.manager(); + let def = definition(&manager); + let id = manager.create(def.clone()).unwrap(); + assert!(WorldManager::new( + f.root.join("registry"), + f.root.join("worlds"), + f.root.join("build.py") + ) + .is_err()); + manager.start(&id).unwrap(); + manager.stop(&id).unwrap(); + drop(manager); + let mut recovered = f.manager(); + let status = recovered.status(&id).unwrap(); + assert_eq!(status["state"], "stopped"); + assert_eq!( + status["definition"]["processor"]["version"], + def.processor.version + ); + assert_eq!(status["resources"]["pid"], serde_json::Value::Null); + assert_eq!(status["history"].as_array().unwrap().len(), 4); +} +#[test] +fn interrupted_record_does_not_adopt_or_restart_a_process() { + let f = Fixture::new(); + let mut manager = f.manager(); + let def = definition(&manager); + let id = manager.create(def).unwrap(); + drop(manager); + let record = f.root.join("worlds").join(&id).join("world.json"); + let mut value: serde_json::Value = serde_json::from_slice(&fs::read(&record).unwrap()).unwrap(); + value["state"] = json!("running"); + fs::write(&record, serde_json::to_vec(&value).unwrap()).unwrap(); + let mut recovered = f.manager(); + let status = recovered.status(&id).unwrap(); + assert_eq!(status["state"], "interrupted"); + assert_eq!(status["resources"]["pid"], serde_json::Value::Null); + assert!(status["error"].as_str().unwrap().contains("no process")); +} + +#[test] +fn library_registration_is_durable_and_does_not_execute() { + let f = Fixture::new(); + let manager = f.manager(); + let library = manager + .create_library(ddlog_runtime::worlds::LibraryDefinition { + name: "Holocron".into(), + repository: "https://github.com/VangelisTech/holocron".into(), + revision: "example-pin".into(), + }) + .unwrap(); + drop(manager); + let mut manager = f.manager(); + assert_eq!( + manager.libraries().unwrap()["libraries"][0]["id"], + library["id"] + ); + assert_eq!(inventory(&mut manager)["worlds"], json!([])); +} + +#[test] +fn shutdown_is_terminal_for_new_start_requests() { + let f = Fixture::new(); + let mut manager = f.manager(); + let def = definition(&manager); + let id = manager.create(def).unwrap(); + manager.shutdown_handle().stop_all(); + assert!(manager + .start_async(&id) + .unwrap_err() + .contains("shutting down")); + let status = manager.status(&id).unwrap(); + assert_eq!(status["state"], "created"); + assert_eq!(status["generation"], 0); + assert_eq!(status["managed_processes"], json!([])); +} +#[test] +fn failed_persistence_retries_without_losing_history() { + let f = Fixture::new(); + let mut manager = f.manager(); + let def = definition(&manager); + let id = manager.create(def).unwrap(); + let temp = f.root.join("worlds").join(&id).join("world.json.tmp"); + fs::create_dir(&temp).unwrap(); + assert!(manager.stop(&id).is_err()); + fs::remove_dir(&temp).unwrap(); + let status = manager.status(&id).unwrap(); + assert_eq!(status["state"], "stopped"); + let path = f.root.join("worlds").join(&id).join("world.json"); + let record: serde_json::Value = serde_json::from_slice(&fs::read(path).unwrap()).unwrap(); + assert_eq!(record["state"], "stopped"); + assert_eq!(record["history"].as_array().unwrap().len(), 2); + assert_eq!(record["history"], status["history"]); +} +#[test] +fn failed_start_persistence_does_not_leave_phantom_starting_world() { + let f = Fixture::new(); + let mut manager = f.manager(); + let def = definition(&manager); + let id = manager.create(def).unwrap(); + let temp = f.root.join("worlds").join(&id).join("world.json.tmp"); + fs::create_dir(&temp).unwrap(); + assert!(manager.start_async(&id).is_err()); + fs::remove_dir(&temp).unwrap(); + let status = manager.status(&id).unwrap(); + assert_eq!(status["state"], "failed"); + assert_eq!(status["managed_processes"], json!([])); + assert!(status["error"].as_str().unwrap().contains("persist")); + assert_eq!(manager.start(&id).unwrap()["state"], "running"); +} + +#[test] +fn runtime_info_reports_build_identity() { + let info = ddlog_runtime::runtime_info(); + assert_eq!(info["schema_version"], 1); + assert_eq!(info["crate_version"], env!("CARGO_PKG_VERSION")); + assert!(info["dirty"].is_boolean()); + match &info["commit"] { + Value::Null => (), + Value::String(hash) => { + assert!(hash.len() == 40 && hash.bytes().all(|b| b.is_ascii_hexdigit())) + } + other => panic!("unexpected commit {other}"), + } +} + +#[test] +fn registration_requires_names_and_files_definitions_into_libraries() { + let f = Fixture::new(); + let manager = f.manager(); + let unnamed = manager.register(RegisterRequest { + name: " ".into(), + description: String::new(), + library_id: None, + definition: echo_definition(), + git_provenance: None, + }); + assert!(unnamed.unwrap_err().contains("name")); + assert!(manager + .register(RegisterRequest { + name: "Echo".into(), + description: String::new(), + library_id: Some("missing-library".into()), + definition: echo_definition(), + git_provenance: None, + }) + .unwrap_err() + .contains("Unknown library")); + let echo = named(&manager, "Echo"); + let libraries = manager.libraries().unwrap(); + let unassigned = libraries["libraries"] + .as_array() + .unwrap() + .iter() + .find(|entry| entry["id"] == "unassigned") + .unwrap(); + assert_eq!(unassigned["name"], "Unassigned"); + assert_eq!( + unassigned["processors"][0]["processor_id"], + echo.processor.processor_id + ); + assert_eq!(unassigned["processors"][0]["name"], "Echo"); + let library = manager + .create_library(ddlog_runtime::worlds::LibraryDefinition { + name: "Examples".into(), + repository: "https://example.invalid/examples".into(), + revision: "main".into(), + }) + .unwrap(); + assert_eq!(library["processors"], json!([])); + // A pin belongs to exactly one library; association moves it. + manager + .associate( + &echo.processor.processor_id, + &echo.processor.version, + library["id"].as_str().unwrap(), + "Echo program", + "Copies rows", + ) + .unwrap(); + let libraries = manager.libraries().unwrap(); + for entry in libraries["libraries"].as_array().unwrap() { + let held = entry["processors"].as_array().unwrap().len(); + assert_eq!(held, usize::from(entry["id"] == library["id"]), "{entry}"); + } + // A composition of the interface-bearing program lists as a composition. + let leaf = manager.register(RegisterRequest { + name: "Leaf".into(), + description: String::new(), + library_id: None, + definition: json!({"rules":"echo(N,S) :- source(N,S).","schemas":{"source":{"input":true,"fields":["int","string"]},"echo":{"input":false,"fields":["int","string"]}},"interface":{"inputs":["source"],"outputs":["echo"]}}), + git_provenance: None, + }).unwrap(); + let composed = manager.register(RegisterRequest { + name: "Composed".into(), + description: "wraps the leaf".into(), + library_id: Some(library["id"].as_str().unwrap().into()), + definition: json!({"composition":{"nodes":{"leaf":{"processor_id":leaf["processor_id"],"version":leaf["version"]}},"inputs":{"rows":{"fields":["int","string"],"targets":[{"node":"leaf","relation":"source"}]}},"bindings":[],"outputs":{"copies":{"node":"leaf","relation":"echo"}}}}), + git_provenance: None, + }).unwrap(); + assert_eq!(composed["library_id"], library["id"]); + let listing = manager.definitions().unwrap(); + let rows = listing["processors"].as_array().unwrap(); + assert_eq!(rows.len(), 3); + let row = |pid: &Value| { + rows.iter() + .find(|row| row["processor_id"] == *pid) + .unwrap() + .clone() + }; + let echo_row = row(&json!(echo.processor.processor_id)); + assert_eq!(echo_row["name"], "Echo program"); + assert_eq!(echo_row["description"], "Copies rows"); + assert_eq!(echo_row["library_id"], library["id"]); + assert_eq!(echo_row["kind"], "program"); + assert_eq!(echo_row["current"], true); + assert_eq!(echo_row["status"], "active"); + let composed_row = row(&composed["processor_id"]); + assert_eq!(composed_row["kind"], "composition"); + assert_eq!(composed_row["name"], "Composed"); + assert_eq!(row(&leaf["processor_id"])["library_id"], "unassigned"); + let page = manager.registry().unwrap().list(100, None, false).unwrap(); + let summary = page + .processors + .iter() + .find(|p| p.processor_id == composed["processor_id"].as_str().unwrap()) + .unwrap(); + assert_eq!( + summary.kind, + ddlog_runtime::registry::ProcessorKind::Composition + ); + // Every version of a processor is listed, and only one is current. + let registry = manager.registry().unwrap(); + let published = registry.publish(echo.processor.processor_id.as_str(), serde_json::from_value(json!({"rules":"echo(N,S) :- source(N,S). twice(N) :- source(N,_).","schemas":{"source":{"input":true,"fields":["int","string"]},"echo":{"input":false,"fields":["int","string"]},"twice":{"input":false,"fields":["int"]}}})).unwrap(), &echo.processor.version, None).unwrap(); + let rows = manager.definitions().unwrap()["processors"].clone(); + let versions: Vec<&Value> = rows + .as_array() + .unwrap() + .iter() + .filter(|row| row["processor_id"] == echo.processor.processor_id) + .collect(); + assert_eq!(versions.len(), 2); + let current: Vec<&&Value> = versions + .iter() + .filter(|row| row["current"] == true) + .collect(); + assert_eq!(current.len(), 1); + assert_eq!(current[0]["version"], published.version); + assert_eq!( + current[0]["name"], "echo, twice ← source", + "unassociated versions derive a name" + ); +} + +#[test] +fn import_preserves_identities_from_a_public_fixture_registry() { + let f = Fixture::new(); + let manager = f.manager(); + assert_eq!( + fs::metadata(fixture_registry()) + .unwrap() + .permissions() + .mode() + & 0o777, + 0o755 + ); + let expected: Value = serde_json::from_slice( + &fs::read(fixture_registry().parent().unwrap().join("records.json")).unwrap(), + ) + .unwrap(); + let star = expected["star"]["processor_id"] + .as_str() + .unwrap() + .to_string(); + let request = |dry_run: bool, processor_id: Option<&str>| ImportRequest { + source_registry: fixture_registry(), + processor_id: processor_id.map(String::from), + library_id: None, + names: [(star.clone(), "Star".to_string())].into_iter().collect(), + dry_run, + }; + let planned = manager.import(request(true, None)).unwrap(); + assert_eq!(planned["errors"], json!([])); + assert_eq!(planned["dry_run"], true); + assert_eq!(planned["imported"].as_array().unwrap().len(), 5); + assert!(planned["imported"] + .as_array() + .unwrap() + .iter() + .all(|row| row["status"] == "imported")); + assert_eq!( + manager.definitions().unwrap()["processors"], + json!([]), + "dry run writes nothing" + ); + let imported = manager.import(request(false, None)).unwrap(); + assert_eq!(imported["errors"], json!([])); + assert_eq!(imported["imported"].as_array().unwrap().len(), 5); + let registry = manager.registry().unwrap(); + for record in expected.as_object().unwrap().values() { + let read = registry + .get( + record["processor_id"].as_str().unwrap(), + Some(record["version"].as_str().unwrap()), + ) + .unwrap(); + assert_eq!( + serde_json::to_value(&read).unwrap(), + *record, + "identity and content preserved" + ); + assert_eq!( + registry + .get(record["processor_id"].as_str().unwrap(), None) + .unwrap() + .version, + record["version"], + "current pointer copied" + ); + } + let listing = manager.definitions().unwrap(); + let rows = listing["processors"].as_array().unwrap(); + assert_eq!(rows.len(), 5); + let name = |key: &str| { + rows.iter() + .find(|row| row["processor_id"] == expected[key]["processor_id"]) + .unwrap()["name"] + .clone() + }; + assert_eq!(name("star"), "Star"); + assert_eq!(name("nested"), "Composition of graph"); + assert_eq!(name("wrapper"), "Composition of graph"); + assert_eq!(name("pure"), "visible ← item"); + let again = manager.import(request(false, None)).unwrap(); + assert!( + again["imported"] + .as_array() + .unwrap() + .iter() + .all(|row| row["status"] == "present"), + "{again}" + ); + // One processor imports its dependency closure first. + let g = Fixture::new(); + let closure = g.manager(); + let nested = expected["nested"]["processor_id"].as_str().unwrap(); + let result = closure.import(request(false, Some(nested))).unwrap(); + let order: Vec<&str> = result["imported"] + .as_array() + .unwrap() + .iter() + .map(|row| row["processor_id"].as_str().unwrap()) + .collect(); + assert_eq!( + order, + vec![ + expected["star"]["processor_id"].as_str().unwrap(), + expected["wrapper"]["processor_id"].as_str().unwrap(), + nested + ] + ); + assert_eq!( + closure.definitions().unwrap()["processors"] + .as_array() + .unwrap() + .len(), + 3 + ); + // A record with the same name but different content is never overwritten. + let tampered = g.root.join("tampered"); + let copy = |from: &std::path::Path, to: &std::path::Path| { + fs::create_dir_all(to).unwrap(); + for entry in fs::read_dir(from).unwrap() { + let entry = entry.unwrap(); + if entry.file_type().unwrap().is_dir() { + fs::create_dir_all(to.join(entry.file_name())).unwrap(); + } + } + }; + copy(&fixture_registry(), &tampered); + for entry in fs::read_dir(fixture_registry()).unwrap() { + let entry = entry.unwrap(); + let target = tampered.join(entry.file_name()); + fs::create_dir_all(target.join("versions")).unwrap(); + fs::copy( + entry.path().join("current.json"), + target.join("current.json"), + ) + .unwrap(); + for version in fs::read_dir(entry.path().join("versions")).unwrap() { + let version = version.unwrap(); + fs::copy( + version.path(), + target.join("versions").join(version.file_name()), + ) + .unwrap(); + } + } + let star_dir = tampered.join(&star).join("versions"); + let star_file = fs::read_dir(&star_dir) + .unwrap() + .next() + .unwrap() + .unwrap() + .path(); + let mut record: Value = serde_json::from_slice(&fs::read(&star_file).unwrap()).unwrap(); + record["created_at_unix_ms"] = json!(1); + fs::write(&star_file, serde_json::to_vec(&record).unwrap()).unwrap(); + let conflict = closure + .import(ImportRequest { + source_registry: tampered.clone(), + processor_id: None, + library_id: None, + names: Default::default(), + dry_run: false, + }) + .unwrap(); + assert_eq!(conflict["imported"], json!([])); + assert_eq!(conflict["errors"].as_array().unwrap().len(), 1); + assert_eq!(conflict["errors"][0]["processor_id"], star); + assert!(conflict["errors"][0]["error"] + .as_str() + .unwrap() + .contains("different content")); + assert_eq!( + closure.definitions().unwrap()["processors"] + .as_array() + .unwrap() + .len(), + 3, + "nothing written" + ); + // A content-hash mismatch is rejected before anything is written. + record["definition"]["rules"] = json!("changed(X) :- source(X)."); + fs::write(&star_file, serde_json::to_vec(&record).unwrap()).unwrap(); + let h = Fixture::new(); + let fresh = h.manager(); + let rejected = fresh + .import(ImportRequest { + source_registry: tampered, + processor_id: None, + library_id: None, + names: Default::default(), + dry_run: false, + }) + .unwrap(); + assert!( + rejected["errors"][0]["error"] + .as_str() + .unwrap() + .contains("hash mismatch"), + "{rejected}" + ); + assert_eq!(rejected["imported"], json!([])); + assert_eq!(fresh.definitions().unwrap()["processors"], json!([])); + assert!(fresh + .import(ImportRequest { + source_registry: "relative/path".into(), + processor_id: None, + library_id: None, + names: Default::default(), + dry_run: true + }) + .is_err()); +} + +#[test] +fn inventory_filters_by_pin_and_summary_skips_live_instance_calls() { + let f = Fixture::new(); + let mut manager = f.manager(); + let echo = named(&manager, "Echo"); + let other = named(&manager, "Other"); + let a = manager.create(echo.clone()).unwrap(); + let b = manager.create(other.clone()).unwrap(); + let filtered = manager + .inventory(&InventoryQuery { + processor_id: Some(echo.processor.processor_id.clone()), + version: None, + summary: false, + }) + .unwrap(); + assert_eq!(filtered["worlds"].as_array().unwrap().len(), 1); + assert_eq!(filtered["worlds"][0]["id"], a); + let none = manager + .inventory(&InventoryQuery { + processor_id: Some(echo.processor.processor_id.clone()), + version: Some("sha256:none".into()), + summary: false, + }) + .unwrap(); + assert_eq!(none["worlds"], json!([])); + manager.start(&b).unwrap(); + let summary = manager + .inventory(&InventoryQuery { + processor_id: None, + version: None, + summary: true, + }) + .unwrap(); + assert_eq!(summary["summary"], true); + let running = summary["worlds"] + .as_array() + .unwrap() + .iter() + .find(|w| w["id"] == b) + .unwrap(); + assert_eq!(running["state"], "running"); + for absent in ["inspection", "instance", "managed_processes"] { + assert!(running.get(absent).is_none(), "{absent} present in summary"); + } + assert_eq!( + running["persistence"], + json!({"status":"not_configured","reason":"world checkpoints are not wired"}) + ); + assert_eq!(running["build"], Value::Null); + assert_eq!(running["resources"]["state"], "available"); + let full = manager.status(&b).unwrap(); + let starting = full["history"] + .as_array() + .unwrap() + .iter() + .find(|e| e["state"] == "starting" && e["generation"] == 1) + .unwrap(); + assert_eq!(full["started_at_unix_ms"], starting["at_unix_ms"]); + assert_eq!(full["instance"]["revision"], 1); + assert_eq!( + full["definition"].get("purpose"), + None, + "default purpose is not serialized" + ); + assert_eq!( + manager.status(&a).unwrap()["started_at_unix_ms"], + Value::Null + ); + // A failed build reports its generation, log tail and instrumentation. + fs::write(f.root.join("reject_build"), "").unwrap(); + assert!(manager.start(&a).is_err()); + let failed = manager.status(&a).unwrap(); + assert_eq!(failed["state"], "failed"); + assert_eq!(failed["build"]["generation"], 1); + assert_eq!(failed["build"]["instrumented"], false); + assert!(failed["build"]["log_tail"].is_string()); + let record: Value = serde_json::from_slice( + &fs::read(f.root.join("worlds").join(&a).join("world.json")).unwrap(), + ) + .unwrap(); + assert!(record.get("test").is_none()); +} + +#[test] +fn execute_exposes_revision_relations_typed_rows_and_source() { + let f = Fixture::new(); + let mut manager = f.manager(); + let echo = named(&manager, "Echo"); + let id = manager.create(echo).unwrap(); + let status = manager.start(&id).unwrap(); + let source = + fs::read_to_string(f.root.join("worlds").join(&id).join("1/build-1/program.dl")).unwrap(); + use sha2::{Digest, Sha256}; + let sha = format!("{:x}", Sha256::digest(source.as_bytes())); + assert_eq!(status["instance"]["revision"], 1); + assert_eq!(status["instance"]["program_version"], 1); + assert_eq!(status["instance"]["source_sha256"], sha); + let program = manager.execute(&id, "program_source", &json!({})).unwrap(); + assert_eq!(program, json!({"source":source,"source_sha256":sha})); + let applied = manager.execute(&id, "apply_changes", &json!({"changes":[{"op":"insert","predicate":"source","values":[1,"a"]},{"op":"insert","predicate":"source","values":[2,"b"]}]})).unwrap(); + assert_eq!(applied["revision"], 2); + assert_eq!(manager.status(&id).unwrap()["instance"]["revision"], 2); + let relations = manager.execute(&id, "relations", &json!({})).unwrap(); + assert_eq!( + relations, + json!({"revision":2,"relations":[{"name":"source","input":true,"fields":["int","string"],"count":2},{"name":"echo","input":false,"fields":["int","string"],"count":2}]}) + ); + let first = manager + .execute(&id, "query_rows", &json!({"predicate":"echo","max_rows":1})) + .unwrap(); + assert_eq!(first["rows"], json!([[1, "a"]])); + assert_eq!(first["total"], 2); + assert_eq!(first["complete"], false); + assert_eq!(first["fields"], json!(["int", "string"])); + assert_eq!(first["revision"], 2); + let second = manager + .execute( + &id, + "query_rows", + &json!({"predicate":"echo","max_rows":1,"continuation":first["continuation"]}), + ) + .unwrap(); + assert_eq!(second["rows"], json!([[2, "b"]])); + assert_eq!(second["complete"], true); + assert_eq!(second["continuation"], Value::Null); + let inputs = manager + .execute( + &id, + "query_rows", + &json!({"predicate":"source","max_rows":1}), + ) + .unwrap(); + assert_eq!(inputs["rows"], json!([[1, "a"]])); + assert_eq!(inputs["total"], 2); + assert_eq!(inputs["complete"], false); + let rest = manager + .execute( + &id, + "query_rows", + &json!({"predicate":"source","continuation":inputs["continuation"]}), + ) + .unwrap(); + assert_eq!(rest["rows"], json!([[2, "b"]])); + assert_eq!(rest["complete"], true); + let everything = manager + .execute(&id, "query_rows", &json!({"predicate":"echo"})) + .unwrap(); + assert_eq!(everything["rows"].as_array().unwrap().len(), 2); + assert!(manager + .execute(&id, "query_rows", &json!({"predicate":"echo","max_rows":0})) + .unwrap_err() + .contains("max_rows")); + assert!(manager + .execute(&id, "query_rows", &json!({"predicate":"missing"})) + .unwrap_err() + .contains("Unknown public relation")); + manager + .execute( + &id, + "apply_changes", + &json!({"changes":[{"op":"delete","predicate":"source","values":[1,"a"]}]}), + ) + .unwrap(); + assert!(manager + .execute( + &id, + "query_rows", + &json!({"predicate":"echo","continuation":first["continuation"]}) + ) + .unwrap_err() + .contains("Continuation")); + assert!(manager + .execute( + &id, + "query_rows", + &json!({"predicate":"source","continuation":inputs["continuation"]}) + ) + .unwrap_err() + .contains("Continuation")); +} + +#[test] +fn scenarios_are_validated_against_public_relations() { + let f = Fixture::new(); + let manager = f.manager(); + let record = manager.register(RegisterRequest { + name: "Bounded".into(), + description: String::new(), + library_id: None, + definition: json!({"rules":"echo(N,S) :- source(N,S). private(N) :- source(N,_).","schemas":{"source":{"input":true,"fields":["int","string"]},"echo":{"input":false,"fields":["int","string"]},"private":{"input":false,"fields":["int"]}},"interface":{"inputs":["source"],"outputs":["echo"]}}), + git_provenance: None, + }).unwrap(); + let (pid, version) = ( + record["processor_id"].as_str().unwrap(), + record["version"].as_str().unwrap(), + ); + assert_eq!( + manager.scenarios_get(pid, version).unwrap()["scenarios"], + json!([]) + ); + let good = vec![scenario( + "one", + vec![json!(1), json!("a")], + vec![vec![json!(1), json!("a")]], + )]; + let stored = manager.scenarios_set(pid, version, good.clone()).unwrap(); + assert_eq!(stored["processor"]["processor_id"], pid); + assert_eq!( + manager.scenarios_get(pid, version).unwrap()["scenarios"], + json!(good) + ); + let bad = |value: Value| -> String { + let scenarios: Vec = serde_json::from_value(json!([value])).unwrap(); + manager.scenarios_set(pid, version, scenarios).unwrap_err() + }; + assert!(bad(json!({"name":"x","changes":[{"op":"insert","predicate":"echo","values":[1,"a"]}],"expect":{}})).contains("not a public input")); + assert!(bad(json!({"name":"x","changes":[{"op":"upsert","predicate":"source","values":[1,"a"]}],"expect":{}})).contains("insert or delete")); + assert!(bad(json!({"name":"x","changes":[{"op":"insert","predicate":"source","values":[1]}],"expect":{}})).contains("arity")); + assert!(bad(json!({"name":"x","changes":[{"op":"insert","predicate":"source","values":["1","a"]}],"expect":{}})).contains("must be int")); + assert!( + bad(json!({"name":"x","changes":[],"expect":{"private":[[1]]}})).contains("not public") + ); + assert!( + bad(json!({"name":"x","changes":[],"expect":{"echo":[[1,"a"],[1,"a"]]}})) + .contains("duplicate") + ); + assert!(bad(json!({"name":"","changes":[],"expect":{}})).contains("name")); + let duplicate: Vec = serde_json::from_value( + json!([{"name":"x","changes":[],"expect":{}},{"name":"x","changes":[],"expect":{}}]), + ) + .unwrap(); + assert!(manager + .scenarios_set(pid, version, duplicate) + .unwrap_err() + .contains("Duplicate")); + assert!(serde_json::from_value::( + json!({"name":"x","changes":[],"expect":{},"extra":1}) + ) + .is_err()); + assert!(manager + .scenarios_set( + pid, + "sha256:0000000000000000000000000000000000000000000000000000000000000000", + vec![] + ) + .is_err()); + assert_eq!( + manager.scenarios_get(pid, version).unwrap()["scenarios"], + json!(good), + "rejected writes leave the store unchanged" + ); +} + +#[test] +fn test_worlds_run_scenarios_asynchronously_and_report_results() { + let f = Fixture::new(); + let mut manager = f.manager(); + let echo = named(&manager, "Echo"); + let (pid, version) = ( + echo.processor.processor_id.clone(), + echo.processor.version.clone(), + ); + let request = |scenarios: Option>, keep_world: bool| TestRequest { + processor_id: pid.clone(), + version: version.clone(), + scenarios: scenarios.map(|names| names.into_iter().map(String::from).collect()), + keep_world, + }; + assert!(manager + .test(request(None, false)) + .unwrap_err() + .contains("scenarios_set")); + manager + .scenarios_set( + &pid, + &version, + vec![ + scenario( + "first", + vec![json!(1), json!("a")], + vec![vec![json!(1), json!("a")]], + ), + scenario( + "second", + vec![json!(2), json!("b")], + vec![vec![json!(1), json!("a")], vec![json!(2), json!("b")]], + ), + scenario( + "wrong", + vec![json!(3), json!("c")], + vec![vec![json!(1), json!("a")], vec![json!(9), json!("z")]], + ), + ], + ) + .unwrap(); + assert!(manager + .test(request(Some(vec!["missing"]), false)) + .unwrap_err() + .contains("Unknown scenario")); + let started = manager.test(request(None, false)).unwrap(); + assert_eq!(started["state"], "starting"); + assert_eq!(started["definition"]["purpose"], "test"); + assert_eq!(started["definition"]["label"], "Test · Echo"); + assert_eq!( + started["definition"]["scenarios"].as_array().unwrap().len(), + 3 + ); + assert_eq!(started["test"]["phase"], "building"); + assert_eq!(started["test"]["passed"], Value::Null); + let id = started["id"].as_str().unwrap().to_string(); + let done = wait_until(&mut manager, &id, |status| status["state"] != "starting"); + assert_eq!( + done["state"], "stopped", + "the instance is dropped when keep_world is false: {done}" + ); + assert_eq!(done["error"], Value::Null); + let test = &done["test"]; + assert_eq!(test["phase"], "done"); + assert_eq!(test["scenario_index"], 3); + assert_eq!(test["passed"], false); + let results = test["results"].as_array().unwrap(); + assert_eq!(results.len(), 3); + assert_eq!(results[0]["name"], "first"); + assert_eq!(results[0]["passed"], true); + assert_eq!(results[0]["revision"], 2); + assert_eq!(results[0]["observed"]["echo"], json!([[1, "a"]])); + assert_eq!(results[1]["passed"], true); + assert_eq!(results[1]["revision"], 3); + assert_eq!(results[1]["observed"]["echo"], json!([[1, "a"], [2, "b"]])); + assert_eq!(results[2]["passed"], false); + assert_eq!(results[2]["missing"]["echo"], json!([[9, "z"]])); + assert_eq!( + results[2]["unexpected"]["echo"], + json!([[2, "b"], [3, "c"]]) + ); + assert_eq!(results[2]["error"], Value::Null); + assert_eq!(done["resources"]["pid"], Value::Null); + let record: Value = serde_json::from_slice( + &fs::read(f.root.join("worlds").join(&id).join("world.json")).unwrap(), + ) + .unwrap(); + assert_eq!(record["test"], *test, "test results are persisted"); + let listed = inventory(&mut manager); + let world = listed["worlds"] + .as_array() + .unwrap() + .iter() + .find(|w| w["id"] == id) + .unwrap(); + assert_eq!(world["definition"]["purpose"], "test"); + // Selected scenarios only, keeping the world for inspection. + let kept = manager.test(request(Some(vec!["second"]), true)).unwrap(); + let kept_id = kept["id"].as_str().unwrap().to_string(); + let running = wait_until(&mut manager, &kept_id, |status| { + status["state"] != "starting" + }); + assert_eq!(running["state"], "running"); + assert_eq!( + running["test"]["passed"], false, + "the second scenario alone lacks the first insert" + ); + assert_eq!(running["test"]["results"].as_array().unwrap().len(), 1); + assert_eq!(running["test"]["results"][0]["name"], "second"); + assert_eq!( + running["test"]["results"][0]["missing"]["echo"], + json!([[1, "a"]]) + ); + assert_eq!( + manager.execute(&kept_id, "relations", &json!({})).unwrap()["relations"][0]["count"], + 1 + ); + assert_eq!(manager.stop(&kept_id).unwrap()["state"], "stopped"); + // Build failures are reported as a failed test phase with the build error. + fs::write(f.root.join("reject_build"), "").unwrap(); + let broken = manager.test(request(Some(vec!["first"]), false)).unwrap(); + let broken_id = broken["id"].as_str().unwrap().to_string(); + let failed = wait_until(&mut manager, &broken_id, |status| { + status["state"] != "starting" + }); + assert_eq!(failed["state"], "failed"); + assert_eq!(failed["test"]["phase"], "failed"); + assert_eq!(failed["test"]["passed"], false); + assert!(failed["test"]["error"] + .as_str() + .unwrap() + .contains("compilation failed")); + assert_eq!(failed["build"]["generation"], 1); + fs::remove_file(f.root.join("reject_build")).unwrap(); + // Purpose and scenarios are validated at creation. + let mut invalid = echo.clone(); + invalid.purpose = "benchmark".into(); + assert!(manager.create(invalid).unwrap_err().contains("purpose")); + let mut empty = echo.clone(); + empty.purpose = "test".into(); + assert!(manager + .create(empty) + .unwrap_err() + .contains("at least one scenario")); + let mut instance = echo.clone(); + instance.scenarios = vec![scenario("s", vec![json!(1), json!("a")], vec![])]; + assert!(manager + .create(instance) + .unwrap_err() + .contains("purpose test")); + // Results survive owner restart. + drop(manager); + let mut recovered = f.manager(); + let status = recovered.status(&id).unwrap(); + assert_eq!(status["state"], "stopped"); + assert_eq!(status["test"]["phase"], "done"); + assert_eq!(status["test"]["results"].as_array().unwrap().len(), 3); +} + +/// Append synthetic native capture lines; the simulated runtime never writes them. +fn append_capture(f: &Fixture, id: &str, generation: u64, events: &[Value]) { + use std::io::Write; + let path = f + .root + .join("worlds") + .join(id) + .join(generation.to_string()) + .join("native-events.jsonl"); + let mut file = fs::OpenOptions::new() + .create(true) + .append(true) + .open(path) + .unwrap(); + for event in events { + writeln!(file, "{event}").unwrap(); + } +} +fn operates(id: u64, addr: &[u64], name: &str, debug: &str) -> Value { + json!({"stream":"timely","worker":0,"time_ns":id,"event":{"Operates":{"id":id,"addr":addr,"name":name}},"debug":debug}) +} +fn schedule(id: u64, start: u64, stop: u64) -> Vec { + vec![ + json!({"stream":"timely","worker":0,"time_ns":start,"event":{"Schedule":{"id":id,"start_stop":"Start"}}}), + json!({"stream":"timely","worker":0,"time_ns":stop,"event":{"Schedule":{"id":id,"start_stop":"Stop"}}}), + ] +} +fn topology() -> Vec { + vec![ + operates(1, &[0, 1], "Input", "Input { rel: \"R_source\" }"), + operates(2, &[0, 2], "Map", "authored"), + json!({"stream":"timely","worker":0,"time_ns":3,"event":{"Channels":{"id":9,"scope_addr":[0],"source":[1,0],"target":[2,0]}}}), + ] +} +fn capture_file(f: &Fixture, definition: &WorldDefinition) -> PathBuf { + f.root + .join("worlds") + .join("captures") + .join(&definition.processor.processor_id) + .join(format!("{}.json", &definition.processor.version[7..])) +} +#[test] +fn activity_is_tailed_rotation_keeps_topology_and_captures_are_retained_once() { + let f = Fixture::new(); + let mut manager = f.manager(); + let echo = named(&manager, "Echo"); + let (pid, version) = ( + echo.processor.processor_id.clone(), + echo.processor.version.clone(), + ); + assert_eq!( + manager.capture_get(&pid, &version).unwrap()["state"], + "missing" + ); + assert!(manager.capture_get("processor_missing", &version).is_err()); + let id = manager.create(echo.clone()).unwrap(); + let running = manager.start(&id).unwrap(); + assert_eq!( + running["inspection"]["state"], "missing", + "the simulated runtime writes no capture" + ); + assert!(!capture_file(&f, &echo).exists()); + // Topology alone is available but not yet captured: no Schedule event. + append_capture(&f, &id, 1, &topology()); + let available = wait_until(&mut manager, &id, |s| { + s["inspection"]["state"] == "available" + }); + assert_eq!(available["inspection"]["unresolved_channels"], 0); + assert_eq!(available["inspection"]["activity"]["complete"], true); + assert_eq!( + available["inspection"]["activity"]["totals"]["operators"], + 2 + ); + assert_eq!( + available["inspection"]["activity"]["nodes"]["0:2"]["schedule_count"], + 0 + ); + assert!(!capture_file(&f, &echo).exists()); + let mut events = schedule(2, 100, 250); + events.push(json!({"stream":"timely","worker":0,"time_ns":300,"event":{"Messages":{"is_send":true,"channel":9,"source":0,"target":0,"seq_no":1,"length":7}}})); + events.push(json!({"stream":"progress","worker":0,"time_ns":310,"event":{}})); + events.push(json!({"stream":"differential","worker":0,"time_ns":320,"event":{"kind":"Batch","operator":2,"length":3}})); + events.push(json!({"stream":"timely","worker":0,"time_ns":400,"event":{"Shutdown":{"id":1}}})); + append_capture(&f, &id, 1, &events); + let active = wait_until(&mut manager, &id, |s| { + s["inspection"]["activity"]["nodes"]["0:2"]["schedule_count"] == 1 + }); + let activity = &active["inspection"]["activity"]; + assert_eq!( + activity["nodes"]["0:2"], + json!({"schedule_count":1,"busy_ns":150,"last_seen_ns":250,"active":true,"arrangement_events":1,"last_arrangement_event":{"kind":"Batch","time_ns":320,"length":3}}) + ); + assert_eq!(activity["nodes"]["0:1"]["active"], false); + assert_eq!( + activity["channels"]["0:9"], + json!({"message_count":1,"records":7}) + ); + assert_eq!(activity["totals"]["timely"], 7); + assert_eq!(activity["totals"]["progress"], 1); + assert_eq!(activity["totals"]["differential"], 1); + assert_eq!(activity["last_event_ns"], 400); + assert_eq!(activity["rotations"], 0); + assert_eq!(activity["lag_bytes"], 0); + assert!(activity["last_ingest_unix_ms"].as_u64().unwrap() > 0); + // The first available status with a Schedule event retained the capture. + let capture = manager.capture_get(&pid, &version).unwrap(); + assert_eq!(capture["schema_version"], 1); + assert_eq!(capture["world_id"], id); + assert_eq!(capture["generation"], 1); + assert_eq!(capture["processor"], json!(echo.processor)); + assert_eq!(capture["graph"]["nodes"].as_array().unwrap().len(), 2); + assert_eq!(capture["graph"]["edges"].as_array().unwrap().len(), 1); + assert_eq!(capture["unresolved_channels"], 0); + assert!(capture.get("activity").is_none(), "topology only"); + let captured_at = capture["captured_at_unix_ms"].as_u64().unwrap(); + assert!(captured_at > 0); + let record: Value = serde_json::from_slice( + &fs::read(f.root.join("worlds").join(&id).join("world.json")).unwrap(), + ) + .unwrap(); + assert_eq!(record["capture_generation"], 1); + // Rotation: the hook truncated the file and wrote the rotation record first. + let mut rotated = vec![json!({"stream":"capture_status","status":"rotated","bytes":4096})]; + rotated.extend(schedule(2, 500, 600)); + let path = f + .root + .join("worlds") + .join(&id) + .join("1") + .join("native-events.jsonl"); + fs::write(&path, "").unwrap(); + append_capture(&f, &id, 1, &rotated); + let after = wait_until(&mut manager, &id, |s| { + s["inspection"]["activity"]["rotations"] == 1 + && s["inspection"]["activity"]["nodes"]["0:2"]["schedule_count"] == 2 + }); + assert_eq!(after["inspection"]["state"], "available"); + assert_eq!( + after["inspection"]["graph"]["nodes"] + .as_array() + .unwrap() + .len(), + 2 + ); + assert_eq!( + after["inspection"]["activity"]["nodes"]["0:2"]["busy_ns"], + 250 + ); + assert_eq!(after["inspection"]["activity"]["complete"], true); + // Captures are retained once per generation: nothing was rewritten. + std::thread::sleep(std::time::Duration::from_millis(5)); + assert_eq!( + manager.capture_get(&pid, &version).unwrap()["captured_at_unix_ms"], + captured_at + ); + // A truncation marker is reported as a state; lag never is. + append_capture( + &f, + &id, + 1, + &[json!({"stream":"capture_status","status":"truncated","reason":"worker_byte_limit"})], + ); + let truncated = wait_until(&mut manager, &id, |s| { + s["inspection"]["state"] == "truncated" + }); + assert_eq!(truncated["inspection"]["activity"]["complete"], false); + assert!( + truncated["inspection"]["activity"]["truncated_at_bytes"] + .as_u64() + .unwrap() + > 0 + ); + assert_eq!(truncated["state"], "running"); + // Summary inventory never touches the capture. + let summary = manager + .inventory(&InventoryQuery { + processor_id: None, + version: None, + summary: true, + }) + .unwrap(); + assert!(summary["worlds"][0].get("inspection").is_none()); + // Stopping joins the tailer; the ingested state stays readable. + let stopped = manager.stop(&id).unwrap(); + assert_eq!(stopped["state"], "stopped"); + assert_eq!(stopped["inspection"]["activity"]["rotations"], 1); + // A second generation captures again and replaces the retained document. + fs::remove_file(&path).unwrap(); + manager.start(&id).unwrap(); + let mut second = topology(); + second.extend(schedule(2, 10, 20)); + append_capture(&f, &id, 2, &second); + wait_until(&mut manager, &id, |s| { + s["inspection"]["activity"]["nodes"]["0:2"]["schedule_count"] == 1 + }); + let capture = manager.capture_get(&pid, &version).unwrap(); + assert_eq!(capture["generation"], 2); + assert_eq!(manager.stop(&id).unwrap()["state"], "stopped"); + // Recovered worlds read their last capture once and hold it without a tailer. + drop(manager); + let mut recovered = f.manager(); + let status = recovered.status(&id).unwrap(); + assert_eq!(status["state"], "stopped"); + assert_eq!(status["inspection"]["state"], "available"); + assert_eq!( + status["inspection"]["activity"]["nodes"]["0:2"]["schedule_count"], + 1 + ); + assert_eq!(status["inspection"]["activity"]["complete"], true); + assert_eq!( + recovered.capture_get(&pid, &version).unwrap()["generation"], + 2, + "recovery does not re-capture a retained generation" + ); + append_capture(&f, &id, 2, &schedule(2, 30, 40)); + std::thread::sleep(std::time::Duration::from_millis(250)); + assert_eq!( + recovered.status(&id).unwrap()["inspection"]["activity"]["nodes"]["0:2"]["schedule_count"], + 1, + "a stopped world's capture is read once, never tailed" + ); +} + +#[test] +fn member_match_groups_resolve_at_snapshot_time() { + let f = Fixture::new(); + let mut manager = f.manager(); + let provenance = json!({"repository":"repo","revision":"commit","source":null}); + let matched = with_groups( + &manager, + json!([ + {"id":"phase","name":"Large star","member_key":"large","provenance":provenance, + "member_match":{"scope_name":"large-star"}, + "ports":[{"id":"in","name":"in","direction":"input","data_type":"edge", + "native_match":{"debug_pattern":"scope$","index":0}}]}, + {"id":"transformer","name":"Transformer","member_key":"transformer","provenance":provenance, + "member_match":{"debug_pattern":"ApplyTransformer \\{ transformer: \"Star\""}} + ]), + ); + let stored = manager + .registry() + .unwrap() + .get( + &matched.processor.processor_id, + Some(&matched.processor.version), + ) + .unwrap(); + let stored = serde_json::to_value(&stored).unwrap(); + assert_eq!( + stored["definition"]["inspection"]["authoredGroups"][0]["memberIds"], + json!([]) + ); + assert_eq!( + stored["definition"]["inspection"]["authoredGroups"][0]["member_match"], + json!({"scope_name":"large-star"}) + ); + assert!(with_groups_fails(&manager, json!([{"id":"bad","name":"Bad","member_key":"bad","provenance":provenance,"member_match":{}}])).contains("member_match")); + assert!(with_groups_fails(&manager, json!([{"id":"bad","name":"Bad","member_key":"bad","provenance":provenance,"member_match":{"debug_pattern":"("}}])).contains("debug_pattern")); + let id = manager.create(matched.clone()).unwrap(); + manager.start(&id).unwrap(); + let nodes = vec![ + operates(0, &[0], "Dataflow", ""), + operates(1, &[0, 1], "Input", "Input { rel: \"R_source\" }"), + operates( + 2, + &[0, 2], + "large-star", + "ApplyTransformer { transformer: \"Star\" } scope", + ), + operates( + 3, + &[0, 2, 1], + "Map", + "ApplyTransformer { transformer: \"Star\" } map", + ), + operates( + 4, + &[0, 2, 2], + "Reduce", + "ApplyTransformer { transformer: \"Star\" }", + ), + operates( + 5, + &[0, 3], + "Probe", + "ApplyTransformer { transformer: \"Star\" }", + ), + json!({"stream":"timely","worker":0,"time_ns":9,"event":{"Channels":{"id":9,"scope_addr":[0],"source":[1,0],"target":[2,0]}}}), + ]; + append_capture(&f, &id, 1, &nodes); + let status = wait_until(&mut manager, &id, |s| { + s["inspection"]["state"] == "available" + }); + assert_eq!(status["inspection"]["mapping_error"], Value::Null); + let groups = &status["inspection"]["metadata"]["authoredGroups"]; + assert_eq!(groups[0]["memberIds"], json!(["0:2", "0:3", "0:4"])); + assert_eq!( + groups[0]["ports"][0]["native_ports"], + json!([{"operator_id":"0:2","index":0}]) + ); + assert_eq!(groups[1]["memberIds"], json!(["0:5"])); + append_capture(&f, &id, 1, &schedule(3, 1, 2)); + wait_until(&mut manager, &id, |_| manager_capture_exists(&f, &matched)); + let capture = manager + .capture_get(&matched.processor.processor_id, &matched.processor.version) + .unwrap(); + assert_eq!( + capture["metadata"]["authoredGroups"][0]["memberIds"], + json!(["0:2", "0:3", "0:4"]) + ); + assert_eq!(capture["mapping_error"], Value::Null); + manager.stop(&id).unwrap(); + // Zero matches are a mapping error naming the group; memberIds stay empty. + let missing = with_groups( + &manager, + json!([ + {"id":"absent","name":"Absent","member_key":"absent","provenance":provenance, + "member_match":{"scope_name":"small-star"}} + ]), + ); + let id = manager.create(missing.clone()).unwrap(); + manager.start(&id).unwrap(); + append_capture(&f, &id, 1, &nodes); + append_capture(&f, &id, 1, &schedule(3, 1, 2)); + let status = wait_until(&mut manager, &id, |s| { + s["inspection"]["state"] == "available" + }); + let error = status["inspection"]["mapping_error"].as_str().unwrap(); + assert!( + error.contains("absent") && error.contains("small-star"), + "{error}" + ); + assert_eq!( + status["inspection"]["metadata"]["authoredGroups"][0]["memberIds"], + json!([]) + ); + let capture = wait_until(&mut manager, &id, |_| manager_capture_exists(&f, &missing)); + assert_eq!(capture["state"], "running"); + let capture = manager + .capture_get(&missing.processor.processor_id, &missing.processor.version) + .unwrap(); + assert!(capture["mapping_error"] + .as_str() + .unwrap() + .contains("absent")); + manager.stop(&id).unwrap(); +} +fn with_groups(manager: &WorldManager, groups: Value) -> WorldDefinition { + let mut definition = echo_definition(); + definition["inspection"] = json!({"schema_version":1,"authoredGroups":groups}); + let record = manager + .register(RegisterRequest { + name: "Matched".into(), + description: String::new(), + library_id: None, + definition, + git_provenance: None, + }) + .unwrap(); + WorldDefinition { + label: "Matched".into(), + processor: ProcessorReference { + processor_id: record["processor_id"].as_str().unwrap().into(), + version: record["version"].as_str().unwrap().into(), + }, + purpose: "instance".into(), + scenarios: vec![], + } +} +fn with_groups_fails(manager: &WorldManager, groups: Value) -> String { + let mut definition = echo_definition(); + definition["inspection"] = json!({"schema_version":1,"authoredGroups":groups}); + manager + .register(RegisterRequest { + name: "Bad".into(), + description: String::new(), + library_id: None, + definition, + git_provenance: None, + }) + .unwrap_err() +} +fn manager_capture_exists(f: &Fixture, definition: &WorldDefinition) -> bool { + capture_file(f, definition).exists() +} diff --git a/tests/worlds_native.rs b/tests/worlds_native.rs new file mode 100644 index 0000000..ba7f5f1 --- /dev/null +++ b/tests/worlds_native.rs @@ -0,0 +1,338 @@ +#![cfg(unix)] +//! Real native acceptance, explicitly configured by the operator. +use ddlog_runtime::{ + registry::{ProcessorDefinition, ProcessorReference}, + worlds::{Scenario, TestRequest, WorldDefinition, WorldManager}, +}; +use serde_json::{json, Value}; +fn wait_until( + manager: &mut WorldManager, + id: &str, + what: &str, + done: impl Fn(&Value) -> bool, +) -> Value { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(120); + loop { + let status = manager.status(id).unwrap(); + if done(&status) { + return status; + } + assert!( + std::time::Instant::now() < deadline, + "timed out waiting for {what}: {status}" + ); + std::thread::sleep(std::time::Duration::from_millis(100)); + } +} +fn world(label: &str, record: &Value) -> WorldDefinition { + WorldDefinition { + label: label.into(), + processor: ProcessorReference { + processor_id: record["processor_id"].as_str().unwrap().into(), + version: record["version"].as_str().unwrap().into(), + }, + purpose: "instance".into(), + scenarios: vec![], + } +} +#[test] +#[ignore = "requires DDLOG_RUNTIME_NATIVE_BUILD and its operator-configured native toolchain"] +fn registered_worlds_expose_native_graph_and_metadata_then_stop() { + let root = std::env::temp_dir().join(format!("world-native-{}", std::process::id())); + std::fs::create_dir(&root).unwrap(); + let driver = + std::env::var_os("DDLOG_RUNTIME_NATIVE_BUILD").expect("Configure native build driver"); + let mut manager = + WorldManager::new(root.join("registry"), root.join("worlds"), driver.into()).unwrap(); + let definition:ProcessorDefinition=serde_json::from_value(json!({"rules":"reach(X,Y) :- edge(X,Y). reach(X,Z) :- reach(X,Y), edge(Y,Z).","schemas":{"edge":{"input":true,"fields":["int","int"]},"reach":{"input":false,"fields":["int","int"]}}})).unwrap(); + let first = manager + .registry() + .unwrap() + .create(definition.clone(), None) + .unwrap(); + let a = manager + .create(WorldDefinition { + label: "Native reachability".into(), + processor: ProcessorReference { + processor_id: first.processor_id.clone(), + version: first.version.clone(), + }, + purpose: "instance".into(), + scenarios: vec![], + }) + .unwrap(); + let started = manager.start(&a).unwrap(); + assert_eq!(started["state"], "running"); + // The capture is tailed asynchronously; `running` does not imply ingested. + let status = wait_until(&mut manager, &a, "native topology", |s| { + s["inspection"]["state"] == "available" + }); + assert!( + status["inspection"]["graph"]["nodes"] + .as_array() + .unwrap() + .len() + > 1 + ); + manager.execute(&a,"apply_changes",&json!({"changes":[{"op":"insert","predicate":"edge","values":[1,2]},{"op":"insert","predicate":"edge","values":[2,3]}]})).unwrap(); + let rows = manager + .execute(&a, "lemmalog_query", &json!({"predicate":"reach"})) + .unwrap(); + assert!(rows["rows"].as_str().unwrap().contains(".f0 = 1, .f1 = 3")); + let relations = manager.execute(&a, "relations", &json!({})).unwrap(); + assert_eq!(relations["revision"], 2); + assert_eq!( + relations["relations"], + json!([{"name":"edge","input":true,"fields":["int","int"],"count":2},{"name":"reach","input":false,"fields":["int","int"],"count":3}]) + ); + let page = manager + .execute(&a, "query_rows", &json!({"predicate":"reach","max_rows":2})) + .unwrap(); + assert_eq!(page["total"], 3); + assert_eq!(page["complete"], false); + assert_eq!(page["rows"].as_array().unwrap().len(), 2); + let rest = manager + .execute( + &a, + "query_rows", + &json!({"predicate":"reach","max_rows":2,"continuation":page["continuation"]}), + ) + .unwrap(); + assert_eq!(rest["complete"], true); + let mut all: Vec = page["rows"] + .as_array() + .unwrap() + .iter() + .chain(rest["rows"].as_array().unwrap()) + .cloned() + .collect(); + all.sort_by_key(|row| row.to_string()); + assert_eq!( + all, + json!([[1, 2], [1, 3], [2, 3]]).as_array().unwrap().clone() + ); + let source = manager.execute(&a, "program_source", &json!({})).unwrap(); + assert!(source["source"].as_str().unwrap().contains("R_reach")); + assert_eq!( + source["source_sha256"], + manager.status(&a).unwrap()["instance"]["source_sha256"] + ); + // Full-detail capture: the transaction above scheduled operators and sent + // messages; the tailer ingests them without making the world unavailable. + let active = wait_until(&mut manager, &a, "native activity", |s| { + let activity = &s["inspection"]["activity"]; + activity["complete"] == json!(true) + && activity["nodes"] + .as_object() + .unwrap() + .values() + .any(|n| n["schedule_count"].as_u64().unwrap_or(0) > 0) + && activity["channels"] + .as_object() + .unwrap() + .values() + .any(|c| c["records"].as_u64().unwrap_or(0) > 0) + }); + let activity = &active["inspection"]["activity"]; + assert_eq!(active["inspection"]["state"], "available"); + assert!(activity["totals"]["timely"].as_u64().unwrap() > 0); + assert!(activity["totals"]["progress"].as_u64().unwrap() > 0); + assert!(activity["totals"]["differential"].as_u64().unwrap() > 0); + assert_eq!(activity["rotations"], 0); + assert_eq!(activity["truncated_at_bytes"], Value::Null); + assert!(activity["last_event_ns"].as_u64().unwrap() > 0); + assert_eq!(activity["totals"]["unresolved_channels"], 0); + // The compiled topology was retained for the definition version. + let first_pin = (first.processor_id.clone(), first.version.clone()); + let capture = manager.capture_get(&first_pin.0, &first_pin.1).unwrap(); + assert_eq!(capture["world_id"], a); + assert_eq!(capture["generation"], 1); + assert_eq!( + capture["graph"]["nodes"], + active["inspection"]["graph"]["nodes"] + ); + assert_eq!(capture["unresolved_channels"], 0); + // Asynchronous scenario test against the same definition version. + let scenarios: Vec = serde_json::from_value(json!([ + {"name":"path","description":"","changes":[ + {"op":"insert","predicate":"edge","values":[1,2]}, + {"op":"insert","predicate":"edge","values":[2,3]}], + "expect":{"reach":[[1,2],[1,3],[2,3]]}}, + {"name":"cut","description":"","changes":[ + {"op":"delete","predicate":"edge","values":[2,3]}], + "expect":{"reach":[[1,2]]}} + ])) + .unwrap(); + manager + .scenarios_set(&first_pin.0, &first_pin.1, scenarios) + .unwrap(); + let test = manager + .test(TestRequest { + processor_id: first_pin.0.clone(), + version: first_pin.1.clone(), + scenarios: None, + keep_world: false, + }) + .unwrap(); + assert_eq!(test["state"], "starting"); + assert_eq!(test["test"]["phase"], "building"); + let test_id = test["id"].as_str().unwrap().to_string(); + let finished = wait_until(&mut manager, &test_id, "test completion", |s| { + s["state"] != "starting" + }); + assert_eq!(finished["state"], "stopped", "{finished}"); + assert_eq!(finished["test"]["phase"], "done"); + assert_eq!(finished["test"]["passed"], true, "{}", finished["test"]); + assert_eq!(finished["test"]["results"][0]["revision"], 2); + assert_eq!(finished["test"]["results"][1]["revision"], 3); + assert_eq!(finished["resources"]["pid"], Value::Null); + let edge = &status["inspection"]["graph"]["edges"][0]; + let mut with_metadata = serde_json::to_value(definition).unwrap(); + with_metadata["inspection"] = json!({"schema_version":1,"authoredGroups":[{"id":"source-boundary","name":"Observed source boundary","member_key":"source","provenance":{"repository":"native-acceptance","revision":"fixture","source":null},"memberIds":[edge["source"]],"ports":[{"id":"output","name":"output","direction":"output","data_type":"native_stream","native_ports":[{"operator_id":edge["source"],"index":edge["source_port"]}]}]}]}); + let second = manager + .registry() + .unwrap() + .create(serde_json::from_value(with_metadata).unwrap(), None) + .unwrap(); + let b = manager + .create(WorldDefinition { + label: "Native mapped world".into(), + processor: ProcessorReference { + processor_id: second.processor_id, + version: second.version, + }, + purpose: "instance".into(), + scenarios: vec![], + }) + .unwrap(); + let mapped = manager.start(&b).unwrap(); + assert_ne!(mapped["resources"]["pid"], status["resources"]["pid"]); + let mapped = wait_until(&mut manager, &b, "mapped topology", |s| { + s["inspection"]["state"] == "available" + }); + assert_eq!( + mapped["inspection"]["mapping_error"], + serde_json::Value::Null + ); + assert_eq!( + mapped["inspection"]["metadata"]["authoredGroups"] + .as_array() + .unwrap() + .len(), + 1 + ); + manager.stop(&a).unwrap(); + assert_eq!(manager.status(&b).unwrap()["state"], "running"); + manager.stop(&b).unwrap(); + // Star program: build-time phase regions resolve match-based authored groups. + let star = manager + .register(ddlog_runtime::worlds::RegisterRequest { + name: "Connected components".into(), + description: String::new(), + library_id: None, + definition: json!({ + "rules":"", + "schemas":{ + "vertices":{"input":true,"fields":["int"]}, + "edges":{"input":true,"fields":["int","int"]}, + "labels":{"input":false,"fields":["int","int"]} + }, + "operators":[{"type":"large_small_star","vertices":"vertices","edges":"edges","output":"labels"}], + "interface":{"inputs":["vertices","edges"],"outputs":["labels"]}, + "inspection":{"schema_version":1,"authoredGroups":[ + {"id":"large-star","name":"Large star","member_key":"large_small_star/large","provenance":{"repository":"native-acceptance","revision":"fixture","source":null},"member_match":{"scope_name":"large-star"}}, + {"id":"small-star","name":"Small star","member_key":"large_small_star/small","provenance":{"repository":"native-acceptance","revision":"fixture","source":null},"member_match":{"scope_name":"small-star"}}, + {"id":"minimum-label","name":"Minimum label","member_key":"large_small_star/minimum","provenance":{"repository":"native-acceptance","revision":"fixture","source":null},"member_match":{"scope_name":"minimum-label"}} + ]} + }), + git_provenance: None, + }) + .unwrap(); + let c = manager + .create(world("Native connected components", &star)) + .unwrap(); + let started = manager.start(&c).unwrap(); + assert_eq!(started["state"], "running"); + let marker = root + .join("worlds") + .join(&c) + .join("1") + .join("build-1") + .join("program_ddlog") + .join("observer-install.json"); + let marker: Value = serde_json::from_slice(&std::fs::read(marker).unwrap()).unwrap(); + assert_eq!(marker["observer_hook"], true); + assert_eq!( + marker["star_phases"], true, + "star phases installed at build time" + ); + manager + .execute( + &c, + "apply_changes", + &json!({"changes":[ + {"op":"insert","predicate":"vertices","values":[1]}, + {"op":"insert","predicate":"vertices","values":[2]}, + {"op":"insert","predicate":"vertices","values":[3]}, + {"op":"insert","predicate":"edges","values":[1,2]}]}), + ) + .unwrap(); + let scoped = wait_until(&mut manager, &c, "star topology", |s| { + s["inspection"]["state"] == "available" + && s["inspection"]["unresolved_channels"] == 0 + && s["inspection"]["activity"]["complete"] == json!(true) + }); + assert_eq!( + scoped["inspection"]["mapping_error"], + Value::Null, + "{}", + scoped["inspection"]["mapping_error"] + ); + let nodes = scoped["inspection"]["graph"]["nodes"].as_array().unwrap(); + for phase in ["large-star", "small-star", "minimum-label"] { + assert!( + nodes.iter().any(|n| n["name"] == phase), + "native scope {phase} present" + ); + } + let groups = scoped["inspection"]["metadata"]["authoredGroups"] + .as_array() + .unwrap(); + assert_eq!(groups.len(), 3); + for group in groups { + let members = group["memberIds"].as_array().unwrap(); + assert!(members.len() > 1, "{} resolved to {members:?}", group["id"]); + } + let labels = manager + .execute(&c, "query_rows", &json!({"predicate":"labels"})) + .unwrap(); + let mut rows: Vec = labels["rows"].as_array().unwrap().clone(); + rows.sort_by_key(|row| row.to_string()); + assert_eq!( + rows, + json!([[1, 1], [2, 1], [3, 3]]).as_array().unwrap().clone() + ); + let capture = manager + .capture_get( + star["processor_id"].as_str().unwrap(), + star["version"].as_str().unwrap(), + ) + .unwrap(); + assert_eq!( + capture["metadata"]["authoredGroups"][0]["memberIds"], + groups[0]["memberIds"] + ); + manager.stop(&c).unwrap(); + drop(manager); + let mut recovered = WorldManager::new( + root.join("registry"), + root.join("worlds"), + std::env::var_os("DDLOG_RUNTIME_NATIVE_BUILD") + .unwrap() + .into(), + ) + .unwrap(); + assert_eq!(recovered.status(&a).unwrap()["state"], "stopped"); + drop(recovered); + std::fs::remove_dir_all(root).unwrap(); +} diff --git a/tests/worlds_stdio.rs b/tests/worlds_stdio.rs new file mode 100644 index 0000000..f2d9413 --- /dev/null +++ b/tests/worlds_stdio.rs @@ -0,0 +1,247 @@ +#![cfg(unix)] +//! Drives the `ddlog-worlds` binary over its newline-delimited stdio protocol +//! with the simulated build driver; no native DDlog evaluation is claimed. +use serde_json::{json, Value}; +use std::io::{BufRead, BufReader, Write}; +use std::os::unix::fs::PermissionsExt; +use std::path::PathBuf; +use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio}; + +struct Owner { + root: PathBuf, + child: Child, + input: Option, + output: BufReader, +} +impl Owner { + fn spawn() -> Self { + let root = std::env::temp_dir().join(format!( + "worlds-stdio-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir(&root).unwrap(); + let template = + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/memory_fake_runtime.py"); + std::fs::write(root.join("build.py"), format!("#!/usr/bin/env python3\nimport sys\nfrom pathlib import Path\nsource=Path({}).read_text().replace('__CONTROL__',{})\nPath(sys.argv[2]).write_text(source)\nPath(sys.argv[2]).chmod(0o700)\n", json!(template), json!(serde_json::to_string(&root).unwrap()))).unwrap(); + std::fs::set_permissions( + root.join("build.py"), + std::fs::Permissions::from_mode(0o700), + ) + .unwrap(); + let mut child = Command::new(env!("CARGO_BIN_EXE_ddlog-worlds")) + .arg(root.join("registry")) + .arg(root.join("worlds")) + .arg(root.join("build.py")) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()) + .spawn() + .unwrap(); + Self { + input: child.stdin.take(), + output: BufReader::new(child.stdout.take().unwrap()), + child, + root, + } + } + fn raw(&mut self, line: &str) -> Value { + let input = self.input.as_mut().unwrap(); + writeln!(input, "{line}").unwrap(); + input.flush().unwrap(); + let mut reply = String::new(); + assert!( + self.output.read_line(&mut reply).unwrap() > 0, + "owner closed its stdout" + ); + serde_json::from_str(&reply).unwrap() + } + fn call(&mut self, operation: &str, args: Value) -> Value { + let reply = self.raw(&json!({"operation":operation,"args":args}).to_string()); + assert_eq!(reply["ok"], true, "{operation} failed: {reply}"); + reply["result"].clone() + } + fn fail(&mut self, operation: &str, args: Value) -> String { + let reply = self.raw(&json!({"operation":operation,"args":args}).to_string()); + assert_eq!( + reply["ok"], false, + "{operation} unexpectedly succeeded: {reply}" + ); + reply["error"].as_str().unwrap().to_string() + } + fn wait(&mut self, id: &str) -> Value { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(20); + loop { + let status = self.call("status", json!({"id":id})); + if status["state"] != "starting" { + return status; + } + assert!(std::time::Instant::now() < deadline, "{status}"); + std::thread::sleep(std::time::Duration::from_millis(10)); + } + } +} +impl Drop for Owner { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + let _ = std::fs::remove_dir_all(&self.root); + } +} + +#[test] +fn stdio_protocol_covers_every_control_plane_verb() { + let mut owner = Owner::spawn(); + let info = owner.call("runtime_info", json!({})); + assert_eq!(info["schema_version"], 1); + assert_eq!(info["crate_version"], env!("CARGO_PKG_VERSION")); + assert!(info["dirty"].is_boolean()); + assert_eq!(owner.raw("not json")["ok"], false); + assert!(owner + .fail("nonsense", json!({})) + .contains("Unknown control-plane operation")); + assert_eq!(owner.call("inventory", json!({}))["worlds"], json!([])); + assert_eq!(owner.call("inventory", Value::Null)["worlds"], json!([])); + let libraries = owner.call("libraries", json!({})); + assert_eq!(libraries["libraries"][0]["id"], "unassigned"); + let definition = json!({"rules":"echo(N,S) :- source(N,S).","schemas":{"source":{"input":true,"fields":["int","string"]},"echo":{"input":false,"fields":["int","string"]}}}); + assert!(owner + .fail("register", json!({"definition":definition})) + .contains("name")); + let library = owner.call( + "library_create", + json!({"name":"Examples","repository":"https://example.invalid/e","revision":"main"}), + ); + let record = owner.call("register", json!({"name":"Echo","description":"copies","library_id":library["id"],"definition":definition})); + assert_eq!(record["name"], "Echo"); + assert_eq!(record["library_id"], library["id"]); + let pin = json!({"processor_id":record["processor_id"],"version":record["version"]}); + let definitions = owner.call("definitions", json!({})); + assert_eq!(definitions["processors"][0]["name"], "Echo"); + assert_eq!(definitions["processors"][0]["kind"], "program"); + assert_eq!(definitions["processors"][0]["current"], true); + assert_eq!( + owner.call("definition", pin.clone())["processor_id"], + record["processor_id"] + ); + let libraries = owner.call("libraries", json!({})); + let filed = libraries["libraries"] + .as_array() + .unwrap() + .iter() + .find(|l| l["id"] == library["id"]) + .unwrap(); + assert_eq!(filed["processors"][0]["name"], "Echo"); + let fixture = + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/upstream/registry"); + let imported = owner.call( + "import", + json!({"source_registry":fixture,"library_id":library["id"],"dry_run":true}), + ); + assert_eq!(imported["imported"].as_array().unwrap().len(), 5); + assert_eq!(imported["errors"], json!([])); + assert_eq!( + owner.call("definitions", json!({}))["processors"] + .as_array() + .unwrap() + .len(), + 1 + ); + let imported = owner.call("import", json!({"source_registry":fixture})); + assert_eq!(imported["imported"].as_array().unwrap().len(), 5); + assert_eq!( + owner.call("definitions", json!({}))["processors"] + .as_array() + .unwrap() + .len(), + 6 + ); + let created = owner.call("create", json!({"label":"Echo world","processor":pin})); + let id = created["id"].as_str().unwrap().to_string(); + assert_eq!(created["state"], "created"); + assert_eq!(created["persistence"]["status"], "not_configured"); + assert_eq!(owner.call("start", json!({"id":id}))["state"], "starting"); + let running = owner.wait(&id); + assert_eq!(running["state"], "running"); + assert_eq!(running["instance"]["revision"], 1); + assert!(running["started_at_unix_ms"].is_number()); + let applied = owner.call("execute", json!({"id":id,"operation":"apply_changes","args":{"changes":[{"op":"insert","predicate":"source","values":[7,"seven"]}]}})); + assert_eq!(applied["revision"], 2); + let relations = owner.call( + "execute", + json!({"id":id,"operation":"relations","args":{}}), + ); + assert_eq!( + relations["relations"][1], + json!({"name":"echo","input":false,"fields":["int","string"],"count":1}) + ); + let rows = owner.call( + "execute", + json!({"id":id,"operation":"query_rows","args":{"predicate":"echo"}}), + ); + assert_eq!(rows["rows"], json!([[7, "seven"]])); + assert_eq!(rows["complete"], true); + let source = owner.call( + "execute", + json!({"id":id,"operation":"program_source","args":{}}), + ); + assert!(source["source"].as_str().unwrap().contains("R_echo")); + assert!(owner + .fail( + "execute", + json!({"id":id,"operation":"processor_install","args":{}}) + ) + .contains("registry")); + let summary = owner.call( + "inventory", + json!({"summary":true,"processor_id":pin["processor_id"]}), + ); + assert_eq!(summary["worlds"].as_array().unwrap().len(), 1); + assert!(summary["worlds"][0].get("inspection").is_none()); + assert!(summary["worlds"][0].get("instance").is_none()); + assert_eq!(owner.call("stop", json!({"id":id}))["state"], "stopped"); + let scenarios = json!([{"name":"one","description":"","changes":[{"op":"insert","predicate":"source","values":[1,"a"]}],"expect":{"echo":[[1,"a"]]}}]); + assert!(owner.fail("scenarios_set", json!({"processor_id":pin["processor_id"],"version":pin["version"],"scenarios":[{"name":"bad","changes":[{"op":"insert","predicate":"echo","values":[1,"a"]}],"expect":{}}]})).contains("public input")); + assert_eq!(owner.call("scenarios_set", json!({"processor_id":pin["processor_id"],"version":pin["version"],"scenarios":scenarios}))["scenarios"], scenarios); + assert_eq!( + owner.call("scenarios_get", pin.clone())["scenarios"], + scenarios + ); + assert_eq!( + owner.call("capture_get", pin.clone())["state"], + "missing", + "the simulated runtime writes no native capture" + ); + assert!(owner + .fail( + "capture_get", + json!({"processor_id":"processor_missing","version":pin["version"]}) + ) + .contains("processor")); + let test = owner.call( + "test", + json!({"processor_id":pin["processor_id"],"version":pin["version"]}), + ); + assert_eq!(test["definition"]["purpose"], "test"); + assert_eq!(test["definition"]["label"], "Test · Echo"); + assert_eq!(test["test"]["phase"], "building"); + let test_id = test["id"].as_str().unwrap().to_string(); + let finished = owner.wait(&test_id); + assert_eq!(finished["state"], "stopped"); + assert_eq!(finished["test"]["phase"], "done"); + assert_eq!(finished["test"]["passed"], true); + assert_eq!(finished["test"]["results"][0]["revision"], 2); + assert_eq!( + owner.call("inventory", json!({})).as_object().unwrap()["worlds"] + .as_array() + .unwrap() + .len(), + 2 + ); + drop(owner.input.take()); + let status = owner.child.wait().unwrap(); + assert!(status.success()); +}