diff --git a/.github/workflows/joule-moq-room.yml b/.github/workflows/joule-moq-room.yml index d5417bc75..eaef01d1f 100644 --- a/.github/workflows/joule-moq-room.yml +++ b/.github/workflows/joule-moq-room.yml @@ -28,12 +28,13 @@ name: joule-moq-room room service # past its removal, every record verified offline; and a call (tests/call_room.rs) rung, accepted # and carrying audio and video through the relay, and another missed. tests/openapi_contract.rs # holds openapi.json to the service: every operationId is the operation's name in the toolchain's -# catalogue (noun.verb, by wai::ops's rule), the routes the routers match are exactly its +# catalogue (wai/wai-rs/src/catalogue.rs, at the route it gives) and every operation the +# catalogue routes to the room service is described, the routes the routers match are exactly its # operations, each status it lists is one its handlers return and each status they return is # listed, and the service, run in each of its modes, answers every operation with a status it # lists. tests/mcp_dev_room.rs builds `wai` with the feature `mcp` (a target directory of its own) # and drives `wai mcp --toolset core,dev` with an MCP client: the tool room_grant and POST /grant -# named alike (room.grant), a development room (`joule-moq-room dev`) started on loopback, grants +# named alike (room.grant), a development room (`wai-room dev`) started on loopback, grants # for two test principals, each joining and publishing, the session verified as live-rooms §9 # verifies a room, a changed record refused, and a grant from a service off the machine refused. # diff --git a/.github/workflows/wai-catalogue.yml b/.github/workflows/wai-catalogue.yml new file mode 100644 index 000000000..ecda70c91 --- /dev/null +++ b/.github/workflows/wai-catalogue.yml @@ -0,0 +1,136 @@ +name: WAI operation catalogue + +# WAI's toolchain has one catalogue of operations, `noun.verb` (wai/wai-rs/src/catalogue.rs), +# and every door names them by it. This workflow fails on drift: +# +# - the export, wai/catalogue/ops.json, is the table (`wai ops export --check`); +# - the table keeps its naming rules (the library's catalogue tests); +# - the command line reaches operations through the catalogue alone, and every operation +# keeps the agent rules: its help, the exit codes, the JSON envelope, --dry-run on every +# operation that is not read-only, --yes (tests/cli_ops.rs, and the binary's own tests); +# - the other doors name operations as the catalogue does: MCP tool names, the services' +# OpenAPI operationIds, the C header, and the Swift and Kotlin sinks +# (scripts/check-wai-ops-doors.py, with its selftest). +# +# Push filters match the push's delta, so every path a door or a test reads is listed. + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +on: + push: + branches: [main] + paths: + - 'wai/wai-rs/**' + - 'wai/catalogue/**' + - 'wai/examples/**' + - 'wai/http-conformance/**' + - 'wai/score-conformance/**' + - 'wai/live-conformance/fleet/**' + - 'wai/REGISTRY.md' + - 'wai/LICENSE' + - 'wai/wai-quantum/**' + - 'wai/wai-ops/**' + - 'wai/wai-app/**' + - 'wai/wai-sink-swift/**' + - 'wai/wai-sink-android/**' + - 'joule-code/joule-code-rs/crates/*/openapi.json' + - 'scripts/check-wai-ops-doors.py' + - 'scripts/check-wai-ops-doors-selftest.sh' + - 'rust-toolchain.toml' + - '.github/workflows/wai-catalogue.yml' + pull_request: + paths: + - 'wai/wai-rs/**' + - 'wai/catalogue/**' + - 'wai/examples/**' + - 'wai/http-conformance/**' + - 'wai/score-conformance/**' + - 'wai/live-conformance/fleet/**' + - 'wai/REGISTRY.md' + - 'wai/LICENSE' + - 'wai/wai-quantum/**' + - 'wai/wai-ops/**' + - 'wai/wai-app/**' + - 'wai/wai-sink-swift/**' + - 'wai/wai-sink-android/**' + - 'joule-code/joule-code-rs/crates/*/openapi.json' + - 'scripts/check-wai-ops-doors.py' + - 'scripts/check-wai-ops-doors-selftest.sh' + - 'rust-toolchain.toml' + - '.github/workflows/wai-catalogue.yml' + +jobs: + # The path filters gate the trigger; this job gates the work from the pull request's own + # diff (scripts/pr-touches-paths.py, reading the list above), as wai-pack.yml explains. + relevant: + name: does this change touch it + runs-on: linux-arm64 + outputs: + run: ${{ steps.gate.outputs.run }} + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + - id: gate + run: | + set -euo pipefail + r=$(python3 scripts/pr-touches-paths.py .github/workflows/wai-catalogue.yml \ + "${{ github.event.pull_request.base.sha }}" \ + "${{ github.event.pull_request.head.sha }}") + echo "this change touches the operation catalogue: $r" + echo "run=$r" >> "$GITHUB_OUTPUT" + + catalogue: + name: the catalogue and every door · linux + needs: relevant + if: needs.relevant.outputs.run == 'true' + runs-on: linux + timeout-minutes: 60 + steps: + - uses: actions/checkout@v5 + - name: Install Rust 1.98.0 + shell: bash + run: | + export PATH="$HOME/.cargo/bin:$PATH" + command -v rustup >/dev/null 2>&1 || \ + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain 1.98.0 + rustup toolchain install 1.98.0 --profile minimal + rustup default 1.98.0 + SUDO=""; [ "$(id -u)" -ne 0 ] && SUDO="sudo" + command -v python3 >/dev/null || { $SUDO apt-get update -qq; $SUDO apt-get install -y -qq python3; } + echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" + # The export other tools and this workflow's door check read is the table, byte for + # byte; the message says how to regenerate it. + - name: The catalogue's export is up to date + working-directory: wai/wai-rs + run: cargo run -q --locked --no-default-features --bin wai -- ops export --check ../catalogue/ops.json + - name: Every door names its operations as the catalogue does + shell: bash + run: | + set -euo pipefail + bash scripts/check-wai-ops-doors-selftest.sh + python3 -I scripts/check-wai-ops-doors.py + # The library's catalogue tests: the naming rules, each broken in a copy of the table + # and refused. The binary's own tests: every implementation names an operation of the + # catalogue and every operation with a command line has one; help is the catalogue's. + # tests/cli_ops.rs: wai ops, help, the exit codes, the envelope, --dry-run and --yes (and + # --confirm, its deprecated spelling), + # first in the archive's build (quickstart), then with every command-line feature that + # needs no system library, so --dry-run is held on generate, score and stage too, and with + # Create's (app), so it is held on wai app export. + - name: The catalogue's rules, and the command line through it + shell: bash + working-directory: wai/wai-rs + run: | + set -euo pipefail + ran() { grep -qE '^test result: ok\. [1-9][0-9]* passed; 0 failed' "$1" || { echo "::error::no test ran in $1"; exit 1; }; } + cargo test --locked --no-default-features --lib catalogue 2>&1 | tee "$RUNNER_TEMP/lib.log" + ran "$RUNNER_TEMP/lib.log" + cargo test --locked --no-default-features --features quickstart --bin wai --test cli_ops 2>&1 | tee "$RUNNER_TEMP/cli.log" + ran "$RUNNER_TEMP/cli.log" + cargo test --locked --no-default-features --features quickstart,generate,score,staged,video_int,audio_int,splat_int --test cli_ops 2>&1 | tee "$RUNNER_TEMP/cli-full.log" + ran "$RUNNER_TEMP/cli-full.log" + cargo test --locked --no-default-features --features quickstart,app --test cli_ops 2>&1 | tee "$RUNNER_TEMP/cli-app.log" + ran "$RUNNER_TEMP/cli-app.log" diff --git a/.github/workflows/wai-conformance.yml b/.github/workflows/wai-conformance.yml index c3ec86273..255a565cf 100644 --- a/.github/workflows/wai-conformance.yml +++ b/.github/workflows/wai-conformance.yml @@ -636,16 +636,19 @@ jobs: # and every tool. receipt_verify agrees vector by vector with jwp-conformance (group and # object receipts), claim-conformance (each claim with the context the tool takes), # live-conformance/rooms and live-conformance/audit, and a receipt changed by one character - # is refused; package_build writes only inside the project directory and never through a + # is refused; envelope_wrap writes only inside the project directory and never through a # link; conformance_run runs live_rooms_verify.py; --read-only offers no tool that writes, # serves or asks for a grant; the dev tools refuse any service off this machine before # connecting, and any target but local (the room itself, joined, published to and verified # through the tools, is joule-moq-room's tests/mcp_dev_room.rs). `--bin wai` runs the - # binary's unit tests: every tool's operation named by the rule, the loopback guard and - # setup-agent's merges and diff; `--lib ops::` the rule itself. tests/cli_setup_agent.rs - # runs `wai setup-agent` in scratch projects as an agent does (no terminal, the JSON - # envelope out): a fresh project, a second run that changes nothing, a CLAUDE.md made only - # where one is, a file that exists changed only with --confirm (exit 4 and the proposed + # binary's unit tests: the tool table held to the operation catalogue both ways (every tool + # an operation the catalogue says wai mcp serves, in its toolset, every such operation a + # tool, a tool of MCP alone taking the catalogue's input), the loopback guard and + # setup-agent's merges and diff; `--lib catalogue::` the catalogue's rules (and + # wai-catalogue.yml the rest). tests/cli_setup_agent.rs runs `wai setup-agent` in scratch + # projects as an agent does (no terminal, --json, the JSON envelope out): a fresh project, a + # second run that changes nothing, a CLAUDE.md made only where one is, a file that exists + # changed only with --yes (exit 4 and the proposed # changes otherwise), a dry run, --toolset registered, a JSON file with comments left # alone, nothing written through a link out of the project, and the table's exit statuses. # Cargo.toml declares cli_mcp with required-features = ["mcp"]. @@ -653,7 +656,7 @@ jobs: working-directory: wai/wai-rs run: | cargo test --no-default-features --features mcp --bin wai --test cli_mcp --test cli_setup_agent - cargo test --no-default-features --lib ops:: + cargo test --no-default-features --lib catalogue:: # Apps made with Create, through `wai` built with the feature `app` (wai/wai-app linked # in). tests/cli_app.rs: `wai create --from app.oui` writes an ordinary project, its # wai.json laid out as a template's and naming the program and the room `wai dev` runs, diff --git a/.github/workflows/wai-examples.yml b/.github/workflows/wai-examples.yml index e2ba01ce1..b7ea6a72b 100644 --- a/.github/workflows/wai-examples.yml +++ b/.github/workflows/wai-examples.yml @@ -93,8 +93,8 @@ jobs: b="$RUNNER_TEMP/bin"; mkdir -p "$b" out() { (cd "$1" && cargo metadata --format-version 1 --no-deps --locked | python3 -c 'import json,sys; print(json.load(sys.stdin)["target_directory"])'); } (cd wai/wai-rs && cargo build --locked --release --no-default-features --features quickstart --bin wai) && cp "$(out wai/wai-rs)/release/wai" "$b/" - (cd joule-code/joule-code-rs && cargo build --locked --release -p joule-moq-relay --bin joule-moq-relay) && cp "$(out joule-code/joule-code-rs)/release/joule-moq-relay" "$b/" - (cd joule-code/joule-code-rs/crates/joule-moq-room && cargo build --locked --release --bin joule-moq-room) && cp "$(out joule-code/joule-code-rs/crates/joule-moq-room)/release/joule-moq-room" "$b/" + (cd joule-code/joule-code-rs && cargo build --locked --release -p joule-moq-relay --bin wai-relay) && cp "$(out joule-code/joule-code-rs)/release/wai-relay" "$b/" + (cd joule-code/joule-code-rs/crates/joule-moq-room && cargo build --locked --release --bin wai-room) && cp "$(out joule-code/joule-code-rs/crates/joule-moq-room)/release/wai-room" "$b/" (cd joule-code/joule-code-rs/crates/joule-moq-remote && cargo build --locked --release --bin wai-remote-host) && cp "$(out joule-code/joule-code-rs/crates/joule-moq-remote)/release/wai-remote-host" "$b/" (cd wai/wai-pack && cargo build --locked --release --bin wai-pack) && cp "$(out wai/wai-pack)/release/wai-pack" "$b/" echo "$b" >> "$GITHUB_PATH" @@ -142,7 +142,7 @@ jobs: - name: The examples in the browser shell: bash env: - BROWSER_BIN: /Applications/Google Chrome.app/Contents/MacOS/Google Chrome + BROWSER_BIN: /Applications/Google Chrome.app/Contents/MacOS/Google Chrome # allow-name: the macOS runner's browser executable, a file path run: | set -euo pipefail test -x "$BROWSER_BIN" || { echo "::error::this runner has no browser at $BROWSER_BIN"; exit 1; } diff --git a/.github/workflows/wai-release.yml b/.github/workflows/wai-release.yml index 4ef5da847..e01657a7a 100644 --- a/.github/workflows/wai-release.yml +++ b/.github/workflows/wai-release.yml @@ -3,7 +3,7 @@ name: wai command line release # On a tag wai-v (the version wai/wai-rs/Cargo.toml states), builds the quickstart # archive (wai/quickstart/build.sh) on each runner and attaches it to the forge release of that # tag, each archive with its SHA-256. An archive holds bin/ (wai with the feature quickstart, -# joule-moq-relay, joule-moq-room, wai-remote-host, wai-pack) and share/wai/web/ (the room +# wai-relay, wai-room, wai-remote-host, wai-pack) and share/wai/web/ (the room # client, built once, on the macOS host, which has the WebAssembly toolchain): # macOS aarch64-apple-darwin and x86_64-apple-darwin; # Linux x86_64- and aarch64-unknown-linux-musl, static, so any distribution runs it; @@ -189,8 +189,8 @@ jobs: New-Item -ItemType Directory -Force -Path (Join-Path $stage 'bin'), (Join-Path $stage 'share\wai'), $dist | Out-Null $builds = @( @{ dir = 'wai\wai-rs'; bin = 'wai'; args = @('--no-default-features', '--features', 'quickstart', '--bin', 'wai') }, - @{ dir = 'joule-code\joule-code-rs'; bin = 'joule-moq-relay'; args = @('-p', 'joule-moq-relay', '--bin', 'joule-moq-relay') }, - @{ dir = 'joule-code\joule-code-rs\crates\joule-moq-room'; bin = 'joule-moq-room'; args = @('--bin', 'joule-moq-room') }, + @{ dir = 'joule-code\joule-code-rs'; bin = 'wai-relay'; args = @('-p', 'joule-moq-relay', '--bin', 'wai-relay') }, + @{ dir = 'joule-code\joule-code-rs\crates\joule-moq-room'; bin = 'wai-room'; args = @('--bin', 'wai-room') }, @{ dir = 'joule-code\joule-code-rs\crates\joule-moq-remote'; bin = 'wai-remote-host'; args = @('--bin', 'wai-remote-host') }, @{ dir = 'wai\wai-pack'; bin = 'wai-pack'; args = @('--bin', 'wai-pack') } ) diff --git a/joule-code/joule-code-rs/crates/joule-moq-ingest/Cargo.toml b/joule-code/joule-code-rs/crates/joule-moq-ingest/Cargo.toml index 2986cdb87..220d19dd4 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-ingest/Cargo.toml +++ b/joule-code/joule-code-rs/crates/joule-moq-ingest/Cargo.toml @@ -10,8 +10,9 @@ repository = "https://github.com/transaction-science/open-standards" [lib] path = "src/lib.rs" +# The binary is `wai-ingest`; the crate keeps its name (WAI toolchain decision 11). [[bin]] -name = "joule-moq-ingest" +name = "wai-ingest" path = "src/main.rs" # The link records' conformance corpus, wai/live-conformance/links/. diff --git a/joule-code/joule-code-rs/crates/joule-moq-ingest/README.md b/joule-code/joule-code-rs/crates/joule-moq-ingest/README.md index 494439cc5..4ec04d1da 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-ingest/README.md +++ b/joule-code/joule-code-rs/crates/joule-moq-ingest/README.md @@ -13,8 +13,8 @@ cues. It also takes contribution over **SRT** and **RIST** into the same tracks recovery and latency. The mappings it implements are WAI's `extensions/live-ingest.md`, `extensions/cue-injection.md` and `extensions/live-contribution.md`. -It is a library (`GatewayServer`, `Gateway`) and a binary, `joule-moq-ingest`. The relay -is `joule-moq-relay`; the MOQT codec is `joule-moq`'s, `draft-ietf-moq-transport-22`. +It is a library (`GatewayServer`, `Gateway`) and a binary, `wai-ingest` (the crate keeps its +name). The relay is `joule-moq-relay` (binary `wai-relay`); the MOQT codec is `joule-moq`'s, `draft-ietf-moq-transport-22`. ## Resources @@ -187,7 +187,7 @@ Each is a field of `[limits]`, `[groups]`, `[timeline]` or `[whep]` in the confi signing key are files, and a room is its public, signed descriptor. ```text -joule-moq-ingest --config gateway.toml # JOULE_MOQ_INGEST_LOG sets the log level +wai-ingest --config gateway.toml # JOULE_MOQ_INGEST_LOG sets the log level ``` Every listener is loopback by default: put a TLS front before the HTTP resources (RFC 9725 diff --git a/joule-code/joule-code-rs/crates/joule-moq-ingest/src/main.rs b/joule-code/joule-code-rs/crates/joule-moq-ingest/src/main.rs index b156bab46..9a50ed297 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-ingest/src/main.rs +++ b/joule-code/joule-code-rs/crates/joule-moq-ingest/src/main.rs @@ -1,7 +1,7 @@ -//! `joule-moq-ingest`: run the gateway. +//! `wai-ingest` (crate `joule-moq-ingest`): run the gateway. //! //! ```text -//! joule-moq-ingest --config gateway.toml +//! wai-ingest --config gateway.toml //! ``` //! //! It logs where it listens and the key it signs receipts and session records with. On @@ -19,19 +19,19 @@ async fn main() -> ExitCode { ["--config", path] => match std::fs::read_to_string(path).map_err(|e| e.to_string()).and_then(|s| GatewayConfig::from_toml(&s)) { Ok(c) => c, Err(e) => { - eprintln!("joule-moq-ingest: {path}: {e}"); + eprintln!("wai-ingest: {path}: {e}"); return ExitCode::FAILURE; } }, _ => { - eprintln!("usage: joule-moq-ingest --config "); + eprintln!("usage: wai-ingest --config "); return ExitCode::from(2); } }; let server = match GatewayServer::start(cfg).await { Ok(s) => s, Err(e) => { - eprintln!("joule-moq-ingest: {e}"); + eprintln!("wai-ingest: {e}"); return ExitCode::FAILURE; } }; diff --git a/joule-code/joule-code-rs/crates/joule-moq-interop/scripts/clients.sh b/joule-code/joule-code-rs/crates/joule-moq-interop/scripts/clients.sh index e0f5bf73d..65fe9f435 100755 --- a/joule-code/joule-code-rs/crates/joule-moq-interop/scripts/clients.sh +++ b/joule-code/joule-code-rs/crates/joule-moq-interop/scripts/clients.sh @@ -11,7 +11,7 @@ T=$1 OUT=$2 shift 2 mkdir -p "$OUT" -JOULE_MOQ_RELAY_LOG=debug "$T/debug/joule-moq-relay" --dev > "$OUT/relay.log" 2>&1 & +JOULE_MOQ_RELAY_LOG=debug "$T/debug/wai-relay" --dev > "$OUT/relay.log" 2>&1 & R=$! # The relay is gone before this script is: a next run binds the same port. trap 'kill $R 2>/dev/null; wait $R 2>/dev/null' EXIT diff --git a/joule-code/joule-code-rs/crates/joule-moq-interop/scripts/local.sh b/joule-code/joule-code-rs/crates/joule-moq-interop/scripts/local.sh index e4379d22c..66516beb5 100755 --- a/joule-code/joule-code-rs/crates/joule-moq-interop/scripts/local.sh +++ b/joule-code/joule-code-rs/crates/joule-moq-interop/scripts/local.sh @@ -4,7 +4,7 @@ # scripts/local.sh set -u T=${1:-target} -"$T/debug/joule-moq-relay" --dev > /tmp/joule-moq-relay-local.log 2>&1 & +"$T/debug/wai-relay" --dev > /tmp/wai-relay-local.log 2>&1 & R=$! # The relay is gone before this script is: a next run binds the same port. trap 'kill $R 2>/dev/null; wait $R 2>/dev/null' EXIT diff --git a/joule-code/joule-code-rs/crates/joule-moq-relay/Cargo.toml b/joule-code/joule-code-rs/crates/joule-moq-relay/Cargo.toml index 863148244..4c8c21442 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-relay/Cargo.toml +++ b/joule-code/joule-code-rs/crates/joule-moq-relay/Cargo.toml @@ -10,8 +10,9 @@ repository = "https://github.com/transaction-science/open-standards" [lib] path = "src/lib.rs" +# The binary is `wai-relay`; the crate keeps its name (WAI toolchain decision 11). [[bin]] -name = "joule-moq-relay" +name = "wai-relay" path = "src/main.rs" [dependencies] diff --git a/joule-code/joule-code-rs/crates/joule-moq-relay/README.md b/joule-code/joule-code-rs/crates/joule-moq-relay/README.md index 93260a8cf..e6873d31f 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-relay/README.md +++ b/joule-code/joule-code-rs/crates/joule-moq-relay/README.md @@ -7,8 +7,8 @@ draft's priorities give, dropping what passes a subscriber's delivery timeout at Whatever it drops, the object's receipts still reach the subscriber, on the track's receipts track. -It is a library (`Relay`, `RelayServer`, `client::Client`) and a binary, -`joule-moq-relay`. The wire codec is `joule-moq`'s. +It is a library (`Relay`, `RelayServer`, `client::Client`) and a binary, `wai-relay` (the +crate keeps its name; the binary is named for WAI). The wire codec is `joule-moq`'s. ## The drafts it speaks @@ -740,7 +740,7 @@ The store must be kept where the room's region pin allows (live-rooms §3.7). ## Configuration -`joule-moq-relay --config relay.toml`, or `joule-moq-relay --dev` for loopback with a +`wai-relay --config relay.toml`, or `wai-relay --dev` for loopback with a development certificate. `relay.example.toml` shows every field: the listen addresses and path, the certificate files (or the names for a development certificate), the cache's byte budget, the limits (sessions, requests and streams in flight, MAX_REQUEST_UPDATES, the token diff --git a/joule-code/joule-code-rs/crates/joule-moq-relay/relay.example.toml b/joule-code/joule-code-rs/crates/joule-moq-relay/relay.example.toml index 7ec581975..6dd13c04a 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-relay/relay.example.toml +++ b/joule-code/joule-code-rs/crates/joule-moq-relay/relay.example.toml @@ -1,4 +1,4 @@ -# joule-moq-relay: an example configuration. Every field is optional; these are the +# wai-relay (crate joule-moq-relay): an example configuration. Every field is optional; these are the # defaults unless a comment says otherwise. Nothing secret belongs here: keys are files # the operator provides, and tokens are named by their SHA-256. diff --git a/joule-code/joule-code-rs/crates/joule-moq-relay/src/main.rs b/joule-code/joule-code-rs/crates/joule-moq-relay/src/main.rs index 8ea5a73ab..b3d2efe9c 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-relay/src/main.rs +++ b/joule-code/joule-code-rs/crates/joule-moq-relay/src/main.rs @@ -1,8 +1,8 @@ -//! `joule-moq-relay`: run the relay. +//! `wai-relay` (crate `joule-moq-relay`): run the relay. //! //! ```text -//! joule-moq-relay --config relay.toml -//! joule-moq-relay --dev # loopback, a development certificate +//! wai-relay --config relay.toml +//! wai-relay --dev # loopback, a development certificate //! ``` //! //! It logs where it listens, the certificate's SHA-256 (for a page's @@ -15,7 +15,7 @@ use joule_moq_relay::{RelayConfig, RelayServer}; use std::process::ExitCode; fn usage() -> ExitCode { - eprintln!("usage: joule-moq-relay (--config | --dev)"); + eprintln!("usage: wai-relay (--config | --dev)"); ExitCode::from(2) } @@ -29,7 +29,7 @@ async fn main() -> ExitCode { ["--config", path] => match std::fs::read_to_string(path).map_err(|e| e.to_string()).and_then(|s| RelayConfig::from_toml(&s)) { Ok(c) => c, Err(e) => { - eprintln!("joule-moq-relay: {path}: {e}"); + eprintln!("wai-relay: {path}: {e}"); return ExitCode::FAILURE; } }, @@ -38,7 +38,7 @@ async fn main() -> ExitCode { let server = match RelayServer::start(cfg).await { Ok(s) => s, Err(e) => { - eprintln!("joule-moq-relay: {e}"); + eprintln!("wai-relay: {e}"); return ExitCode::FAILURE; } }; diff --git a/joule-code/joule-code-rs/crates/joule-moq-remote/README.md b/joule-code/joule-code-rs/crates/joule-moq-remote/README.md index 299b1e03e..8602aa144 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-remote/README.md +++ b/joule-code/joule-code-rs/crates/joule-moq-remote/README.md @@ -31,7 +31,7 @@ takes every principal's records in order. The host makes a key once, and runs in ``` wai-remote-host key ./host.key # mode 0600; prints the public key wai-remote-host run --room https://room.example/room-api --key ./host.key \ - [--invitation ] --relay wss://relay.example/moq \ + [--invitation ] --relay wss://relay.example/moq \ [--ca ./private-ca.pem] --floor floor --give-on-request ``` diff --git a/joule-code/joule-code-rs/crates/joule-moq-room/Cargo.toml b/joule-code/joule-code-rs/crates/joule-moq-room/Cargo.toml index 08f506d17..6ea1028cd 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-room/Cargo.toml +++ b/joule-code/joule-code-rs/crates/joule-moq-room/Cargo.toml @@ -20,8 +20,9 @@ wtransport = { path = "../../vendor/wtransport" } [lib] path = "src/lib.rs" +# The binary is `wai-room`; the crate keeps its name (WAI toolchain decision 11). [[bin]] -name = "joule-moq-room" +name = "wai-room" path = "src/main.rs" [dependencies] diff --git a/joule-code/joule-code-rs/crates/joule-moq-room/README.md b/joule-code/joule-code-rs/crates/joule-moq-room/README.md index 98ee078a6..2dd3608b8 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-room/README.md +++ b/joule-code/joule-code-rs/crates/joule-moq-room/README.md @@ -1,7 +1,8 @@ # joule-moq-room A room service for WAI live rooms (`wai/extensions/live-rooms.md`) over -`joule-moq-relay`. It holds one room's authority and clock keys and: +`joule-moq-relay`. Its binary is `wai-room` (the crate keeps its name). It holds one room's +authority and clock keys and: - **issues grants.** A visitor's page makes an Ed25519 key and asks for a grant for it (`POST /grant`). The service signs a short-lived `WRG1` grant: a publisher's (its own @@ -27,7 +28,7 @@ A room service for WAI live rooms (`wai/extensions/live-rooms.md`) over permits `meta`, so that each principal sends the extension's statements. The host's grant carries `remote_host`; a controller's the control power, `remote_control` = `control`; a viewer's neither. The host is the key `host` names or, when it names none, the first key that - presents an invitation the room's authority signed (`joule-moq-room invite`), to which the + presents an invitation the room's authority signed (`wai-room invite`), to which the invitation is then bound. Who may ask for a controller's grant is `allow`: `all`, `listed` (the keys `controllers` lists) or `none`. The host hands control over by a grant exchange (`POST /remote/control`, its grant and a proof over the service's challenge), which takes @@ -177,14 +178,14 @@ each room's grants and chain hold one stretch of company. ## Running it ```text -joule-moq-room keygen new keys (mode 0600, never over a file); prints the - relay configuration lines that trust them -joule-moq-room relay-lines those lines again -joule-moq-room invite [] an invitation to host a remote desktop room, signed by - the room's authority, good for seconds (3600) -joule-moq-room --config serve (room.example.toml shows every field) -joule-moq-room dev [--publishers ] a development room: the relay and the service in one - process on loopback, driven by JSON control lines +wai-room keygen new keys (mode 0600, never over a file); prints the + relay configuration lines that trust them +wai-room relay-lines those lines again +wai-room invite [] an invitation to host a remote desktop room, signed by + the room's authority, good for seconds (3600) +wai-room --config serve (room.example.toml shows every field) +wai-room dev [--publishers ] a development room: the relay and the service in one + process on loopback, driven by JSON control lines ``` The keys file must be readable by its owner alone; the service refuses it otherwise. @@ -218,7 +219,7 @@ trust_field = 1 ### A development room -`joule-moq-room dev` (`src/dev.rs`) runs a relay and the service together in one process, for +`wai-room dev` (`src/dev.rs`) runs a relay and the service together in one process, for a developer's machine and for tests, and never for anyone else. It binds 127.0.0.1 alone, on ports the system picks; its keys are made at start and kept only in the process; its relay holds media tracks to no receipts, so a test principal publishes plain objects; and its rate diff --git a/joule-code/joule-code-rs/crates/joule-moq-room/openapi.json b/joule-code/joule-code-rs/crates/joule-moq-room/openapi.json index eea38c92b..78e6bb173 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-room/openapi.json +++ b/joule-code/joule-code-rs/crates/joule-moq-room/openapi.json @@ -1378,7 +1378,7 @@ }, "/watch/challenge": { "get": { - "operationId": "watchchallenge.create", + "operationId": "watch.setup", "tags": [ "watch" ], @@ -2151,7 +2151,7 @@ }, "/msg/challenge": { "get": { - "operationId": "conversationchallenge.create", + "operationId": "conversation.setup", "tags": [ "messaging" ], @@ -3180,7 +3180,7 @@ }, "/call/challenge": { "get": { - "operationId": "callchallenge.create", + "operationId": "call.setup", "tags": [ "calls" ], diff --git a/joule-code/joule-code-rs/crates/joule-moq-room/room.example.toml b/joule-code/joule-code-rs/crates/joule-moq-room/room.example.toml index d53762e21..f4d802721 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-room/room.example.toml +++ b/joule-code/joule-code-rs/crates/joule-moq-room/room.example.toml @@ -1,5 +1,5 @@ -# joule-moq-room: an example configuration. Nothing secret belongs here: the keys are a -# file of their own (`joule-moq-room keygen ` makes it, mode 0600), outside every +# wai-room (crate joule-moq-room): an example configuration. Nothing secret belongs here: the +# keys are a file of their own (`wai-room keygen ` makes it, mode 0600), outside every # repository, and the relay knows the clock's token by its SHA-256 alone. # The HTTP service, on loopback behind the front that serves the room's page. @@ -128,7 +128,7 @@ global_every_s = 10 # Every room is a remote desktop room (WAI remote-desktop): one host's screen shared into it. The # host is the key `host` names, or, empty, the first key that presents an invitation the room's -# authority signed (`joule-moq-room invite `). Not also e2ee, a watch room, a room with +# authority signed (`wai-room invite `). Not also e2ee, a watch room, a room with # co-performance, one admitted by age or a world room. The relay holds the host's screen to its key # with a door: # [[receipts.door]] prefix = ["*", "*", "screen"], trust_field = 1. diff --git a/joule-code/joule-code-rs/crates/joule-moq-room/src/dev.rs b/joule-code/joule-code-rs/crates/joule-moq-room/src/dev.rs index 9e52e5999..7dbd4103b 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-room/src/dev.rs +++ b/joule-code/joule-code-rs/crates/joule-moq-room/src/dev.rs @@ -1,5 +1,5 @@ //! A **development room**: the relay and the room service in one process, on loopback, for a -//! developer's machine and for tests. `joule-moq-room dev` runs one and takes its control +//! developer's machine and for tests. `wai-room dev` runs one and takes its control //! lines on standard input; `wai mcp` drives it that way. //! //! It is not a deployment, and differs from one on purpose: @@ -56,7 +56,7 @@ const STEP: Duration = Duration::from_secs(20); const MAX_OBJECTS: u64 = 64; /// What `stderr` says first: the room is for development only. -pub const BANNER: &str = "joule-moq-room dev: a DEVELOPMENT room on loopback. Its keys exist only in this process, its relay \ +pub const BANNER: &str = "wai-room dev: a DEVELOPMENT room on loopback. Its keys exist only in this process, its relay \ holds media tracks to no receipts, and its rate limits are open. Never serve it to anyone."; /// A test principal that has entered: its session and the tracks it holds open. @@ -369,7 +369,7 @@ impl DevRoom { } } -/// `joule-moq-room dev [--publishers ]`: run a development room and answer control lines +/// `wai-room dev [--publishers ]`: run a development room and answer control lines /// until `stop` or until standard input closes. pub async fn run(args: &[String]) -> std::process::ExitCode { let mut publishers: u16 = 4; @@ -380,14 +380,14 @@ pub async fn run(args: &[String]) -> std::process::ExitCode { publishers = match n.parse() { Ok(n) if (1..=64).contains(&n) => n, _ => { - eprintln!("joule-moq-room dev: --publishers takes 1 to 64"); + eprintln!("wai-room dev: --publishers takes 1 to 64"); return std::process::ExitCode::from(2); } }; it.next(); } _ => { - eprintln!("usage: joule-moq-room dev [--publishers ]"); + eprintln!("usage: wai-room dev [--publishers ]"); return std::process::ExitCode::from(2); } } @@ -396,7 +396,7 @@ pub async fn run(args: &[String]) -> std::process::ExitCode { let room = match DevRoom::start(publishers, "").await { Ok(r) => r, Err(e) => { - eprintln!("joule-moq-room dev: {e}"); + eprintln!("wai-room dev: {e}"); println!("{}", json!({"ready": false, "error": e})); return std::process::ExitCode::FAILURE; } diff --git a/joule-code/joule-code-rs/crates/joule-moq-room/src/keys.rs b/joule-code/joule-code-rs/crates/joule-moq-room/src/keys.rs index f987444a3..4e039039b 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-room/src/keys.rs +++ b/joule-code/joule-code-rs/crates/joule-moq-room/src/keys.rs @@ -53,7 +53,7 @@ impl Keys { pub fn to_toml(&self) -> String { format!( - "# joule-moq-room's keys. Secret: mode 0600, outside every repository.\nauthority_seed = \"{}\"\nclock_seed = \"{}\"\nclock_token = \"{}\"\n", + "# wai-room's keys. Secret: mode 0600, outside every repository.\nauthority_seed = \"{}\"\nclock_seed = \"{}\"\nclock_token = \"{}\"\n", hex::encode(self.authority.to_bytes()), hex::encode(self.clock.to_bytes()), hex::encode(self.clock_token) diff --git a/joule-code/joule-code-rs/crates/joule-moq-room/src/main.rs b/joule-code/joule-code-rs/crates/joule-moq-room/src/main.rs index 2ad4d84eb..f650eaa4b 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-room/src/main.rs +++ b/joule-code/joule-code-rs/crates/joule-moq-room/src/main.rs @@ -1,17 +1,17 @@ -//! `joule-moq-room`: run the room service. +//! `wai-room` (crate `joule-moq-room`): run the room service. //! //! ```text -//! joule-moq-room keygen new keys (mode 0600, never over a file), and the +//! wai-room keygen new keys (mode 0600, never over a file), and the //! relay configuration lines that trust them -//! joule-moq-room relay-lines those lines again, for keys that exist -//! joule-moq-room invite [] an invitation to host a remote desktop room +//! wai-room relay-lines those lines again, for keys that exist +//! wai-room invite [] an invitation to host a remote desktop room //! (`[remote]` with no `host`), signed by the //! room's authority, good for seconds (3600) -//! joule-moq-room --config serve -//! joule-moq-room dev [--publishers ] a development room on loopback: the relay and the -//! service in one process, fresh keys held in memory, -//! driven by JSON control lines on standard input -//! (`joule_moq_room::dev`); never for deployment +//! wai-room --config serve +//! wai-room dev [--publishers ] a development room on loopback: the relay and the +//! service in one process, fresh keys held in memory, +//! driven by JSON control lines on standard input +//! (`joule_moq_room::dev`); never for deployment //! ``` //! //! It stops on Ctrl-C or SIGTERM. With `max_resident_mb` set it exits with status 70 once @@ -23,7 +23,7 @@ use std::path::Path; use std::process::ExitCode; fn usage() -> ExitCode { - eprintln!("usage: joule-moq-room (keygen | relay-lines | invite [] | --config | dev [--publishers ])"); + eprintln!("usage: wai-room (keygen | relay-lines | invite [] | --config | dev [--publishers ])"); ExitCode::from(2) } @@ -43,11 +43,11 @@ async fn main() -> ExitCode { ["keygen", path] => match Keys::create(Path::new(path)) { Ok(k) => { println!("{}", k.relay_lines()); - eprintln!("joule-moq-room: keys written to {path} (mode 0600)"); + eprintln!("wai-room: keys written to {path} (mode 0600)"); ExitCode::SUCCESS } Err(e) => { - eprintln!("joule-moq-room: {e}"); + eprintln!("wai-room: {e}"); ExitCode::FAILURE } }, @@ -57,7 +57,7 @@ async fn main() -> ExitCode { ExitCode::SUCCESS } Err(e) => { - eprintln!("joule-moq-room: {e}"); + eprintln!("wai-room: {e}"); ExitCode::FAILURE } }, @@ -70,11 +70,11 @@ async fn main() -> ExitCode { match Keys::load(Path::new(path)) { Ok(k) => { println!("{}", joule_moq_room::remote::new_invitation(&k, valid_s)); - eprintln!("joule-moq-room: an invitation to host, good for {valid_s} s, bound to the first key that presents it"); + eprintln!("wai-room: an invitation to host, good for {valid_s} s, bound to the first key that presents it"); ExitCode::SUCCESS } Err(e) => { - eprintln!("joule-moq-room: {e}"); + eprintln!("wai-room: {e}"); ExitCode::FAILURE } } @@ -83,14 +83,14 @@ async fn main() -> ExitCode { let cfg = match std::fs::read_to_string(path).map_err(|e| e.to_string()).and_then(|s| Config::from_toml(&s)) { Ok(c) => c, Err(e) => { - eprintln!("joule-moq-room: {path}: {e}"); + eprintln!("wai-room: {path}: {e}"); return ExitCode::FAILURE; } }; let keys = match Keys::load(&cfg.keys) { Ok(k) => k, Err(e) => { - eprintln!("joule-moq-room: {e}"); + eprintln!("wai-room: {e}"); return ExitCode::FAILURE; } }; @@ -104,14 +104,14 @@ async fn serve(cfg: Config, keys: Keys) -> ExitCode { let listener = match tokio::net::TcpListener::bind(cfg.listen).await { Ok(l) => l, Err(e) => { - eprintln!("joule-moq-room: binding {}: {e}", cfg.listen); + eprintln!("wai-room: binding {}: {e}", cfg.listen); return ExitCode::FAILURE; } }; let svc = match Service::try_new(keys, &cfg) { Ok(s) => s, Err(e) => { - eprintln!("joule-moq-room: {e}"); + eprintln!("wai-room: {e}"); return ExitCode::FAILURE; } }; diff --git a/joule-code/joule-code-rs/crates/joule-moq-room/src/remote.rs b/joule-code/joule-code-rs/crates/joule-moq-room/src/remote.rs index ef55df365..57d4f3b04 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-room/src/remote.rs +++ b/joule-code/joule-code-rs/crates/joule-moq-room/src/remote.rs @@ -11,7 +11,7 @@ //! `screen` kind; a controller's carries the control power, `remote_control` with the classes //! the configuration gives (`control`); a viewer's carries neither. The host is the key the //! configuration names (`host`), or, when it names none, the first visitor who presents an -//! invitation the room's authority signed (`joule-moq-room invite`); an invitation is bound to +//! invitation the room's authority signed (`wai-room invite`); an invitation is bound to //! the key that presented it first. Who may ask for a controller's grant is the configuration's //! allow rule (`allow`: everyone, the keys listed, or no one); the host hands control over by a //! grant exchange (live-rooms §4.6, §7.1 here), which it asks for with its grant and a proof of diff --git a/joule-code/joule-code-rs/crates/joule-moq-room/tests/mcp_dev_room.rs b/joule-code/joule-code-rs/crates/joule-moq-room/tests/mcp_dev_room.rs index da5af5d1f..e5f5c3366 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-room/tests/mcp_dev_room.rs +++ b/joule-code/joule-code-rs/crates/joule-moq-room/tests/mcp_dev_room.rs @@ -1,5 +1,5 @@ //! End to end, as a coding agent works: an MCP client drives `wai mcp --toolset core,dev`, which -//! runs this crate's development room (`joule-moq-room dev`) as a child, and through its tools +//! runs this crate's development room (`wai-room dev`) as a child, and through its tools //! //! - finds the one operation the two doors share named alike: the tool `room_grant` is the //! operation `room.grant`, which `openapi.json` describes as the service's `POST /grant`; @@ -48,7 +48,7 @@ impl Agent { .args(["mcp", "--toolset", "core,dev", "--project"]) .arg(project) .arg("--room-bin") - .arg(env!("CARGO_BIN_EXE_joule-moq-room")) + .arg(env!("CARGO_BIN_EXE_wai-room")) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::inherit()) @@ -143,7 +143,7 @@ fn an_agent_starts_a_room_joins_publishes_and_verifies_the_session() { assert_eq!(again["key"].as_str(), Some(keys[0].as_str())); // The session, verified. - let s = a.ok("room_session", json!({})); + let s = a.ok("session_show", json!({})); assert_eq!(s["room"].as_str(), Some(id.as_str())); let args = json!({"descriptor": s["descriptor"], "grants": s["grants"], "key_log_head": s["key_log_head"], "chains": s["chains"]}); let v = a.ok("receipt_verify", args.clone()); @@ -168,13 +168,13 @@ fn an_agent_starts_a_room_joins_publishes_and_verifies_the_session() { // Alice leaves; the session verifies with Bob alone present. let l = a.ok("room_leave", json!({"principal": "alice"})); assert!(l["events"].as_array().unwrap().iter().any(|e| e == "leave"), "{l}"); - let s = a.ok("room_session", json!({})); + let s = a.ok("session_show", json!({})); let v = a.ok("receipt_verify", json!({"kind": "room_session", "descriptor": s["descriptor"], "grants": s["grants"], "key_log_head": s["key_log_head"], "chains": s["chains"]})); assert_eq!(v["outcome"], "accept", "{v}"); assert_eq!(v["present"], json!([keys[1]]), "{v}"); assert_eq!(v["records_in_order"], 7); assert_eq!(a.ok("dev_stop", json!({}))["stopped"], true); - assert!(a.call("room_session", json!({})).is_err(), "no room once stopped"); + assert!(a.call("session_show", json!({})).is_err(), "no room once stopped"); let _ = std::fs::remove_dir_all(&project); } diff --git a/joule-code/joule-code-rs/crates/joule-moq-room/tests/openapi_contract.rs b/joule-code/joule-code-rs/crates/joule-moq-room/tests/openapi_contract.rs index 54958117f..17624fcc9 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-room/tests/openapi_contract.rs +++ b/joule-code/joule-code-rs/crates/joule-moq-room/tests/openapi_contract.rs @@ -1,8 +1,9 @@ //! The service's OpenAPI description (`openapi.json`) held to the code, in both directions: //! //! - **Names.** Every operation's `operationId` is its name in the toolchain's catalogue, the -//! one `HANDLERS` gives it: `noun.verb` by the rule every door keeps (`wai::ops::check_id`), -//! each name once. +//! one `HANDLERS` gives it: an operation of `wai::catalogue` that runs on this service, at the +//! route the catalogue gives it, each name once; and every operation the catalogue routes to +//! this service is described here. //! - **Routes.** The (method, path) pairs the routers match (`src/http.rs`, //! `src/messaging.rs`, `src/call.rs`) are exactly the description's operations. //! - **Statuses, from the source.** Each operation names the functions that answer it below @@ -139,7 +140,7 @@ const HANDLERS: &[(&str, &str, &str, Handlers)] = &[ ("POST", "/co/fidelity", "fidelity.create", &[("http.rs", "co_request"), ("http.rs", "unauthorised"), ("http.rs", "signed_reply")]), ("GET", "/watch/time", "time.show", &[("http.rs", "handle")]), ("GET", "/watch/source", "source.fetch", &[("http.rs", "watch_source")]), - ("GET", "/watch/challenge", "watchchallenge.create", &[("http.rs", "handle")]), + ("GET", "/watch/challenge", "watch.setup", &[("http.rs", "handle")]), ("GET", "/watch", "watch.tail", &[("http.rs", "watch_log")]), ("GET", "/watch/room", "watch.show", &[("http.rs", "handle")]), ("POST", "/watch/join", "watch.join", &[("http.rs", "handle"), ("http.rs", "watch_join")]), @@ -148,7 +149,7 @@ const HANDLERS: &[(&str, &str, &str, Handlers)] = &[ ("GET", "/remote/room", "remote.show", &[("remote.rs", "session"), ("remote.rs", "admit_reader"), ("http.rs", "unauthorised")]), ("GET", "/remote/state", "remote.verify", &[("remote.rs", "state"), ("remote.rs", "admit_reader"), ("http.rs", "unauthorised")]), ("GET", "/msg", "conversation.show", &[("messaging.rs", "handle")]), - ("GET", "/msg/challenge", "conversationchallenge.create", &[("messaging.rs", "handle")]), + ("GET", "/msg/challenge", "conversation.setup", &[("messaging.rs", "handle")]), ("POST", "/msg/join", "conversation.join", &[("messaging.rs", "handle"), ("messaging.rs", "join"), ("messaging.rs", "unauthorised")]), ("GET", "/msg/log", "conversation.tail", &[("messaging.rs", "handle"), ("messaging.rs", "log"), ("messaging.rs", "unauthorised")]), ("POST", "/msg/key-package", "conversation.import", &[("messaging.rs", "handle"), ("messaging.rs", "unauthorised"), ("messaging.rs", "body"), ("messaging.rs", "act")]), @@ -157,7 +158,7 @@ const HANDLERS: &[(&str, &str, &str, Handlers)] = &[ ("POST", "/msg/ack", "acknowledgement.create", &[("messaging.rs", "handle"), ("messaging.rs", "unauthorised"), ("messaging.rs", "body"), ("messaging.rs", "act")]), ("POST", "/msg/leave", "conversation.leave", &[("messaging.rs", "handle"), ("messaging.rs", "unauthorised"), ("messaging.rs", "body"), ("messaging.rs", "act")]), ("POST", "/msg/remove", "member.delete", &[("messaging.rs", "handle"), ("messaging.rs", "unauthorised"), ("messaging.rs", "body"), ("messaging.rs", "act")]), - ("GET", "/call/challenge", "callchallenge.create", &[("http.rs", "handle"), ("call.rs", "handle")]), + ("GET", "/call/challenge", "call.setup", &[("http.rs", "handle"), ("call.rs", "handle")]), ("POST", "/call/place", "call.create", &[("http.rs", "handle"), ("call.rs", "handle"), ("call.rs", "unauthorised"), ("call.rs", "body"), ("call.rs", "place")]), ("POST", "/call/ring", "call.invite", &[("http.rs", "handle"), ("call.rs", "handle"), ("http.rs", "unauthorised"), ("call.rs", "body"), ("call.rs", "ring")]), ("GET", "/inbox", "inbox.show", &[("http.rs", "handle"), ("call.rs", "handle"), ("call.rs", "unauthorised"), ("call.rs", "inbox")]), @@ -182,14 +183,17 @@ fn every_operation_is_named_as_the_catalogue_names_it() { let doc = description(); let mut seen = BTreeSet::new(); for (m, p, op, _) in HANDLERS { - assert_eq!(wai::ops::check_id(op), Ok(()), "{m} {p}: {op}"); + let o = wai::catalogue::op(op).unwrap_or_else(|| panic!("{m} {p}: {op} is not an operation of the catalogue (wai/wai-rs/src/catalogue.rs)")); + let r = o.route.unwrap_or_else(|| panic!("{op}: the catalogue gives it no route")); + assert_eq!((r.service, r.method, r.path), ("wai-room", *m, *p), "{op}: the catalogue's route"); assert!(seen.insert(*op), "{op} names two operations"); let id = doc["paths"][*p][m.to_ascii_lowercase()]["operationId"].as_str(); assert_eq!(id, Some(*op), "{m} {p}: its operationId is its name in the catalogue"); } - // The operations the catalogue names with their routes. - for (m, p, op) in [("GET", "/room", "room.show"), ("POST", "/grant", "room.grant")] { - assert!(HANDLERS.iter().any(|h| (h.0, h.1, h.2) == (m, p, op)), "{op} is {m} {p}"); + // Every operation the catalogue routes to this service is one of these. + for o in wai::catalogue::OPS.iter().filter(|o| o.route.is_some_and(|r| r.service == "wai-room")) { + let r = o.route.expect("routed"); + assert!(HANDLERS.iter().any(|h| (h.0, h.1, h.2) == (r.method, r.path, o.id)), "the catalogue routes {} to {} {}, which this service does not describe", o.id, r.method, r.path); } } diff --git a/joule-code/joule-code-rs/crates/joule-moq-web/e2e/lib.mjs b/joule-code/joule-code-rs/crates/joule-moq-web/e2e/lib.mjs index 084d359ca..bb8524818 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-web/e2e/lib.mjs +++ b/joule-code/joule-code-rs/crates/joule-moq-web/e2e/lib.mjs @@ -109,7 +109,7 @@ function relayToml(c) { // A DVR window of every track (WAI live-recording §8). if (c.dvr) t += `[dvr]\npath = ${q(c.dvr.path)}\nroom_keys = ${arr(c.dvr.room_keys || [])}\n\n[[dvr.window]]\nprefix = ["*"]\nmax_age_s = ${c.dvr.max_age_s}\nmax_bytes = ${c.dvr.max_bytes}\n\n`; t += `[summary]\nout_path = ${q(c.summary_out)}\n\n[auth]\nrequired = ${c.auth ? "true" : "false"}\n\n`; - // Lines a room service prints for the relay that trusts it (`joule-moq-room relay-lines`). + // Lines a room service prints for the relay that trusts it (`wai-room relay-lines`). if (c.extra) t += c.extra + "\n"; // A grant that may subscribe may also seek back below the live edge (live-rooms §10.6), // as a late joiner does, unless the test says otherwise. @@ -181,7 +181,7 @@ export function tmpdir(label) { return fs.mkdtempSync(path.join(process.env.TMPDIR || os.tmpdir(), `moq-web-${label}-`)); } -// A room service (joule-moq-room) for a relay: its keys made in `dir` (mode 0600), the +// A room service (wai-room, crate joule-moq-room) for a relay: its keys made in `dir` (mode 0600), the // relay's lines that trust them, then, once the relay listens, the service itself. export function roomKeys(bin, dir) { const keys = path.join(dir, "room-keys.toml"); diff --git a/joule-code/joule-code-rs/crates/joule-moq-web/e2e/messaging.e2e.mjs b/joule-code/joule-code-rs/crates/joule-moq-web/e2e/messaging.e2e.mjs index 4d8088e79..7bb7004c9 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-web/e2e/messaging.e2e.mjs +++ b/joule-code/joule-code-rs/crates/joule-moq-web/e2e/messaging.e2e.mjs @@ -1,9 +1,9 @@ // End-to-end tests of the messaging view (web/room/messages.html) and the call flow -// (web/room/call.html) against a room service (joule-moq-room) on loopback, in the host's +// (web/room/call.html) against a room service (wai-room) on loopback, in the host's // installed browser driven by the automation package of e2e/package.json, with a fake camera // and microphone (WAI live-messaging). // -// node e2e/messaging.e2e.mjs --room --relay --web +// node e2e/messaging.e2e.mjs --room --relay --web // [--browser ] [--only messaging,call] [--out ] [--runs N] // // Scenarios: diff --git a/joule-code/joule-code-rs/crates/joule-moq-web/e2e/room.e2e.mjs b/joule-code/joule-code-rs/crates/joule-moq-web/e2e/room.e2e.mjs index d30273537..2a6ce025f 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-web/e2e/room.e2e.mjs +++ b/joule-code/joule-code-rs/crates/joule-moq-web/e2e/room.e2e.mjs @@ -2,12 +2,12 @@ // host's installed browser driven by the automation package of e2e/package.json, with a // fake camera and microphone. // -// node e2e/room.e2e.mjs --relay --wai --web +// node e2e/room.e2e.mjs --relay --wai --web // --corpus [--browser ] // [--only compose,webtransport,websocket,goaway,dvr,service,e2ee,age,co,ageco,binding,cowatch, // origin,termination,remote,world,privateworld] // [--runs N] -// [--between ] [--out ] [--room ] [--base ] [--seek on|off] +// [--between ] [--out ] [--room ] [--base ] [--seek on|off] // [--wai-dir ] // // Scenarios: @@ -564,7 +564,7 @@ async function scenarioService(run) { if (BASE) { base = `${BASE}/room/`; } else { - if (!ROOM) throw new Error("--room is needed for the service scenario"); + if (!ROOM) throw new Error("--room is needed for the service scenario"); const k = roomKeys(ROOM, dir); const door = ["audio", "video"].map((m) => ({ prefix: ["*", "*", m], trust_field: 1, hold_ms: 2000 })); // A DVR window over every track and a cache far smaller than the seconds sought back, as @@ -872,7 +872,7 @@ async function provenGet(url, grantHex, seedHex) { // remote_desktop_verify.py, from the extension's text alone. async function scenarioRemote(run) { const label = "remote"; - if (!REMOTE || !ROOM) throw new Error("--remote and --room are needed for the remote scenario"); + if (!REMOTE || !ROOM) throw new Error("--remote and --room are needed for the remote scenario"); const dir = path.join(OUT, `run${run}-remote`); fs.mkdirSync(dir, { recursive: true }); const k = roomKeys(ROOM, dir); @@ -1051,7 +1051,7 @@ async function scenarioE2ee(run) { const label = "e2ee"; const dir = path.join(OUT, `run${run}-e2ee`); fs.mkdirSync(dir, { recursive: true }); - if (!ROOM) throw new Error("--room is needed for the e2ee scenario"); + if (!ROOM) throw new Error("--room is needed for the e2ee scenario"); const k = roomKeys(ROOM, dir); const door = ["audio", "video"].map((m) => ({ prefix: ["*", "*", m], trust_field: 1, hold_ms: 2000 })); // A DVR window over every track: the relay keeps nothing of a room whose objects are sealed. @@ -1251,7 +1251,7 @@ async function scenarioAge(run) { const label = "age"; const dir = path.join(OUT, `run${run}-age`); fs.mkdirSync(dir, { recursive: true }); - if (!ROOM) throw new Error("--room is needed for the age scenario"); + if (!ROOM) throw new Error("--room is needed for the age scenario"); const keys = testKeys(path.join(path.dirname(CORPUS), "..", "age", "vectors.json")); const issuer = await startIssuer(keys); const k = roomKeys(ROOM, dir); @@ -1351,7 +1351,7 @@ async function scenarioOrigin(run) { const label = "origin"; const dir = path.join(OUT, `run${run}-origin`); fs.mkdirSync(dir, { recursive: true }); - if (!ROOM) throw new Error("--room is needed for the origin scenario"); + if (!ROOM) throw new Error("--room is needed for the origin scenario"); const k = roomKeys(ROOM, dir); const door = ["audio", "video"].map((m) => ({ prefix: ["*", "*", m], trust_field: 1, hold_ms: 2000 })); const relay = await startRelay(RELAY, { no_quic: true, auth: true, door, extra: k.lines, summary_out: path.join(dir, "summaries.jsonl"), limits: { max_session_ms: 600000 } }, dir, "relay"); @@ -1450,7 +1450,7 @@ async function scenarioAgeCo(run) { const label = "ageco"; const dir = path.join(OUT, `run${run}-ageco`); fs.mkdirSync(dir, { recursive: true }); - if (!ROOM) throw new Error("--room is needed for the ageco scenario"); + if (!ROOM) throw new Error("--room is needed for the ageco scenario"); const keys = testKeys(path.join(path.dirname(CORPUS), "..", "age", "vectors.json")); const issuer = await startIssuer(keys); const k = roomKeys(ROOM, dir); @@ -1555,7 +1555,7 @@ async function scenarioCo(run) { const label = "co"; const dir = path.join(OUT, `run${run}-co`); fs.mkdirSync(dir, { recursive: true }); - if (!ROOM) throw new Error("--room is needed for the co scenario"); + if (!ROOM) throw new Error("--room is needed for the co scenario"); const k = roomKeys(ROOM, dir); const door = ["audio", "video"].map((m) => ({ prefix: ["*", "*", m], trust_field: 1, hold_ms: 2000 })); // The relay holds the floor and the audience's inputs, honouring the clock's statements. @@ -2022,7 +2022,7 @@ async function scenarioCowatch(run) { const label = "cowatch"; const dir = path.join(OUT, `run${run}-cowatch`); fs.mkdirSync(dir, { recursive: true }); - if (!ROOM) throw new Error("--room is needed for the cowatch scenario"); + if (!ROOM) throw new Error("--room is needed for the cowatch scenario"); const k = roomKeys(ROOM, dir); const source = path.join(dir, "programme.wav"); const wav = writeWav(source, 120); @@ -2303,7 +2303,7 @@ const WORLD_TOML = `[world]\nmodel_id = "${"07".repeat(32)}"\nwidth = 64\nheight async function worldRoom(run, label, sealed) { const dir = path.join(OUT, `run${run}-${label}`); fs.mkdirSync(dir, { recursive: true }); - if (!ROOM) throw new Error(`--room is needed for the ${label} scenario`); + if (!ROOM) throw new Error(`--room is needed for the ${label} scenario`); const k = roomKeys(ROOM, dir); // The relay-lines' world door (§4.3), and a DVR window that keeps the room's world tracks for // a page that enters late (§7); it keeps nothing of a private world. diff --git a/joule-code/joule-code-rs/crates/joule-moq-web/e2e/run.sh b/joule-code/joule-code-rs/crates/joule-moq-web/e2e/run.sh index b80825ba9..2fb2016d1 100755 --- a/joule-code/joule-code-rs/crates/joule-moq-web/e2e/run.sh +++ b/joule-code/joule-code-rs/crates/joule-moq-web/e2e/run.sh @@ -43,9 +43,9 @@ mkdir -p "$PKG" step() { echo "== $*"; } -step "native: joule-moq-relay, joule-moq-room, wai_live_web" -(cd "$JC" && cargo build $LOCKED --release -p joule-moq-relay --bin joule-moq-relay) -(cd "$CRATE/../joule-moq-room" && cargo build $LOCKED --release --bin joule-moq-room) +step "native: wai-relay, wai-room, wai_live_web" +(cd "$JC" && cargo build $LOCKED --release -p joule-moq-relay --bin wai-relay) +(cd "$CRATE/../joule-moq-room" && cargo build $LOCKED --release --bin wai-room) (cd "$CRATE/../joule-moq-remote" && cargo build $LOCKED --release --bin wai-remote-host) (cd "$WLW" && cargo build $LOCKED --release --bin wai_live_web) @@ -93,8 +93,8 @@ while [ $# -gt 0 ]; do done if [ "$only" = 0 ] || [ -n "$room_only" ]; then node "$HERE/room.e2e.mjs" \ - --relay "$TARGET/release/joule-moq-relay" \ - --room "$TARGET/release/joule-moq-room" \ + --relay "$TARGET/release/wai-relay" \ + --room "$TARGET/release/wai-room" \ --wai "$TARGET/release/wai_live_web" \ --remote "$TARGET/release/wai-remote-host" \ --web "$CRATE/web" \ @@ -108,8 +108,8 @@ fi if [ -n "$msg_only" ]; then step "end-to-end: conversations and calls" node "$HERE/messaging.e2e.mjs" \ - --relay "$TARGET/release/joule-moq-relay" \ - --room "$TARGET/release/joule-moq-room" \ + --relay "$TARGET/release/wai-relay" \ + --room "$TARGET/release/wai-room" \ --web "$CRATE/web" \ --out "$OUT/results/messaging" \ --only "$msg_only" \ diff --git a/scripts/check-wai-ops-doors-selftest.sh b/scripts/check-wai-ops-doors-selftest.sh new file mode 100755 index 000000000..883cabaae --- /dev/null +++ b/scripts/check-wai-ops-doors-selftest.sh @@ -0,0 +1,160 @@ +#!/usr/bin/env bash +# +# check-wai-ops-doors-selftest.sh — check-wai-ops-doors.py can fail. +# +# A check that cannot fail is decoration. This builds a scratch tree shaped like the +# repository (a catalogue export, an MCP tool table, an OpenAPI document, the C header and a +# Swift and a Kotlin source) whose every door names its operations as the catalogue does, and +# asserts that the check passes it and fails each way a door can misname an operation, +# saying which. +set -uo pipefail + +here=$(cd "$(dirname "$0")" && pwd) +check="$here/check-wai-ops-doors.py" +tmp=$(mktemp -d) +trap 'rm -rf "$tmp"' EXIT + +mk() { + rm -rf "$tmp/wai" "$tmp/joule-code" + mkdir -p "$tmp/wai/catalogue" "$tmp/wai/wai-rs/src/bin/wai_cli/mcp" "$tmp/wai/wai-rs/include" \ + "$tmp/joule-code/joule-code-rs/crates/svc" "$tmp/wai/wai-sink-swift/Sources/WaiSink" \ + "$tmp/wai/wai-sink-android/core/src/main/kotlin/wai/sink" + cat > "$tmp/wai/catalogue/ops.json" <<'J' +{"format": "wai.ops/1", "operations": [ + {"id": "envelope.inspect", "placement": "local", "route": null}, + {"id": "receipt.verify", "placement": "local", "route": null}, + {"id": "room.grant", "placement": "service", "route": {"method": "POST", "path": "/v1/rooms/{room}/grants"}} +]} +J + cat > "$tmp/wai/wai-rs/src/bin/wai_cli/mcp/tools.rs" <<'R' +pub(crate) const TOOLS: &[Tool] = &[ + Tool { name: "envelope_inspect", title: "Inspect", run: inspect }, + Tool { + name: "room_grant", + title: "Grant", + run: grant, + }, +]; +R + cat > "$tmp/joule-code/joule-code-rs/crates/svc/openapi.json" <<'J' +{"openapi": "3.1.0", "paths": {"/v1/rooms/{room}/grants": {"post": {"operationId": "room.grant"}, "parameters": []}}} +J + cat > "$tmp/wai/wai-rs/include/wai.h" <<'H' +int32_t wai_envelope_inspect(const uint8_t *p, size_t n); +int32_t wai_envelope_pack(const uint8_t *p, size_t n); +void wai_buffer_free(struct WaiBuffer b); +int32_t wai_image_png_decode(const uint8_t *p); +H + cat > "$tmp/wai/wai-sink-swift/Sources/WaiSink/Envelope.swift" <<'S' +public final class WaiEnvelope: @unchecked Sendable { + public init() {} + public func inspect(_ path: String) -> String { "{" } + public func pack() {} + private func verifyQuietly() {} +} +public final class WaiSink { + public func info() -> String { "" } +} +S + cat > "$tmp/wai/wai-sink-android/core/src/main/kotlin/wai/sink/WaiReceipt.kt" <<'K' +package wai.sink + +class WaiReceipt( + val owner: String, + private val pool: Runnable = Runnable { }, +) { + fun verify(bytes: ByteArray): Boolean = bytes.isNotEmpty() + private fun checkQuietly() {} + companion object { + fun verifyAll(list: List) = list.all { it.isNotEmpty() } + } +} +K +} + +fails=0 +expect() { # expect [text the output must hold] + local want=$1 what=$2 needle=${3:-} out rc + out=$(python3 -I "$check" --root "$tmp" 2>&1); rc=$? + if [ "$want" = pass ] && [ $rc -ne 0 ]; then echo "FAIL $what: the check failed"; printf '%s\n' "$out" | sed 's/^/ /'; fails=$((fails+1)); return; fi + if [ "$want" = fail ] && [ $rc -eq 0 ]; then echo "FAIL $what: the check passed"; printf '%s\n' "$out" | sed 's/^/ /'; fails=$((fails+1)); return; fi + if [ -n "$needle" ] && ! grep -qF -- "$needle" <<<"$out"; then echo "FAIL $what: the output does not say '$needle'"; printf '%s\n' "$out" | sed 's/^/ /'; fails=$((fails+1)); return; fi + echo "ok $what" +} +sub() { # sub : one replacement, which must apply + python3 - "$1" "$2" "$3" <<'P' || { echo "FAIL the fixture edit did not apply: $2"; fails=$((fails+1)); } +import sys +p, a, b = sys.argv[1:] +s = open(p).read() +if s.count(a) != 1: + sys.exit(1) +open(p, "w").write(s.replace(a, b)) +P +} + +mk; expect pass "a tree whose every door names its operations as the catalogue does" "MCP: 2 tools, each an operation" +mk; expect pass "the C door's held names and its library API" "C: 1 functions name operations of the catalogue" +mk; expect pass "a Swift and a Kotlin method that name operations" "Kotlin: 2 methods name operations" + +mk; rm "$tmp/wai/wai-rs/src/bin/wai_cli/mcp/tools.rs" +expect pass "a door with no operation yet says so" "no MCP door yet" + +mk; sub "$tmp/wai/wai-rs/src/bin/wai_cli/mcp/tools.rs" '"envelope_inspect"' '"inspect_envelope"' +expect fail "an MCP tool misnamed" "tool 'inspect_envelope' names inspect.envelope, which the catalogue does not hold" + +mk; sub "$tmp/wai/wai-rs/src/bin/wai_cli/mcp/tools.rs" '"room_grant"' '"dev_room_grant"' +expect fail "an MCP tool that is not noun_verb" "tool 'dev_room_grant' is not noun_verb" + +mk; cat > "$tmp/wai/wai-rs/src/bin/wai_cli/mcp/tools.rs" <<'R' +pub(crate) const TOOLS: &[Tool] = &[ + Tool { op: "envelope.inspect", toolset: Toolset::Core, effect: Effect::Reads, run: inspect, + description: "What an envelope holds." }, + Tool { op: "room.grant", toolset: Toolset::Dev, effect: Effect::DevRoom, run: grant, + description: "A grant." }, +]; +R +expect pass "a tool table that names each tool by its operation" "MCP: 2 tools, each an operation" + +mk; cat > "$tmp/wai/wai-rs/src/bin/wai_cli/mcp/tools.rs" <<'R' +pub(crate) const TOOLS: &[Tool] = &[ + Tool { op: "envelope.inspect", toolset: Toolset::Core, run: inspect, description: "x" }, + Tool { op: "receipt.check", toolset: Toolset::Core, run: check, description: "y" }, +]; +R +expect fail "a tool named by an operation the catalogue does not hold" "tool 'receipt_check' names receipt.check" + +mk; printf 'pub(crate) const TOOLS: &[Tool] = &[];\n' > "$tmp/wai/wai-rs/src/bin/wai_cli/mcp/tools.rs" +expect fail "an MCP tool table the check cannot read" "no tool name could be read" + +mk; sub "$tmp/joule-code/joule-code-rs/crates/svc/openapi.json" '"room.grant"' '"grant"' +expect fail "an OpenAPI operationId misnamed" "operationId 'grant' is not an operation of the catalogue" + +mk; sub "$tmp/joule-code/joule-code-rs/crates/svc/openapi.json" '/v1/rooms/{room}/grants' '/grant' +expect fail "an OpenAPI route that is not the catalogue's" "the catalogue routes room.grant to POST /v1/rooms/{room}/grants" + +mk; sub "$tmp/joule-code/joule-code-rs/crates/svc/openapi.json" '"room.grant"' '"envelope.inspect"' +expect fail "an OpenAPI operation the catalogue places on this machine" "envelope.inspect is placed local, not on a service" + +mk; printf 'int32_t wai_envelope_decode(const uint8_t *p);\n' >> "$tmp/wai/wai-rs/include/wai.h" +expect fail "a C function misnamed" "wai_envelope_decode names envelope.decode, which the catalogue does not hold" + +mk; printf 'int32_t wai_receipt_get(const uint8_t *p);\n' >> "$tmp/wai/wai-rs/include/wai.h" +expect fail "a C function with a near-synonym verb" "wai_receipt_get: the verb get is a near-synonym; the operation verb is show" + +mk; sub "$tmp/wai/wai-sink-swift/Sources/WaiSink/Envelope.swift" 'public func pack() {}' 'public func verify() {}' +expect fail "a Swift method misnamed" "WaiEnvelope.verify names envelope.verify, which the catalogue does not hold" + +mk; sub "$tmp/wai/wai-sink-android/core/src/main/kotlin/wai/sink/WaiReceipt.kt" 'fun verify(bytes' 'fun check(bytes' +expect fail "a Kotlin method misnamed" "WaiReceipt.check names receipt.check, which the catalogue does not hold" + +mk; sub "$tmp/wai/wai-sink-android/core/src/main/kotlin/wai/sink/WaiReceipt.kt" 'fun verifyAll(' 'fun describeAll(' +expect fail "a Kotlin companion method with a near-synonym verb" "WaiReceipt.describeAll: the verb describe is a near-synonym" + +mk; sub "$tmp/wai/wai-sink-android/core/src/main/kotlin/wai/sink/WaiReceipt.kt" 'private fun checkQuietly' 'fun checkQuietly' +expect fail "a private Kotlin method made public under an operation verb" "WaiReceipt.checkQuietly names receipt.check" + +if [ "$fails" -ne 0 ]; then + echo "check-wai-ops-doors-selftest: $fails case(s) failed" + exit 1 +fi +echo "check-wai-ops-doors-selftest: every case held" diff --git a/scripts/check-wai-ops-doors.py b/scripts/check-wai-ops-doors.py new file mode 100755 index 000000000..c062df8f8 --- /dev/null +++ b/scripts/check-wai-ops-doors.py @@ -0,0 +1,292 @@ +#!/usr/bin/env python3 +"""check-wai-ops-doors.py: every door of WAI names its operations as the catalogue does. + +WAI's operations are one catalogue, `noun.verb` (wai/wai-rs/src/catalogue.rs), exported as +wai/catalogue/ops.json by `wai ops export` (CI holds the export to the table with +`wai ops export --check`). Each door names an operation by a mechanical transform of its id: + + MCP tool `noun_verb` (or its `op`) wai/wai-rs/src/bin/wai_cli/mcp/tools.rs + HTTP API operationId `noun.verb` every openapi.json of the services + C function `wai_noun_verb` wai/wai-rs/include/wai.h + Swift type `Wai`, method `…` wai/wai-sink-swift/Sources + Kotlin class `Wai`, fun `…` wai/wai-sink-android/*/src/main + +The command line is held by the `wai` binary's own tests (wai_cli/door.rs). + +What each door is held to: + +- MCP: every tool's name is `noun_verb` of an operation in the catalogue. +- HTTP API: every operationId is an operation of the catalogue that runs on a service + (placement `service` or `both`), at the route the catalogue gives it. +- C, Swift, Kotlin: a name in a catalogue noun's namespace whose verb is an operation verb + (the closed list below) names an operation, and that operation must be in the catalogue; + a near-synonym verb (`get`, `describe`, `info`, `keygen`, `init`, `new`) in a catalogue + noun's namespace is refused. Any other name is library API the catalogue does not list + (the C ABI's codec and sink functions, the native sinks' classes), and is reported as such. + +A door that has no operation of the catalogue yet is reported as such; that is not a +failure. A wrong name is. + + check-wai-ops-doors.py [--root DIR] [--ops FILE] + +Exit 0 when every door holds, 1 with one line a finding otherwise. Standard library only. +""" +import glob +import json +import os +import re +import sys + +# The operation verbs: the closed list of WAI toolchain v1 (naming rule N3). +VERBS = { + "create", "list", "show", "update", "delete", "check", "verify", "run", "build", "export", + "import", "start", "stop", "join", "leave", "grant", "invite", "revoke", "rotate", "tail", + "search", "test", "load", "encode", "decode", "inspect", "wrap", "unwrap", "fetch", + "generate", "promote", "rollback", "lock", "upgrade", "setup", "guide", "stream", +} +# Near-synonyms of an operation verb, and the verb to use. +NEAR = {"get": "show", "describe": "show", "info": "show", "keygen": "create", "init": "create", "new": "create"} + +MCP_TABLE = "wai/wai-rs/src/bin/wai_cli/mcp/tools.rs" +OPENAPI_GLOBS = ["joule-code/joule-code-rs/crates/*/openapi.json", "wai/*/openapi.json", "wai/wai-rs/openapi.json"] +C_HEADER = "wai/wai-rs/include/wai.h" +SWIFT_GLOB = "wai/wai-sink-swift/Sources/**/*.swift" +KOTLIN_GLOB = "wai/wai-sink-android/*/src/main/**/*.kt" + + +def load_ops(path): + with open(path, encoding="utf-8") as f: + doc = json.load(f) + if doc.get("format") != "wai.ops/1": + raise SystemExit(f"{path}: not a catalogue export (format {doc.get('format')!r})") + return {o["id"]: o for o in doc["operations"]} + + +def split_id(i): + n, _, v = i.partition(".") + return n, v + + +def check_mcp(root, ops, findings, notes): + path = os.path.join(root, MCP_TABLE) + if not os.path.exists(path): + notes.append("MCP: no MCP door yet (no tool table); nothing to hold") + return + with open(path, encoding="utf-8") as f: + src = f.read() + # A tool is named outright (`name: "noun_verb"`) or by its operation (`op: "noun.verb"`), + # its name then the operation's with `_` for the dot. + names = [] + for body in re.findall(r"\bTool\s*\{(.*?)\}\s*,", src, re.S): + named = re.search(r'\bname:\s*"([^"]+)"', body) + by_op = re.search(r'\bop:\s*"([^"]+)"', body) + if named: + names.append(named.group(1)) + elif by_op: + names.append(by_op.group(1).replace(".", "_", 1)) + if not names: + findings.append(f"MCP: {MCP_TABLE}: no tool name could be read (`Tool {{ name: \"…\" }}` or `Tool {{ op: \"…\" }}`)") + return + bad = 0 + for t in names: + if t.count("_") != 1: + findings.append(f"MCP: tool {t!r} is not noun_verb of an operation") + bad += 1 + continue + i = t.replace("_", ".") + if i not in ops: + findings.append(f"MCP: tool {t!r} names {i}, which the catalogue does not hold") + bad += 1 + if not bad: + notes.append(f"MCP: {len(names)} tools, each an operation of the catalogue") + + +def openapi_operations(doc): + for path, item in (doc.get("paths") or {}).items(): + for method, op in item.items(): + if method.lower() in ("get", "put", "post", "delete", "patch", "head", "options", "trace") and isinstance(op, dict): + yield method.upper(), path, op.get("operationId") + + +def check_openapi(root, ops, findings, notes): + files = sorted({f for g in OPENAPI_GLOBS for f in glob.glob(os.path.join(root, g))}) + if not files: + notes.append("HTTP API: no OpenAPI document yet; nothing to hold") + return + for f in files: + rel = os.path.relpath(f, root) + with open(f, encoding="utf-8") as fh: + doc = json.load(fh) + n = bad = 0 + for method, path, oid in openapi_operations(doc): + n += 1 + where = f"HTTP API: {rel}: {method} {path}" + if not oid: + findings.append(f"{where}: no operationId") + bad += 1 + elif oid not in ops: + findings.append(f"{where}: operationId {oid!r} is not an operation of the catalogue") + bad += 1 + else: + o = ops[oid] + if o["placement"] not in ("service", "both"): + findings.append(f"{where}: {oid} is placed {o['placement']}, not on a service") + bad += 1 + r = o.get("route") + if r and (r["method"].upper(), r["path"]) != (method, path): + findings.append(f"{where}: the catalogue routes {oid} to {r['method']} {r['path']}") + bad += 1 + if not bad: + notes.append(f"HTTP API: {rel}: {n} operations, each an operation of the catalogue at its route") + + +def held_name(door, where, noun, verb, nouns, ops, findings): + """A name in `noun`'s namespace with `verb`: a finding when it names an operation the + catalogue does not hold, or uses a near-synonym. True when it names an operation.""" + if noun not in nouns: + return False + if verb in NEAR: + findings.append(f"{door}: {where}: the verb {verb} is a near-synonym; the operation verb is {NEAR[verb]}") + return True + if verb not in VERBS: + return False + i = f"{noun}.{verb}" + if i not in ops: + near = sorted(o for o in ops if split_id(o)[1] == verb) + hint = f" (operations with that verb: {', '.join(near)})" if near else "" + findings.append(f"{door}: {where} names {i}, which the catalogue does not hold{hint}") + return True + + +def check_c(root, ops, nouns, findings, notes): + path = os.path.join(root, C_HEADER) + if not os.path.exists(path): + notes.append("C: no header; nothing to hold") + return + with open(path, encoding="utf-8") as f: + src = f.read() + fns = sorted(set(re.findall(r"\b(wai_[a-z0-9_]+)\s*\(", src))) + mapped = other = 0 + before = len(findings) + for fn in fns: + parts = fn.split("_")[1:] + if len(parts) >= 2 and held_name("C", fn, parts[0], parts[1], nouns, ops, findings): + mapped += 1 + else: + other += 1 + if len(findings) == before: + if mapped: + notes.append(f"C: {mapped} functions name operations of the catalogue, each held; {other} are library API") + else: + notes.append(f"C: no operation of the catalogue in this door yet; its {other} functions are library API (codecs, envelopes, the sink)") + + +def camel_verb(name): + m = re.match(r"[a-z]+", name) + return m.group(0) if m else "" + + +TYPE_RE = { + "Swift": re.compile(r"^\s*(?:@\w+\s+)*(public|open)\s+(?:final\s+)?(?:class|struct|enum|actor)\s+(\w+)|^\s*(?:public\s+)?extension\s+(\w+)"), + "Kotlin": re.compile(r"^\s*(?!private|internal)(?:public\s+)?(?:open\s+|abstract\s+|sealed\s+|data\s+|enum\s+|final\s+)*(?:class|object|interface)\s+(\w+)"), +} +FUNC_RE = { + "Swift": re.compile(r"^\s*(?:@\w+\s+)*(?:public|open)\s+(?:static\s+|class\s+|final\s+|override\s+)*func\s+(\w+)"), + "Kotlin": re.compile(r"^\s*(?!.*\b(?:private|internal|protected)\b)(?:public\s+|override\s+|suspend\s+|inline\s+|operator\s+)*fun\s+(?:<[^>]*>\s*)?(\w+)"), +} + + +def sdk_names(lang, src): + """Every (type, method) the source declares public: a method under the type whose body + holds it, a companion object's under its class. A type's body opens at the first line + after its declaration that ends with `{` (a Kotlin primary constructor can take several + lines, and a default argument can hold a lambda's braces).""" + out = [] + stack = [] # (type name, brace depth inside its body) + depth = 0 + pending = None + for line in src.splitlines(): + code = re.sub(r'"(?:\\.|[^"\\])*"', '""', line.split("//")[0]) + name = None + t = TYPE_RE[lang].match(code) + if t: + name = next(g for g in t.groups() if g and g not in ("public", "open")) + elif lang == "Kotlin" and re.match(r"^\s*companion\s+object\b", code) and stack: + name = stack[-1][0] + f = FUNC_RE[lang].match(code) + if f and stack: + out.append((stack[-1][0], f.group(1))) + if name: + pending = name + if pending and code.rstrip().endswith("{"): + stack.append((pending, depth + 1)) + pending = None + for ch in code: + if ch == "{": + depth += 1 + elif ch == "}": + depth -= 1 + while stack and depth < stack[-1][1]: + stack.pop() + return out + + +def check_sdk(lang, root, pattern, ops, nouns, findings, notes): + files = sorted(glob.glob(os.path.join(root, pattern), recursive=True)) + if not files: + notes.append(f"{lang}: no sources; nothing to hold") + return + mapped = other = 0 + before = len(findings) + for f in files: + with open(f, encoding="utf-8") as fh: + src = fh.read() + for typ, meth in sdk_names(lang, src): + if not typ.startswith("Wai") or len(typ) <= 3: + other += 1 + continue + noun = typ[3:].lower() + where = f"{os.path.relpath(f, root)}: {typ}.{meth}" + if held_name(lang, where, noun, camel_verb(meth), nouns, ops, findings): + mapped += 1 + else: + other += 1 + if len(findings) == before: + if mapped: + notes.append(f"{lang}: {mapped} methods name operations of the catalogue, each held; {other} are library API") + else: + notes.append(f"{lang}: no operation of the catalogue in this door yet; its {other} public names are the native sink's API, whose nouns the catalogue does not hold") + + +def main(argv): + root, ops_path = ".", None + i = 0 + while i < len(argv): + if argv[i] == "--root" and i + 1 < len(argv): + root = argv[i + 1] + i += 2 + elif argv[i] == "--ops" and i + 1 < len(argv): + ops_path = argv[i + 1] + i += 2 + else: + print(__doc__.strip().splitlines()[0], file=sys.stderr) + print("usage: check-wai-ops-doors.py [--root DIR] [--ops FILE]", file=sys.stderr) + return 2 + ops = load_ops(ops_path or os.path.join(root, "wai/catalogue/ops.json")) + nouns = {split_id(i)[0] for i in ops} + findings, notes = [], [] + check_mcp(root, ops, findings, notes) + check_openapi(root, ops, findings, notes) + check_c(root, ops, nouns, findings, notes) + check_sdk("Swift", root, SWIFT_GLOB, ops, nouns, findings, notes) + check_sdk("Kotlin", root, KOTLIN_GLOB, ops, nouns, findings, notes) + for n in notes: + print(f"ok {n}") + for f in findings: + print(f"FAIL {f}") + print(f"check-wai-ops-doors: {len(ops)} operations; {len(findings)} finding(s)") + return 1 if findings else 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/wai/CHANGELOG.md b/wai/CHANGELOG.md index 5d4d92804..bc263e36b 100644 --- a/wai/CHANGELOG.md +++ b/wai/CHANGELOG.md @@ -6,6 +6,76 @@ wire format may change and such changes are recorded here. ## v0.4.0 — current +### One catalogue of operations, the agent rules on every command, and the service binaries named `wai-*` + +- **The operation catalogue** (`wai::catalogue`): every operation the toolchain offers, once, + named `noun.verb` (`envelope.inspect`, `project.create`, `receipt.verify`), the verb from + naming rule N3's closed list, with its summary, command line, placement, input and output + JSON Schemas, effect, toolset, stability and the build features it needs. It lists the + operations that work today, `wai breaking` (`compat.check`), `wai conform` + (`conformance.run`, `conformance.list`) and the fleet's `wai enrolment create|list` and + `wai report create|export` (`enrolment.create`, `enrolment.list`, `report.create`, + `report.export`, each placed on the client, against a fleet service) among them. Each entry + says when it waits for confirmation (`confirm`). A command a person already types keeps + its spelling (`wai hello` is `toolchain.test`, `wai stage refine` is `stage.encode`, + `wai http seal-playlist` is `playlist.lock`). **`wai ops list`**, **`wai ops search + ""`** (offline, up to five ranked matches), **`wai ops show `** and **`wai ops + export`**, whose output is [`catalogue/ops.json`](catalogue/ops.json). +- **The command line through it.** A command resolves through the catalogue alone, by the + spelling it has today (`wai inspect`) or by its id's words (`wai envelope inspect`); help and + usage come from the catalogue's entries. +- **The agent rules on every command.** Exit codes 0 to 7 (0 done, 1 a check failed, 2 usage, + 3 permission, 4 confirmation required, 5 not found, 6 target unavailable, 7 internal); + `--json` prints one envelope, `{"wai": "envelope/1", "op", "ok", "data" | "error", "exit"}`, + from one module that `wai breaking` and `wai conform` share; a command's own JSON (`wai create + --list`, `wai generate`, `wai score`, `wai stage apply`, `wai dev --check`, `wai breaking`, + `wai conform`) is now that envelope's `data`. Without `--json` the output is text, on a + terminal or not. `--dry-run` on every operation that is not + read-only writes nothing and says what it would write. An operation that would replace a + file the command line did not name (`out.wai` when `-o` is not given, an earlier + `wai hello`'s files) asks at a terminal and otherwise exits 4 until `--yes` is given. An + unknown command exits 5, not 2. +- **One confirmation flag, `--yes`.** `--confirm`, which `wai conform`, `wai setup-agent` and + the fleet's operations took, is its deprecated spelling: it still works and does the same, + and `wai --help`, each operation's help and the export (`agent_flags`) say it is deprecated. + Every confirming command a run prints names `--yes`. The fleet's operations, `wai conform` + and `wai setup-agent` wait as their entries say: a fleet service changed, or a file that + exists replaced. +- **The fleet's operations on the same rules**: their JSON is the envelope with `--json` (it was + also printed whenever standard output was not a terminal), a dry run's `plan` names each file + and each request to the fleet service, none of them sent, and a confirmation is exit 4 with + `proposed` and the confirming command. `wai conform` and `wai breaking` print text unless + `--json` is given, as every command does. +- **The doors held to it** (`.github/workflows/wai-catalogue.yml`): the export is the table; + `scripts/check-wai-ops-doors.py` holds MCP tool names, the services' OpenAPI operationIds, + the C header and the Swift and Kotlin sinks' public names to the catalogue. `wai mcp`'s tool + table is held to it both ways (every tool an operation the catalogue says it serves, in that + toolset, and every such operation a tool), and so is the room service's OpenAPI document + (every `operationId` at the catalogue's route, every routed operation described). +- **Renamed to the verb rule (N3)**: the MCP tool `package_build`, which carries a file in an + envelope as `wai wrap` does, is `envelope_wrap`; `room_session` is `session_show`; the room + service's challenges are `watch.setup`, `conversation.setup` and `call.setup` (they were + `watchchallenge.create`, `conversationchallenge.create` and `callchallenge.create`); + `wai mcp` is the operation `mcp.start`. Create's export is also `wai app export`. +- **Breaking**, intended: the module that held the naming rule is replaced by the catalogue, + which holds the rule and every name. `api:rust:wai::ops`, `api:rust:wai::ops::VERBS`, + `api:rust:wai::ops::NAMED_AS_IS`, `api:rust:wai::ops::check_id` and + `api:rust:wai::ops::mcp_tool_name` are gone; use `wai::catalogue`, its `VERBS`, its rule + (`findings_of`) and each operation's name in every door (`Op::names`). The names the rule + once let stand are held to it now, so `NAMED_AS_IS` has no replacement. +- **Breaking**, intended: the room service's three challenges keep their routes and take the + names the rule gives them, `watch.setup`, `conversation.setup` and `call.setup`, as their + `operationId`s: `api:openapi:joule-moq-room:GET /watch/challenge`, + `api:openapi:joule-moq-room:GET /msg/challenge` and + `api:openapi:joule-moq-room:GET /call/challenge`. A client generated from the document calls + them by those names; the requests and answers are unchanged. +- **The service binaries are `wai-relay`, `wai-room` and `wai-ingest`**; their crates keep + their names (`joule-moq-relay`, `joule-moq-room`, `joule-moq-ingest`). `wai dev` and the + quickstart archive use the new names. +- **Every example is Apache-2.0** and carries `LICENSE`, which `wai create` writes into the + project. +- `wai` sends nothing off your machine unless you name a remote service. + ### WAI Studio on the web: one front end, its core in WebAssembly, served by `wai dev` at `/_wai/` - **[`wai-studio`](wai-studio/)**: one front end with two hosts, chosen when the page starts. @@ -71,10 +141,10 @@ wire format may change and such changes are recorded here. - **Measurements are not compared**: `wai breaking` leaves out the corpus files it lists as records of a measured run (the external decodes of the format probes, the relay's recorded metering run), each with its reason; the vectors built from them are still compared. -- Both follow the toolchain's agent rules: the JSON envelope `envelope/1` with `--json` or off a - terminal, one exit status for each outcome (0 held, 1 a check failed, 2 usage, 4 a record - would be replaced without `--confirm`, 5 not found, 6 the implementation does not answer, 7 - internal), and `wai conform --dry-run`. +- Both follow the toolchain's agent rules: the JSON envelope `envelope/1` with `--json`, one + exit status for each outcome (0 held, 1 a check failed, 2 usage, 4 a record would be replaced + without `--yes`, 5 not found, 6 the implementation does not answer, 7 internal), and + `wai conform --dry-run`. ### Fleet: an organisation's nodes enrolled by their keys, their records verified, and a report it signs for a period @@ -143,39 +213,40 @@ wire format may change and such changes are recorded here. ### Coding agents: `wai mcp`, `wai setup-agent`, and the room service's API described -- **`wai mcp`** (`agent.mcp`; the feature `mcp`, which the quickstart's `wai` holds): WAI's +- **`wai mcp`** (`mcp.start`; the feature `mcp`, which the quickstart's `wai` holds): WAI's operations as tools for a coding assistant, a Model Context Protocol server (revision 2025-06-18) on standard input and output. Each tool is an operation of the toolchain's catalogue, named by it with `_` for the dot, in toolsets. `core`, the default: `envelope_inspect` (an envelope or a package), `registry_list` and `registry_show` (the registered capabilities), `receipt_verify` (a delivery receipt, a claim with the context the tool takes, a live-room session or a stream audit bundle, by `kind` or read from the - arguments; each answer the verifier's verdict and reason code), `package_build` (a media file + arguments; each answer the verifier's verdict and reason code), `envelope_wrap` (a media file under `wai wrap`'s rules, inside the project directory only), `conformance_list` and `conformance_run` (a corpus's independent verifier), and `docs_search` (the specification's text). `dev`, marked development only, on the target `local` alone: `dev_start` and `dev_stop` (a relay and a room service on loopback), `room_grant` (grants for test principals, refusing any service whose address is not a loopback address, before connecting), `room_join`, - `room_leave` and `room_session`. `--toolset` chooses; `--read-only` offers only the tools that + `room_leave` and `session_show`. `--toolset` chooses; `--read-only` offers only the tools that read or run a verifier. The verdicts agree vector by vector with jwp-conformance, claim-conformance, live-conformance/rooms and live-conformance/audit (`tests/cli_mcp.rs`). - **`wai setup-agent`** (`agent.setup`): writes `AGENTS.md` in a project, adds an import of it to a `CLAUDE.md` the project already has, and registers `wai mcp` (with `--toolset`, those toolsets) in its MCP configuration files, by their documented formats; it changes a file that exists only after its diff is shown and confirmed, has a dry run, and writes nothing outside - the project directory. Off a terminal it answers in the toolchain's JSON envelope + the project directory. With `--json` it answers in the toolchain's JSON envelope (`envelope/1`), with the table's exit statuses: 2 usage, 3 permission, 4 confirmation required (each proposed change and the confirming command given), 5 not found (`tests/cli_setup_agent.rs`). - **`wai mcp` and `wai setup-agent` are verbs of the one table** (`dx::VERBS`) that holds `hello`, - `create`, `dev` and `verify`. `wai::ops` holds the rule operation names keep: `noun.verb`, - lowercase, the verb from a closed list. + `create`, `dev` and `verify`, and operations of the catalogue (`wai::catalogue`): `mcp.start`, + `agent.setup`, every tool's operation, and the room service's operations at their routes, + held to it both ways by the tool table's tests and the room service's contract test. - **The room service's API**: `joule-moq-room/openapi.json` (OpenAPI 3.1) describes every route, request and status of the room service in each of its modes, each operation's `operationId` its name in the catalogue (`room.show`, `room.grant`, `watch.join`, `message.create`, ...), held to the code by a contract test that matches the names, the routers' routes, each operation's statuses against its handlers', and the running service's answers in every mode. - `joule-moq-room dev` runs a development room on loopback for `wai mcp` and for tests. + `wai-room dev` runs a development room on loopback for `wai mcp` and for tests. ### The developer's path: `wai hello`, `create`, `dev` and `verify`; the floor with no system library; the examples diff --git a/wai/README.md b/wai/README.md index 49d4e7bee..22d382bc0 100644 --- a/wai/README.md +++ b/wai/README.md @@ -56,6 +56,7 @@ cargo install --locked --path wai-rs --no-default-features --features quickstart | [`wai-rs/`](wai-rs/) | Rust reference implementation (lib + cdylib + staticlib + C FFI) | | [`examples/`](examples/README.md) | Small runnable projects, one per template of `wai create`: a video room, watching together, messaging and a call, an encoder into a room, packaging, receipts in Rust and Python, native sinks on Apple and Android platforms, remote desktop | | [`quickstart/`](quickstart/README.md) | The prebuilt command line's archive: what it holds, how it is built and released, and its installer | +| [`catalogue/`](catalogue/README.md) | The operation catalogue as JSON (`wai ops export`): every operation of the toolchain, `noun.verb`, and the name each door gives it | | [`REGISTRY.md`](REGISTRY.md), [`registry/capabilities.json`](registry/capabilities.json) | The registry: every registered string with its kind, status, classes and edition, generated from the reference implementation (`wai registry export`), with its JSON Schema and the policy for registering a string | | [`vocab/`](vocab/) | RDF vocabulary: v2 generated from the registry, v1 frozen | | [`extensions/`](extensions/) | Sub-specifications (confidentiality, receipts, worlds, film, quantum operations, integer operators, vector graphics, …) | @@ -76,8 +77,17 @@ cd my-room && wai dev # the relay, the room service and yo wai verify ../wai-hello # the receipts hello wrote, checked offline ``` -[`examples/`](examples/README.md) holds each template, with a test that runs it. The library's -own checks, from a clone: +[`examples/`](examples/README.md) holds each template, with a test that runs it. + +Every command is an operation of one catalogue, named `noun.verb` (`envelope.inspect`, +`project.create`): `wai ops list` lists them, `wai ops search ""` finds one offline, and +`wai ops export` writes the catalogue as JSON ([`catalogue/ops.json`](catalogue/README.md)). +Every command takes `--json` (one JSON envelope on stdout), `--dry-run` where it would write, +and `--yes` where a change waits for confirmation (`--confirm`, deprecated, does the same), and +exits with one of eight codes (`wai --help` lists them). `wai` sends nothing off your machine +unless you name a remote service. + +The library's own checks, from a clone: ```bash cd wai-rs @@ -113,9 +123,9 @@ project already has, and registers `wai mcp` in the project's MCP configuration: and the project-level MCP file of each agent whose directory the project already holds (`--all` writes every one it knows, `--dry-run` lists them, `--toolset core,dev` registers the server with those toolsets). A file that does not exist is created; one that exists is changed only -after its diff is shown and the change confirmed (asked on a terminal, or `--confirm`); without +after its diff is shown and the change confirmed (asked on a terminal, or `--yes`); without either, nothing that exists changes and the command exits 4 with each proposed change and the -command that confirms. It writes nothing outside the project directory. Off a terminal its +command that confirms. It writes nothing outside the project directory. With `--json` its output is the toolchain's JSON envelope. `wai mcp` is a Model Context Protocol server on standard input and output. Each tool is an @@ -123,14 +133,14 @@ operation of the toolchain, named by it with `_` for the dot, and the tools come | Toolset | Tools | |---|---| -| `core` (the default) | `envelope_inspect`, `registry_list`, `registry_show`, `receipt_verify`, `package_build`, `conformance_list`, `conformance_run`, `docs_search` | -| `dev` | `dev_start`, `dev_stop`, `room_grant`, `room_join`, `room_leave`, `room_session` | +| `core` (the default) | `envelope_inspect`, `registry_list`, `registry_show`, `receipt_verify`, `envelope_wrap`, `conformance_list`, `conformance_run`, `docs_search` | +| `dev` | `dev_start`, `dev_stop`, `room_grant`, `room_join`, `room_leave`, `session_show` | | `app` (a build with the feature `app`) | `app_guide`, `app_check`, `app_stream`, `app_export`: Create's, below | `receipt_verify` verifies a delivery receipt, a claim, a live-room session or a stream audit bundle, and answers with the verifier's verdict and reason code. The `dev` tools, marked development only, act on the target `local` alone: they start a relay and a room service on -loopback (`joule-moq-room dev`), mint grants for test principals, join and publish as them, and +loopback (`wai-room dev`), mint grants for test principals, join and publish as them, and read the session for verification; the grant tool refuses any service that is not on a loopback address. `--read-only` offers only the tools that read or run a verifier; the packaging tool writes only inside the project directory. No tool sends anything off the machine. diff --git a/wai/catalogue/README.md b/wai/catalogue/README.md new file mode 100644 index 000000000..60b6e4793 --- /dev/null +++ b/wai/catalogue/README.md @@ -0,0 +1,92 @@ +# The operation catalogue + +Every operation of the WAI toolchain, once. [`ops.json`](ops.json) is written by +`wai ops export` from the table in `wai-rs/src/catalogue.rs`, and CI fails when the two differ +(`.github/workflows/wai-catalogue.yml`). Do not edit it by hand: change the table, then + +```bash +cd wai/wai-rs && cargo run -q --no-default-features --bin wai -- ops export --out ../catalogue/ops.json +``` + +## An operation + +An operation is named `noun.verb`: lowercase ASCII, a singular noun, and a verb from naming +rule N3's closed list (create, list, show, update, delete, check, verify, run, build, export, +import, start, stop, join, leave, grant, invite, revoke, rotate, tail, search, test, load, encode, +decode, inspect, wrap, unwrap, fetch, generate, promote, rollback, lock, upgrade, setup, guide, +stream), never a near-synonym (`get`, `describe` and `info` are `show`; `keygen`, `init` and +`new` are `create`). A command a person already types keeps its spelling as the operation's +command line: `wai hello` is `toolchain.test`, `wai stage refine` is `stage.encode`. Its entry +(format `wai.ops/1`) carries: + +| Member | Meaning | +|---|---| +| `id` | `noun.verb` | +| `summary` | One line, for help, docs and the search | +| `cli`, `aliases` | The command line today, and other spellings; every operation also answers to `wai ` | +| `usage` | The synopsis | +| `placement` | `local`, `client` (on this machine, against a service it names), `service` or `both` | +| `effect` | `reads`, `runs`, `writes-project`, `writes-target` or `destructive` | +| `dry_run` | Whether it takes `--dry-run`: every operation whose effect is not `reads` | +| `confirm` | When it waits for `--yes`: `never`, `when-replacing-an-unnamed-file`, `when-replacing-a-file`, `when-changing-the-target-or-replacing-a-file` or `always` | +| `route` | Its HTTP method and path, for an operation that runs on a service | +| `toolset` | `core`, `dev`, `app`, `quantum` or `maintainer` | +| `stability` | `stable`, `preview` or `internal` | +| `input`, `output`, `stream` | JSON Schemas: its arguments (each with the flag or position that gives it), the `data` of its `--json` envelope, and each line of a streaming operation | +| `since`, `deprecated` | The toolchain version that first carries it; its replacement, once renamed | +| `receipt` | Whether it writes a signed receipt or record that a `verify` operation checks | +| `needs` | The build features it needs (`all` or `any` of them) | +| `names` | Its name in each door | + +## One name in every door + +| Door | `envelope.inspect` | Held by | +|---|---|---| +| CLI | `wai inspect`, `wai envelope inspect` | the `wai` binary's tests (`tests/cli_ops.rs`) | +| MCP | `envelope_inspect` | `scripts/check-wai-ops-doors.py` | +| HTTP API | `operationId: envelope.inspect` | `scripts/check-wai-ops-doors.py` | +| C | `wai_envelope_inspect` | `scripts/check-wai-ops-doors.py` | +| Rust | `wai::envelope::inspect` | | +| JavaScript, Swift, Kotlin | `wai.envelope.inspect` (Swift and Kotlin: `WaiEnvelope.inspect`) | `scripts/check-wai-ops-doors.py` (Swift, Kotlin) | + +A door that does not carry an operation yet is reported as such by the check; a door that +names one differently fails it. In the C header and the native sinks, a name is an operation's +when its noun is a catalogue noun and its verb an operation verb; the codec, envelope and sink +functions that are not operations are library API. + +## Agent rules + +Every command of the catalogue keeps them. + +| Exit | Meaning | +|---|---| +| 0 | Done; every check held | +| 1 | Ran, and a check failed | +| 2 | Usage error or invalid input | +| 3 | Authentication or permission | +| 4 | Confirmation required; nothing changed | +| 5 | Not found (file, target, room, operation, or a build feature) | +| 6 | Target unavailable; safe to retry | +| 7 | Internal error | + +With `--json`, stdout holds one envelope and logs stay on stderr; without it the output is text, +on a terminal or not. One module holds these rules for every command +(`wai-rs/src/bin/wai_cli/dx/envelope.rs`). + +```json +{ "wai": "envelope/1", "op": "envelope.wrap", "ok": false, "exit": 4, + "error": { "code": "confirmation-required", "message": "out.wai exists, and the command line did not name it as the output (-o); nothing was changed", + "hint": "read the proposed changes, then run the confirming command", "retryable": false }, + "proposed": [ { "replace": "out.wai" } ], + "confirm": "wai wrap wai.text.zstd p.bin --yes" } +``` + +A success carries `data`: the command's own JSON where it has one, `{"text": ...}` otherwise. +A failure carries its `exit`; a check that failed (exit 1) carries both `error` and `data`. A dry run adds `"dry_run": true` +and the `plan` of what it would write, and of each request it would send to a service it names +(a fleet service), none of them sent. `--yes` is never implied; off a terminal, or with +`--no-input`, a change that waits for it exits 4. `--confirm` is a deprecated spelling of `--yes` +that does the same; the export lists the agent flags (`agent_flags`) with it as a deprecated +alias. + +`wai` sends nothing off your machine unless you name a remote service. diff --git a/wai/wai-studio/ops.pending.json b/wai/catalogue/ops.json similarity index 92% rename from wai/wai-studio/ops.pending.json rename to wai/catalogue/ops.json index e03ef82a2..41601b3e8 100644 --- a/wai/wai-studio/ops.pending.json +++ b/wai/catalogue/ops.json @@ -36,6 +36,36 @@ "meaning": "internal error" } ], + "agent_flags": [ + { + "flag": "--json", + "summary": "one JSON envelope on stdout: {\"wai\": \"envelope/1\", \"op\", \"ok\", \"data\" | \"error\"}", + "aliases": [] + }, + { + "flag": "--dry-run", + "summary": "write nothing and send nothing; say what would be done (an operation that only reads ignores it)", + "aliases": [] + }, + { + "flag": "--yes", + "summary": "confirm a change that waits for confirmation; off a terminal, without it, exit 4", + "aliases": [ + { + "flag": "--confirm", + "deprecated": { + "since": "0.3.0", + "use_instead": "--yes" + } + } + ] + }, + { + "flag": "--no-input", + "summary": "never ask: a change that waits for confirmation exits 4 unless --yes is given", + "aliases": [] + } + ], "operations": [ { "id": "toolchain.test", @@ -180,7 +210,8 @@ "aliases": [], "usage": [ "wai dev [--port P] [--dir D] [--site DIR] [--watch FILE [--watch-ms MS]] [--call] [--remote]", - " [--messaging] [--e2ee] [--publishers N] [--check] [--for S] [--web DIR] [--relay BIN] [--room BIN]", + " [--show [--audience N]] [--messaging] [--e2ee] [--publishers N] [--check] [--for S] [--web DIR]", + " [--relay BIN] [--room BIN]", " --port P the origin's port on 127.0.0.1 (8080; 0 picks a free one)", " --dir D keys, configuration and logs (.wai/dev)", " --site DIR your page: DIR is served at /, the room client beside it at /room/", @@ -189,6 +220,9 @@ " .ogg, .mp3); --watch-ms MS its duration, read from a WAV file's header", " --call the room is a call: /room/call.html places one, rings, answers", " --remote the room is one host's shared screen; prints the host's invitation", + " --show the room is a show: hosts on camera, an audience that votes in polls", + " the room's clock opens and signs (co-performance); --audience N each", + " viewer's inputs to a poll (1)", " --messaging a conversation beside the room, at /msg-api/: /room/messages.html", " --e2ee every room end-to-end encrypted", " --check start, check every piece through the origin, report, stop", @@ -196,6 +230,8 @@ " --web DIR the room client's files (beside this binary: ../share/wai/web; $WAI_WEB)", " --relay BIN wai-relay (beside this binary, then $PATH; $WAI_RELAY_BIN)", " --room BIN wai-room (beside this binary, then $PATH; $WAI_ROOM_BIN)", + " WAI Studio is served at /_wai/ when its files are found ($WAI_STUDIO, or share/wai/studio", + " beside this binary).", " A wai.json in the current directory (`wai create` writes one) gives the defaults; a flag", " wins over it. Ctrl-C stops everything. With --json and without --check, the first line", " is {\"ready\": true, ...}." @@ -267,6 +303,20 @@ "flag": "--remote" } }, + "show": { + "type": "boolean", + "description": "The room is a show: hosts on camera, an audience that votes in polls.", + "x-cli": { + "flag": "--show" + } + }, + "audience": { + "type": "integer", + "description": "With --show, each viewer's inputs to a poll (1).", + "x-cli": { + "flag": "--audience" + } + }, "messaging": { "type": "boolean", "description": "A conversation beside the room.", @@ -368,11 +418,12 @@ }, { "id": "receipt.verify", - "summary": "Every receipt index (*.wai.receipts) under each path, its group receipts and claims checked offline", + "summary": "Every receipt index (*.wai.receipts) under each path, its group receipts and claims checked offline, and every conformance result and fleet report bundle there", "cli": "wai verify", "aliases": [], "usage": [ - "wai verify ... the receipts under each path (receipt indexes *.wai.receipts), checked offline" + "wai verify ... the receipts (receipt indexes *.wai.receipts), conformance results (*.wai-conform.json)", + " and fleet report bundles (*.wai-fleet.json) under each path, checked offline" ], "placement": "local", "effect": "reads", @@ -394,7 +445,7 @@ "items": { "type": "string" }, - "description": "Files or directories holding receipt indexes.", + "description": "Files or directories holding receipt indexes, conformance results or fleet report bundles.", "x-cli": { "positional": true } @@ -2739,13 +2790,13 @@ "aliases": [], "usage": [ "wai conform --suite --impl [--key ] [--out ] [--root ]", - " [--timeout ] [--name ] [--version ] [--all] `wai conformance list` names the kinds", - " and suites; a record that exists is replaced only with --yes" + " [--timeout ] [--name ] [--version ] [--all] [--yes] `wai conformance list` names", + " the kinds and suites; a record that exists is replaced only with --yes (--confirm, deprecated, is the same)" ], "placement": "local", "effect": "writes-project", "dry_run": true, - "confirm": "when-replacing-an-unnamed-file", + "confirm": "when-replacing-a-file", "route": null, "doors": { "cli": true, @@ -2921,6 +2972,335 @@ "sdk": "wai.conformance.list" } }, + { + "id": "enrolment.create", + "summary": "A node's enrolment signed with the organisation key, written to a file and, with --target, given to a fleet service", + "cli": "wai enrolment create", + "aliases": [], + "usage": [ + "wai enrolment create --role --key [--label L] [--from µs] [--until µs] [--out F]", + " [--target URL] [--yes] a node's enrolment (WFE1), signed with the organisation key, written to F", + " (.wfe without --target) and, with --target, given to the fleet service there; changing the service", + " or replacing a file waits for --yes (--confirm, deprecated, is the same)" + ], + "placement": "client", + "effect": "writes-target", + "dry_run": true, + "confirm": "when-changing-the-target-or-replacing-a-file", + "route": null, + "doors": { + "cli": true, + "mcp": false, + "http": false + }, + "toolset": "core", + "stability": "preview", + "input": { + "type": "object", + "properties": { + "node": { + "type": "string", + "description": "The node's Ed25519 public key, 64 hexadecimal digits.", + "x-cli": { + "positional": true + } + }, + "role": { + "type": "string", + "description": "relay, compositor, ingest, room, ledger or audit.", + "x-cli": { + "flag": "--role" + } + }, + "key": { + "type": "string", + "format": "path", + "description": "The organisation key's file: 32 bytes, or 64 hexadecimal digits.", + "x-cli": { + "flag": "--key" + } + }, + "label": { + "type": "string", + "description": "The node's label, at most 64 bytes.", + "x-cli": { + "flag": "--label" + } + }, + "from": { + "type": "integer", + "description": "The start of the span it holds, microseconds since 1970 (0).", + "x-cli": { + "flag": "--from" + } + }, + "until": { + "type": "integer", + "description": "The end of that span, microseconds since 1970 (none).", + "x-cli": { + "flag": "--until" + } + }, + "out": { + "type": "string", + "format": "path", + "description": "The enrolment's file (.wfe when no --target is given).", + "x-cli": { + "flag": "--out" + } + }, + "target": { + "type": "string", + "description": "A fleet service's http:// address, given the enrolment.", + "x-cli": { + "flag": "--target" + } + } + }, + "required": [ + "node", + "role", + "key" + ], + "additionalProperties": false + }, + "output": { + "type": "object", + "required": [ + "node", + "role", + "organisation", + "digest", + "enrolment" + ], + "description": "The enrolment (WFE1, hexadecimal), its digest, and the file and service it went to" + }, + "stream": null, + "since": "0.3.0", + "deprecated": null, + "receipt": true, + "needs": { + "all": [ + "fleet" + ] + }, + "names": { + "cli": "wai enrolment create", + "mcp": null, + "c": "wai_enrolment_create", + "rust": "wai::enrolment::create", + "sdk": "wai.enrolment.create" + } + }, + { + "id": "enrolment.list", + "summary": "The nodes a fleet service holds enrolled, each with its role, label and how many records it gave", + "cli": "wai enrolment list", + "aliases": [], + "usage": [ + "wai enrolment list --target URL the nodes the fleet service at URL holds enrolled" + ], + "placement": "client", + "effect": "reads", + "dry_run": false, + "confirm": "never", + "route": null, + "doors": { + "cli": true, + "mcp": false, + "http": false + }, + "toolset": "core", + "stability": "preview", + "input": { + "type": "object", + "properties": { + "target": { + "type": "string", + "description": "The fleet service's http:// address.", + "x-cli": { + "flag": "--target" + } + } + }, + "required": [ + "target" + ], + "additionalProperties": false + }, + "output": { + "type": "object", + "required": [ + "enrolments" + ], + "description": "Each node the fleet service holds enrolled: its key, role, label and records" + }, + "stream": null, + "since": "0.3.0", + "deprecated": null, + "receipt": false, + "needs": null, + "names": { + "cli": "wai enrolment list", + "mcp": null, + "c": "wai_enrolment_list", + "rust": "wai::enrolment::list", + "sdk": "wai.enrolment.list" + } + }, + { + "id": "report.create", + "summary": "The fleet report of a period, signed by a fleet service over the enrolments and records it holds", + "cli": "wai report create", + "aliases": [], + "usage": [ + "wai report create --target URL --start µs --end µs [--yes] the fleet report of [start, end), signed by the", + " fleet service at URL; it changes the service, so it waits for --yes (--confirm, deprecated, is the same)" + ], + "placement": "client", + "effect": "writes-target", + "dry_run": true, + "confirm": "always", + "route": null, + "doors": { + "cli": true, + "mcp": false, + "http": false + }, + "toolset": "core", + "stability": "preview", + "input": { + "type": "object", + "properties": { + "target": { + "type": "string", + "description": "The fleet service's http:// address.", + "x-cli": { + "flag": "--target" + } + }, + "start": { + "type": "integer", + "description": "The period's start, microseconds since 1970.", + "x-cli": { + "flag": "--start" + } + }, + "end": { + "type": "integer", + "description": "The period's end, after its start.", + "x-cli": { + "flag": "--end" + } + } + }, + "required": [ + "target", + "start", + "end" + ], + "additionalProperties": false + }, + "output": { + "type": "object", + "required": [ + "report", + "outcome" + ], + "description": "The report's digest and its outcome as extensions/fleet.md §7.3 states it" + }, + "stream": null, + "since": "0.3.0", + "deprecated": null, + "receipt": true, + "needs": null, + "names": { + "cli": "wai report create", + "mcp": null, + "c": "wai_report_create", + "rust": "wai::report::create", + "sdk": "wai.report.create" + } + }, + { + "id": "report.export", + "summary": "A fleet report's bundle fetched from a fleet service, its report recomputed here, then written to a file", + "cli": "wai report export", + "aliases": [], + "usage": [ + "wai report export --target URL [--out F] [--yes] the report's bundle from the fleet service at URL,", + " recomputed by this build and written to F (.wai-fleet.json) for `wai verify`; replacing a file", + " waits for --yes (--confirm, deprecated, is the same)" + ], + "placement": "client", + "effect": "writes-project", + "dry_run": true, + "confirm": "when-replacing-a-file", + "route": null, + "doors": { + "cli": true, + "mcp": false, + "http": false + }, + "toolset": "core", + "stability": "preview", + "input": { + "type": "object", + "properties": { + "digest": { + "type": "string", + "description": "The report's digest, 64 hexadecimal digits.", + "x-cli": { + "positional": true + } + }, + "target": { + "type": "string", + "description": "The fleet service's http:// address.", + "x-cli": { + "flag": "--target" + } + }, + "out": { + "type": "string", + "format": "path", + "description": "The bundle's file (.wai-fleet.json).", + "x-cli": { + "flag": "--out" + } + } + }, + "required": [ + "digest", + "target" + ], + "additionalProperties": false + }, + "output": { + "type": "object", + "required": [ + "file", + "outcome" + ], + "description": "The bundle's file and its outcome, recomputed here" + }, + "stream": null, + "since": "0.3.0", + "deprecated": null, + "receipt": true, + "needs": { + "all": [ + "fleet" + ] + }, + "names": { + "cli": "wai report export", + "mcp": null, + "c": "wai_report_export", + "rust": "wai::report::export", + "sdk": "wai.report.export" + } + }, { "id": "mcp.start", "summary": "WAI's operations as tools for a coding agent: a Model Context Protocol server on standard input and output, in toolsets", @@ -3029,7 +3409,7 @@ "placement": "local", "effect": "writes-project", "dry_run": true, - "confirm": "when-replacing-an-unnamed-file", + "confirm": "when-replacing-a-file", "route": null, "doors": { "cli": true, diff --git a/wai/examples/README.md b/wai/examples/README.md index a39bb308a..1959e4d02 100644 --- a/wai/examples/README.md +++ b/wai/examples/README.md @@ -21,6 +21,9 @@ wai dev | [`android-app`](android-app/README.md) | The native sink's Kotlin API, the one an Android app's player session calls | a JDK 17, the `wai-sink-android` project | | [`remote-desktop`](remote-desktop/README.md) | One machine's screen shared into a room, with control handed over and every input signed | `wai dev`, `wai-remote-host`, a browser | +Every example is Apache-2.0, and carries the license's text as `LICENSE`, which a project +`wai create` makes from it receives with the rest. + Each example's `test/` directory holds the check that it works; it is not part of the project `wai create` writes. `bash ../quickstart/test/run-examples.sh portable|browser|apple|android` runs them, as `.github/workflows/wai-examples.yml` does. diff --git a/wai/examples/android-app/LICENSE b/wai/examples/android-app/LICENSE new file mode 100644 index 000000000..3b60ad360 --- /dev/null +++ b/wai/examples/android-app/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for describing the origin of the Work and + reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Support. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or support. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2026 EBUNDLE contributors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied. See the License for the specific language governing permissions + and limitations under the License. diff --git a/wai/examples/android-app/README.md b/wai/examples/android-app/README.md index 58d2d1603..bd64a6375 100644 --- a/wai/examples/android-app/README.md +++ b/wai/examples/android-app/README.md @@ -28,4 +28,5 @@ README covers it. **With a coding assistant.** Run `wai setup-agent` in this directory: it writes the instructions an assistant reads and connects it to WAI's tools. -**Licence.** Apache-2.0: `wai create` writes the licence beside the project, as `LICENSE`. +**License.** Apache-2.0: the text is in `LICENSE`, and a project `wai create` makes from this +example carries it. diff --git a/wai/examples/apple-app/LICENSE b/wai/examples/apple-app/LICENSE new file mode 100644 index 000000000..3b60ad360 --- /dev/null +++ b/wai/examples/apple-app/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for describing the origin of the Work and + reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Support. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or support. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2026 EBUNDLE contributors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied. See the License for the specific language governing permissions + and limitations under the License. diff --git a/wai/examples/apple-app/README.md b/wai/examples/apple-app/README.md index e2d842232..68ea8e26d 100644 --- a/wai/examples/apple-app/README.md +++ b/wai/examples/apple-app/README.md @@ -30,4 +30,5 @@ that the bundle verifies. The player opens a window: give it an HLS address (the **With a coding assistant.** Run `wai setup-agent` in this directory: it writes the instructions an assistant reads and connects it to WAI's tools. -**Licence.** Apache-2.0: `wai create` writes the licence beside the project, as `LICENSE`. +**License.** Apache-2.0: the text is in `LICENSE`, and a project `wai create` makes from this +example carries it. diff --git a/wai/examples/encoder-ingest/LICENSE b/wai/examples/encoder-ingest/LICENSE new file mode 100644 index 000000000..3b60ad360 --- /dev/null +++ b/wai/examples/encoder-ingest/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for describing the origin of the Work and + reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Support. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or support. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2026 EBUNDLE contributors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied. See the License for the specific language governing permissions + and limitations under the License. diff --git a/wai/examples/encoder-ingest/README.md b/wai/examples/encoder-ingest/README.md index 4c86cb8b9..ed72a8a9d 100644 --- a/wai/examples/encoder-ingest/README.md +++ b/wai/examples/encoder-ingest/README.md @@ -29,4 +29,5 @@ first build fetches the repository and compiles the relay and the gateway. **With a coding assistant.** Run `wai setup-agent` in this directory: it writes the instructions an assistant reads and connects it to WAI's tools. -**Licence.** Apache-2.0: `wai create` writes the licence beside the project, as `LICENSE`. +**License.** Apache-2.0: the text is in `LICENSE`, and a project `wai create` makes from this +example carries it. diff --git a/wai/examples/messaging/LICENSE b/wai/examples/messaging/LICENSE new file mode 100644 index 000000000..3b60ad360 --- /dev/null +++ b/wai/examples/messaging/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for describing the origin of the Work and + reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Support. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or support. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2026 EBUNDLE contributors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied. See the License for the specific language governing permissions + and limitations under the License. diff --git a/wai/examples/messaging/README.md b/wai/examples/messaging/README.md index 04838080c..661e47059 100644 --- a/wai/examples/messaging/README.md +++ b/wai/examples/messaging/README.md @@ -30,4 +30,5 @@ first tab's inbox rings, and answering opens the call, camera and microphone on **With a coding assistant.** Run `wai setup-agent` in this directory: it writes the instructions an assistant reads and connects it to WAI's tools. -**Licence.** Apache-2.0: `wai create` writes the licence beside the project, as `LICENSE`. +**License.** Apache-2.0: the text is in `LICENSE`, and a project `wai create` makes from this +example carries it. diff --git a/wai/examples/package-and-deliver/LICENSE b/wai/examples/package-and-deliver/LICENSE new file mode 100644 index 000000000..3b60ad360 --- /dev/null +++ b/wai/examples/package-and-deliver/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for describing the origin of the Work and + reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Support. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or support. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2026 EBUNDLE contributors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied. See the License for the specific language governing permissions + and limitations under the License. diff --git a/wai/examples/package-and-deliver/README.md b/wai/examples/package-and-deliver/README.md index 7c1579cc9..c0a13a131 100644 --- a/wai/examples/package-and-deliver/README.md +++ b/wai/examples/package-and-deliver/README.md @@ -28,4 +28,5 @@ prints its commands, live packaging among them. **With a coding assistant.** Run `wai setup-agent` in this directory: it writes the instructions an assistant reads and connects it to WAI's tools. -**Licence.** Apache-2.0: `wai create` writes the licence beside the project, as `LICENSE`. +**License.** Apache-2.0: the text is in `LICENSE`, and a project `wai create` makes from this +example carries it. diff --git a/wai/examples/remote-desktop/LICENSE b/wai/examples/remote-desktop/LICENSE new file mode 100644 index 000000000..3b60ad360 --- /dev/null +++ b/wai/examples/remote-desktop/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for describing the origin of the Work and + reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Support. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or support. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2026 EBUNDLE contributors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied. See the License for the specific language governing permissions + and limitations under the License. diff --git a/wai/examples/remote-desktop/README.md b/wai/examples/remote-desktop/README.md index 30a8d1d50..bf4816434 100644 --- a/wai/examples/remote-desktop/README.md +++ b/wai/examples/remote-desktop/README.md @@ -30,4 +30,5 @@ session's records; the independent verifier in the WAI repository checks them: **With a coding assistant.** Run `wai setup-agent` in this directory: it writes the instructions an assistant reads and connects it to WAI's tools. -**Licence.** Apache-2.0: `wai create` writes the licence beside the project, as `LICENSE`. +**License.** Apache-2.0: the text is in `LICENSE`, and a project `wai create` makes from this +example carries it. diff --git a/wai/examples/remote-desktop/host.sh b/wai/examples/remote-desktop/host.sh index 26553835a..8b0966195 100755 --- a/wai/examples/remote-desktop/host.sh +++ b/wai/examples/remote-desktop/host.sh @@ -11,7 +11,7 @@ set -eu ORIGIN=${WAI_DEV_ORIGIN:-http://127.0.0.1:8080} [ -f host.key ] || wai-remote-host key host.key > /dev/null -INVITATION=$(joule-moq-room invite .wai/dev/room-keys.toml 3600) +INVITATION=$(wai-room invite .wai/dev/room-keys.toml 3600) SOURCE=display if [ "${1:-}" = "--pattern" ]; then SOURCE="pattern --encoder ipcm" diff --git a/wai/examples/remote-desktop/test/remote-desktop.test.sh b/wai/examples/remote-desktop/test/remote-desktop.test.sh index 6970374da..938cb3c91 100755 --- a/wai/examples/remote-desktop/test/remote-desktop.test.sh +++ b/wai/examples/remote-desktop/test/remote-desktop.test.sh @@ -5,7 +5,7 @@ # repository's independent verifier must accept the recorded session. # # bash wai/examples/remote-desktop/test/remote-desktop.test.sh -# Needs wai, joule-moq-relay, joule-moq-room and wai-remote-host on PATH (or WAI_RELAY_BIN, +# Needs wai, wai-relay, wai-room and wai-remote-host on PATH (or WAI_RELAY_BIN, # WAI_ROOM_BIN, WAI_WEB as `wai dev` takes them), and python3. # No -e: each check records its own result, and the run goes on to the next. set -uo pipefail diff --git a/wai/examples/verify-receipts/LICENSE b/wai/examples/verify-receipts/LICENSE new file mode 100644 index 000000000..3b60ad360 --- /dev/null +++ b/wai/examples/verify-receipts/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for describing the origin of the Work and + reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Support. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or support. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2026 EBUNDLE contributors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied. See the License for the specific language governing permissions + and limitations under the License. diff --git a/wai/examples/verify-receipts/README.md b/wai/examples/verify-receipts/README.md index 9873d0609..9d5750fbd 100644 --- a/wai/examples/verify-receipts/README.md +++ b/wai/examples/verify-receipts/README.md @@ -28,4 +28,5 @@ command line. **With a coding assistant.** Run `wai setup-agent` in this directory: it writes the instructions an assistant reads and connects it to WAI's tools. -**Licence.** Apache-2.0: `wai create` writes the licence beside the project, as `LICENSE`. +**License.** Apache-2.0: the text is in `LICENSE`, and a project `wai create` makes from this +example carries it. diff --git a/wai/examples/video-room/LICENSE b/wai/examples/video-room/LICENSE new file mode 100644 index 000000000..3b60ad360 --- /dev/null +++ b/wai/examples/video-room/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for describing the origin of the Work and + reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Support. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or support. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2026 EBUNDLE contributors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied. See the License for the specific language governing permissions + and limitations under the License. diff --git a/wai/examples/video-room/README.md b/wai/examples/video-room/README.md index 77d03cad4..03791a3b8 100644 --- a/wai/examples/video-room/README.md +++ b/wai/examples/video-room/README.md @@ -24,4 +24,5 @@ origin; the API is described at https://wai.transaction.science/docs/api/. **With a coding assistant.** Run `wai setup-agent` in this directory: it writes the instructions an assistant reads and connects it to WAI's tools. -**Licence.** Apache-2.0: `wai create` writes the licence beside the project, as `LICENSE`. +**License.** Apache-2.0: the text is in `LICENSE`, and a project `wai create` makes from this +example carries it. diff --git a/wai/examples/watch-together/LICENSE b/wai/examples/watch-together/LICENSE new file mode 100644 index 000000000..3b60ad360 --- /dev/null +++ b/wai/examples/watch-together/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for describing the origin of the Work and + reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Support. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or support. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2026 EBUNDLE contributors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied. See the License for the specific language governing permissions + and limitations under the License. diff --git a/wai/examples/watch-together/README.md b/wai/examples/watch-together/README.md index aaaa7fc95..324316522 100644 --- a/wai/examples/watch-together/README.md +++ b/wai/examples/watch-together/README.md @@ -27,4 +27,5 @@ but a WAV file. Or run `wai dev --watch film.webm --watch-ms 90000`. **With a coding assistant.** Run `wai setup-agent` in this directory: it writes the instructions an assistant reads and connects it to WAI's tools. -**Licence.** Apache-2.0: `wai create` writes the licence beside the project, as `LICENSE`. +**License.** Apache-2.0: the text is in `LICENSE`, and a project `wai create` makes from this +example carries it. diff --git a/wai/extensions/fleet.md b/wai/extensions/fleet.md index c02cdbc9f..e5cfe4734 100644 --- a/wai/extensions/fleet.md +++ b/wai/extensions/fleet.md @@ -494,9 +494,10 @@ in order): | `report.export` | `wai report export --target URL [--out F]` | `GET /v1/fleet/reports/{digest}` | writes a file | | `receipt.verify` | `wai verify ` | `POST /v1/fleet/verify` | reads | -Each takes `--json`, and writes its JSON envelope when standard output is not a terminal; each that -writes takes `--dry-run`, which writes nothing and says what it would write; one that changes the -service waits for `--confirm`, and without it exits with the command that confirms. +Each keeps the toolchain's agent rules: `--json` writes its JSON envelope; each that writes takes +`--dry-run`, which writes nothing, sends nothing and says what it would; one that changes the +service, or would replace a file, waits for `--yes` (`--confirm`, deprecated, is the same), and +without it exits 4 with the proposed change and the command that confirms it. ## 10. Reasons diff --git a/wai/quickstart/README.md b/wai/quickstart/README.md index 81927f8f0..04459ed6b 100644 --- a/wai/quickstart/README.md +++ b/wai/quickstart/README.md @@ -7,8 +7,8 @@ of it is open source under Apache-2.0, built from the open-standards repository | File | What it is | |---|---| | `bin/wai` | The command line: `hello`, `create`, `dev`, `verify`, and the envelope tools (`inspect`, `wrap`, `unwrap`, `encode`, `decode` for the mandatory floor, `capabilities`) | -| `bin/joule-moq-relay` | The relay a room's media goes through: MOQT over QUIC, WebTransport and WebSocket | -| `bin/joule-moq-room` | The room service: a room's keys, its grants, its clock | +| `bin/wai-relay` | The relay a room's media goes through: MOQT over QUIC, WebTransport and WebSocket | +| `bin/wai-room` | The room service: a room's keys, its grants, its clock | | `bin/wai-remote-host` | A remote desktop room's host agent | | `bin/wai-pack` | The packager: a title as WAI objects, HTTP playlists and a MoQ catalog | | `share/wai/web/` | The room client a browser loads: its pages and their WebAssembly | diff --git a/wai/quickstart/build.sh b/wai/quickstart/build.sh index 8f2fc88e1..67a4b7edb 100755 --- a/wai/quickstart/build.sh +++ b/wai/quickstart/build.sh @@ -6,8 +6,8 @@ # Builds, each release and --locked, each from its own workspace: # wai wai/wai-rs, --no-default-features --features quickstart: the mandatory # floor, hello, create, dev and verify; no system codec library -# joule-moq-relay the relay (joule-code/joule-code-rs) -# joule-moq-room the room service +# wai-relay the relay (crate joule-moq-relay, joule-code/joule-code-rs) +# wai-room the room service (crate joule-moq-room) # wai-remote-host a remote desktop room's host agent # wai-pack the packager # and writes /wai--.tar.gz holding bin/ (those five), share/wai/web/ @@ -61,8 +61,8 @@ STAGE=$(mktemp -d) trap 'rm -rf "$STAGE"' EXIT mkdir -p "$STAGE/$NAME/bin" "$STAGE/$NAME/share/wai" need "$ROOT/wai/wai-rs" wai --no-default-features --features quickstart --bin wai -need "$ROOT/joule-code/joule-code-rs" joule-moq-relay -p joule-moq-relay --bin joule-moq-relay -need "$ROOT/joule-code/joule-code-rs/crates/joule-moq-room" joule-moq-room --bin joule-moq-room +need "$ROOT/joule-code/joule-code-rs" wai-relay -p joule-moq-relay --bin wai-relay +need "$ROOT/joule-code/joule-code-rs/crates/joule-moq-room" wai-room --bin wai-room need "$ROOT/joule-code/joule-code-rs/crates/joule-moq-remote" wai-remote-host --bin wai-remote-host need "$ROOT/wai/wai-pack" wai-pack --bin wai-pack cp -R "$WEBDIR" "$STAGE/$NAME/share/wai/web" @@ -78,7 +78,7 @@ if [ "$TRIPLE" = "$HOST" ]; then import json, os, subprocess, sys, tempfile wai, ex = sys.argv[1], sys.argv[2] with tempfile.TemporaryDirectory() as d: - for t in json.loads(subprocess.check_output([wai, "create", "--list", "--json"])): + for t in json.loads(subprocess.check_output([wai, "create", "--list", "--json"]))["data"]: out = os.path.join(d, t["template"]) subprocess.check_output([wai, "create", out, "--template", t["template"]]) for f in t["files"]: diff --git a/wai/quickstart/formulae.py b/wai/quickstart/formulae.py index 7790289aa..8aa9302d5 100644 --- a/wai/quickstart/formulae.py +++ b/wai/quickstart/formulae.py @@ -95,7 +95,7 @@ if win_sha: "homepage": HOME, "license": "Apache-2.0", "architecture": {"64bit": {"url": url(win + ".zip"), "hash": win_sha, "extract_dir": win}}, - "bin": ["bin\\wai.exe", "bin\\joule-moq-relay.exe", "bin\\joule-moq-room.exe", "bin\\wai-remote-host.exe", "bin\\wai-pack.exe"], + "bin": ["bin\\wai.exe", "bin\\wai-relay.exe", "bin\\wai-room.exe", "bin\\wai-remote-host.exe", "bin\\wai-pack.exe"], } pathlib.Path(out, "wai.json").write_text(json.dumps(manifest, indent=2) + "\n") print(f"wrote the formula{' and the manifest' if win_sha else ''} for wai {version}") diff --git a/wai/quickstart/publish.sh b/wai/quickstart/publish.sh index 53bd82c57..61d7bba6f 100755 --- a/wai/quickstart/publish.sh +++ b/wai/quickstart/publish.sh @@ -19,7 +19,7 @@ BASE="$FORGE_API/repos/$FORGE_REPO/releases" release_id() { curl -fsS -H "$AUTH" "$BASE/tags/$TAG" 2>/dev/null | python3 -c 'import json,sys; print(json.load(sys.stdin).get("id", ""))' 2>/dev/null || true; } ID="$(release_id)" if [ -z "$ID" ]; then - BODY="$(python3 -c 'import json,sys; print(json.dumps({"tag_name": sys.argv[1], "name": sys.argv[2], "body": "The WAI command line and what `wai dev` runs, for macOS, Linux and Windows: each archive holds bin/ (wai, joule-moq-relay, joule-moq-room, wai-remote-host, wai-pack), share/wai/web/ (the room client), a README and the licence; each .sha256 file checks its archive (`shasum -a 256 -c`)."}))' "$TAG" "$TITLE")" + BODY="$(python3 -c 'import json,sys; print(json.dumps({"tag_name": sys.argv[1], "name": sys.argv[2], "body": "The WAI command line and what `wai dev` runs, for macOS, Linux and Windows: each archive holds bin/ (wai, wai-relay, wai-room, wai-remote-host, wai-pack), share/wai/web/ (the room client), a README and the licence; each .sha256 file checks its archive (`shasum -a 256 -c`)."}))' "$TAG" "$TITLE")" curl -fsS -H "$AUTH" -H 'content-type: application/json' -X POST "$BASE" -d "$BODY" > /dev/null 2>&1 || true # Builds run side by side; one of them created it if this did not. for _ in 1 2 3 4 5 6; do ID="$(release_id)"; [ -n "$ID" ] && break; sleep 3; done diff --git a/wai/wai-app/e2e/create.e2e.mjs b/wai/wai-app/e2e/create.e2e.mjs index 8bd78aeb5..aec3a01c5 100644 --- a/wai/wai-app/e2e/create.e2e.mjs +++ b/wai/wai-app/e2e/create.e2e.mjs @@ -3,7 +3,7 @@ // poll opened and answered, the app taken away as app.oui and made a project by // `wai create --from app.oui`, and that project checked and run with `wai dev`. // -// node create.e2e.mjs --rt --relay --room +// node create.e2e.mjs --rt --relay --room // --wai --web --out // [--browser ] [--site ] // @@ -232,7 +232,7 @@ async function scenarioBuilt() { const { project, dir } = shared; const ok = wai(["app", "check", "--json"], project); const okv = envelope(ok.out); - check("built: `wai app check` passes on the exported project", ok.code === 0 && okv.ok === true, ((okv.result && okv.result.checked) || []).join("; ")); + check("built: `wai app check` passes on the exported project", ok.code === 0 && okv.ok === true, ((okv.data && okv.data.checked) || []).join("; ")); // Negative controls: the check refuses a project that is not whole. const broken = path.join(dir, "exported-broken"); const edit = (f, from, to) => { const t = fs.readFileSync(f, "utf8"); if (!from.test(t)) throw new Error(`${f}: ${from}`); fs.writeFileSync(f, t.replace(from, to)); }; @@ -248,7 +248,7 @@ async function scenarioBuilt() { mutate(broken); const r = wai(["app", "check", "--json"], broken); const v = envelope(r.out); - const failures = ((v.result && v.result.failures) || []).join(" | "); + const failures = ((v.data && v.data.failures) || []).join(" | "); check(`built: \`wai app check\` refuses a project ${what}`, r.code === 1 && expect.test(failures), failures.slice(0, 200)); } // The project's own development setup: `wai dev` in it, from its wai.json. diff --git a/wai/wai-app/e2e/run.sh b/wai/wai-app/e2e/run.sh index 08eed954e..9c00bded8 100755 --- a/wai/wai-app/e2e/run.sh +++ b/wai/wai-app/e2e/run.sh @@ -3,7 +3,7 @@ # # wai/wai-app/e2e/run.sh [--only create,built,refused,own,agent] # -# 1. joule-moq-relay and joule-moq-room (native, release), and `wai` with its feature `app` +# 1. wai-relay and wai-room (crates joule-moq-relay and joule-moq-room; native, release), and `wai` with its feature `app` # (wai/wai-rs, in a target directory of its own: two workspaces build wai-rs differently); # 2. the WebAssembly packages, through wasm-bindgen's web target, each with its own --out-name, # into the runtime directory: joule-moq-web (room/moq/pkg/wai_room_moq), wai-live-web @@ -35,9 +35,9 @@ mkdir -p "$OUT/results" "$RT" step() { echo "== $*"; } -step "native: joule-moq-relay, joule-moq-room, wai (feature app)" -(cd "$JC" && cargo build $LOCKED --release -p joule-moq-relay --bin joule-moq-relay) -(cd "$JC/crates/joule-moq-room" && cargo build $LOCKED --release --bin joule-moq-room) +step "native: wai-relay, wai-room, wai (feature app)" +(cd "$JC" && cargo build $LOCKED --release -p joule-moq-relay --bin wai-relay) +(cd "$JC/crates/joule-moq-room" && cargo build $LOCKED --release --bin wai-room) WAI_TARGET="$TARGET/wai-app-cli" (cd "$ROOT/wai/wai-rs" && CARGO_TARGET_DIR="$WAI_TARGET" cargo build $LOCKED --release --no-default-features --features app --bin wai) @@ -75,8 +75,8 @@ browser=() step "end-to-end" node "$HERE/create.e2e.mjs" \ --rt "$RT" \ - --relay "$TARGET/release/joule-moq-relay" \ - --room "$TARGET/release/joule-moq-room" \ + --relay "$TARGET/release/wai-relay" \ + --room "$TARGET/release/wai-room" \ --wai "$WAI_TARGET/release/wai" \ --web "$OUT/web" \ --out "$OUT/results" \ diff --git a/wai/wai-rs/Cargo.toml b/wai/wai-rs/Cargo.toml index fd68611b5..1d268ce7a 100644 --- a/wai/wai-rs/Cargo.toml +++ b/wai/wai-rs/Cargo.toml @@ -1507,6 +1507,13 @@ name = "cli_dx" path = "tests/cli_dx.rs" required-features = ["quickstart"] +# The operation catalogue through the binary: `wai ops`, help, the exit codes, the JSON +# envelope, --dry-run on every operation that is not read-only, and --yes. +[[test]] +name = "cli_ops" +path = "tests/cli_ops.rs" +required-features = ["quickstart"] + # `wai breaking` needs no feature; `wai conform` runs the reference verifiers, the score # renderer and the origin of this crate as the implementations under test. [[test]] diff --git a/wai/wai-rs/src/bin/wai.rs b/wai/wai-rs/src/bin/wai.rs index 71323fccd..846358033 100644 --- a/wai/wai-rs/src/bin/wai.rs +++ b/wai/wai-rs/src/bin/wai.rs @@ -5,17 +5,23 @@ //! (AVIF/JPEG-XL/PNG/Opus/FLAC/AV1/zstd/XZ). Designed for scripting and quick //! verification; production paths go through the Rust API or the C FFI. //! +//! Every command is an operation of the catalogue (`wai::catalogue`), named `noun.verb`, +//! and the command line reaches it only through the catalogue (`wai_cli/door.rs`): its +//! words, its usage and its help come from there, and so do the agent rules every command +//! keeps (`wai_cli/agent.rs`: the exit codes, `--json`, `--dry-run`, `--yes`). `wai ops` +//! lists, exports and searches the catalogue. +//! //! Every command except `encode` and `decode` builds without the `codecs` //! feature (no system codec libraries), which is how CI runs it. With the `floor` //! feature alone, `encode` writes PNG and zstd and `decode` opens the mandatory floor -//! (PNG, FLAC, zstd), still with no system library. `up` (a local relay, room service +//! (PNG, FLAC, zstd), still with no system library. `dev` (a local relay, room service //! and room page on one origin) and `create` (a new project from an example) need no //! feature. `generate` (make a //! `wai.generate.spectral` picture natively and print its SHA-256) needs the feature //! `generate`, and `score build|render` (write a `WSCR` from integer note fields, //! render one and print its digests) the feature `score`; neither needs a codec library. +//! `wai` sends nothing off the machine unless a command names a remote service. -use std::env; use std::fs; use std::process::ExitCode; @@ -54,11 +60,19 @@ mod score; #[path = "wai_cli/encode.rs"] mod platform_encode_cli; -// Every verb kept in a module of its own, named in one table (dx::VERBS): the developer's -// path (`hello`, `create`, `dev`, `verify`) and the agent door (`mcp`, `setup-agent`). +// The developer's path (`hello`, `create`, `dev`, `verify`) and the agent door (`mcp`, +// `setup-agent`), one module an operation, their implementations by catalogue id in dx::VERBS. #[path = "wai_cli/dx/mod.rs"] mod dx; +// The command line's door to the catalogue: dispatch, help and `wai ops`. +#[path = "wai_cli/door.rs"] +mod door; + +// Every file a command writes, through one place, so `--dry-run` writes none. +#[path = "wai_cli/out.rs"] +mod out; + #[cfg(feature = "http")] #[path = "wai_cli/http.rs"] mod http; @@ -69,51 +83,6 @@ mod http_vectors; #[path = "wai_cli/http_playlists.rs"] mod http_playlists; -fn die(msg: impl std::fmt::Display) -> ExitCode { - eprintln!("error: {msg}"); - ExitCode::from(2) -} - -fn usage() { - eprintln!("wai encode [-o out.wai] [-q quality]"); - eprintln!("wai decode -o "); - eprintln!("wai inspect "); - eprintln!("wai wrap [-o out.wai] [--fallback ] [--intent ]"); - eprintln!(" [--companion ]... WAI2: companions bound to the payload"); - eprintln!("wai unwrap [-o payload] [--component ] carry/extract ANY registered codec's bytes"); - eprintln!("wai capabilities list registered capabilities + tier + license + replicate class + role"); - eprintln!("wai registry export [--root ] [--check]"); - eprintln!(" write (or, with --check, compare) registry/capabilities.json, its schema,"); - eprintln!(" vocab/v2.* and the generated table of REGISTRY.md"); - eprintln!("wai prior-set print the listing of a parameter set (wai.prior.set) and its pin"); - eprintln!("wai stage refine|apply|inspect|pack|explode … staged-delivery layers (a build with `staged`,"); - eprintln!(" `video_int`, `audio_int` and `splat_int`); `wai stage` alone prints its usage"); - eprintln!("wai http publish|codecs|origin|vectors|bench … HTTP delivery (a build with `http`);"); - eprintln!(" `wai http` alone prints its usage"); - eprintln!("wai generate --model (--prompt --seed --steps | --instr )"); - eprintln!(" --size WxH -o [--json] make a wai.generate.spectral picture (RGB8) and"); - eprintln!(" print its SHA-256; --sizes WxH,... --write-instr writes the instruction"); - eprintln!(" object of form v0 (SPEC \u{a7}5). A build with `generate`"); - eprintln!("wai score build -o [--json] a WSCR from integer note fields"); - eprintln!("wai score render [--pcm ] [--json] the reference mixdown and its digests"); - eprintln!(" (pcm_sha256, mixdown_blake3, score_hash). A build with `score`"); - #[cfg(feature = "platform_encode")] - platform_encode_cli::usage(); - #[cfg(not(feature = "platform_encode"))] - eprintln!("wai encode avc|hevc …, wai encoders, wai encode-record verify … a build with `platform_encode`"); - eprintln!(); - dx::usage(); - eprintln!(); - eprintln!("encode and decode need a build with the `codecs` feature; a build with `floor` alone"); - eprintln!("encodes png and zstd and decodes png, flac and zstd (the mandatory floor, no system library)."); - eprintln!("codecs: png jpeg avif jxl jxl-lossless opus flac av1 av1-lossless zstd xz"); - eprintln!("inputs:"); - eprintln!(" image codecs: .rgb (with .meta containing 'H W' ASCII) or PNG/JPEG file"); - eprintln!(" audio codecs: .f32 (raw mono float32) with .meta containing sample-rate"); - eprintln!(" video codecs: .rgb-seq directory of frame files + .meta with 'N H W fps_num fps_den'"); - eprintln!(" text codecs: any file"); -} - /// The capabilities this binary decodes natively: the crate's sink set /// (`wai::sink_capabilities`) in a build with the `codecs` feature, the mandatory /// floor (`wai::floor_capabilities`: PNG, FLAC, zstd) in a build with `floor` alone, @@ -293,8 +262,9 @@ fn encode(codec: &str, input: &str, out: &str, q: u8) -> Result<(), String> { }; let manifest = Manifest::new(media_for(cap), "replicate", ModelRequirement::new(cap), Conditioning::new(kind)); - let n = Wai::new(manifest, payload).write(out).map_err(|e| e.to_string())?; - println!("encoded → {out} ({} bytes, capability {})", n, cap); + let bytes = Wai::new(manifest, payload).to_bytes().map_err(|e| e.to_string())?; + out::write(out, &bytes).map_err(|e| format!("{out}: {e}"))?; + println!("encoded → {out} ({} bytes, capability {})", bytes.len(), cap); Ok(()) } @@ -325,48 +295,48 @@ fn decode(input: &str, out: &str) -> Result<(), String> { match cap { c if c == CAP_IMAGE_PNG => { let (rgb, h, w) = image::png_decode(&wai.payload).map_err(|e| format!("{:?}", e))?; - fs::write(out, &rgb).map_err(|e| e.to_string())?; - fs::write(format!("{out}.meta"), format!("{h} {w}\n")).map_err(|e| e.to_string())?; + out::write(out, &rgb).map_err(|e| e.to_string())?; + out::write(format!("{out}.meta"), format!("{h} {w}\n")).map_err(|e| e.to_string())?; } #[cfg(feature = "codecs")] c if c == CAP_IMAGE_JPEG => { let (rgb, h, w) = image::jpeg_decode(&wai.payload).map_err(|e| format!("{:?}", e))?; - fs::write(out, &rgb).map_err(|e| e.to_string())?; - fs::write(format!("{out}.meta"), format!("{h} {w}\n")).map_err(|e| e.to_string())?; + out::write(out, &rgb).map_err(|e| e.to_string())?; + out::write(format!("{out}.meta"), format!("{h} {w}\n")).map_err(|e| e.to_string())?; } #[cfg(feature = "codecs")] c if c == CAP_IMAGE_AVIF => { let (rgb, h, w) = image::avif_decode(&wai.payload).map_err(|e| format!("{:?}", e))?; - fs::write(out, &rgb).map_err(|e| e.to_string())?; - fs::write(format!("{out}.meta"), format!("{h} {w}\n")).map_err(|e| e.to_string())?; + out::write(out, &rgb).map_err(|e| e.to_string())?; + out::write(format!("{out}.meta"), format!("{h} {w}\n")).map_err(|e| e.to_string())?; } #[cfg(feature = "codecs")] c if c == CAP_IMAGE_JXL => { let (rgb, h, w) = image::jxl_decode(&wai.payload).map_err(|e| format!("{:?}", e))?; - fs::write(out, &rgb).map_err(|e| e.to_string())?; - fs::write(format!("{out}.meta"), format!("{h} {w}\n")).map_err(|e| e.to_string())?; + out::write(out, &rgb).map_err(|e| e.to_string())?; + out::write(format!("{out}.meta"), format!("{h} {w}\n")).map_err(|e| e.to_string())?; } #[cfg(feature = "codecs")] c if c == CAP_AUDIO_OPUS => { let (samples, sr) = audio::opus_decode(&wai.payload).map_err(|e| format!("{:?}", e))?; let bytes: Vec = samples.iter().flat_map(|f| f.to_le_bytes()).collect(); - fs::write(out, &bytes).map_err(|e| e.to_string())?; - fs::write(format!("{out}.meta"), format!("{sr}\n")).map_err(|e| e.to_string())?; + out::write(out, &bytes).map_err(|e| e.to_string())?; + out::write(format!("{out}.meta"), format!("{sr}\n")).map_err(|e| e.to_string())?; } c if c == CAP_AUDIO_FLAC => { let (samples, sr) = audio::flac_decode(&wai.payload).map_err(|e| format!("{:?}", e))?; let bytes: Vec = samples.iter().flat_map(|f| f.to_le_bytes()).collect(); - fs::write(out, &bytes).map_err(|e| e.to_string())?; - fs::write(format!("{out}.meta"), format!("{sr}\n")).map_err(|e| e.to_string())?; + out::write(out, &bytes).map_err(|e| e.to_string())?; + out::write(format!("{out}.meta"), format!("{sr}\n")).map_err(|e| e.to_string())?; } c if c == CAP_TEXT_ZSTD => { let d = text::zstd_decode(&wai.payload).map_err(|e| format!("{:?}", e))?; - fs::write(out, &d).map_err(|e| e.to_string())?; + out::write(out, &d).map_err(|e| e.to_string())?; } #[cfg(feature = "codecs")] c if c == CAP_TEXT_XZ => { let d = text::xz_decode(&wai.payload).map_err(|e| format!("{:?}", e))?; - fs::write(out, &d).map_err(|e| e.to_string())?; + out::write(out, &d).map_err(|e| e.to_string())?; } #[cfg(feature = "codecs")] c if c == CAP_VIDEO_AV1 || c == CAP_VIDEO_AV1_LOSSLESS => { @@ -374,7 +344,7 @@ fn decode(input: &str, out: &str) -> Result<(), String> { // decode to far more than fits in memory at once. use std::io::Write as _; let hd = video::av1_header(&wai.payload).map_err(|e| format!("{:?}", e))?; - let mut file = std::io::BufWriter::new(fs::File::create(out).map_err(|e| e.to_string())?); + let mut file = std::io::BufWriter::new(out::create(out).map_err(|e| e.to_string())?); let mut n = 0u32; video::av1_decode_each(&wai.payload, |rgb, w, h| { if (w, h) != (hd.w, hd.h) { @@ -385,7 +355,7 @@ fn decode(input: &str, out: &str) -> Result<(), String> { file.write_all(rgb).map_err(|e| video::VideoError(e.to_string())) }).map_err(|e| format!("{:?}", e))?; file.flush().map_err(|e| e.to_string())?; - fs::write(format!("{out}.meta"), + out::write(format!("{out}.meta"), format!("{n} {} {} {} {}\n", hd.h, hd.w, hd.fps_num, hd.fps_den)) .map_err(|e| e.to_string())?; } @@ -692,7 +662,9 @@ fn wrap(cap: &str, input: &str, out: &str, fallback: Option<&str>, "{e}: a layer names what it refines; `wai stage refine` writes one (extensions/staged-delivery.md)"), _ => format!("{e} (SPEC \u{a7}5)"), })?; - let n = envelope.write(out).map_err(|e| e.to_string())?; + let bytes = envelope.to_bytes().map_err(|e| e.to_string())?; + out::write(out, &bytes).map_err(|e| format!("{out}: {e}"))?; + let n = bytes.len(); let tier = wai::codecs::capability_tier(cap).map(|t| format!("{t:?}")).unwrap_or_else(|| "?".into()); Ok(Wrapped { lines: vec![ @@ -804,7 +776,9 @@ fn wrap_with_companions(cap: &str, payload: Vec, out: &str, fallback: Option envelope.check_int_payload_framing().map_err(|(i, e)| format!("not writing {out}: component {i}: {e}"))?; envelope.check_component_roles() .map_err(|e| format!("{e} (SPEC \u{a7}5, \u{a7}7.1)"))?; - let n = envelope.write(out).map_err(|e| e.to_string())?; + let bytes = envelope.to_bytes().map_err(|e| e.to_string())?; + out::write(out, &bytes).map_err(|e| format!("{out}: {e}"))?; + let n = bytes.len(); let mut lines = vec![format!("wrapped → {out} ({n} B WAI2 envelope, {} components, intent {intent})", envelope.renditions.len())]; lines.extend(component_lines(&envelope)); @@ -882,7 +856,7 @@ fn unwrap(input: &str, out: Option<&str>, component: Option<&str>) -> Result<(), } println!(" payload {} bytes", w.payload.len()); if let Some(out) = out { - std::fs::write(out, &w.payload).map_err(|e| format!("write {out}: {e}"))?; + out::write(out, &w.payload).map_err(|e| format!("write {out}: {e}"))?; println!(" → wrote payload to {out} (byte-identical to the wrapped input)"); } Ok(()) @@ -917,7 +891,7 @@ fn unwrap_component(input: &str, bytes: &[u8], out: Option<&str>, } println!(" component [{idx}] {} · {} bytes", meta.capability, payload.len()); if let Some(out) = out { - std::fs::write(out, payload).map_err(|e| format!("write {out}: {e}"))?; + out::write(out, payload).map_err(|e| format!("write {out}: {e}"))?; println!(" → wrote component {idx} to {out} (byte-identical to the wrapped input)"); } Ok(()) @@ -983,9 +957,9 @@ fn registry(args: &[String]) -> Result { stale.push(rel.clone()); } else { if let Some(dir) = path.parent() { - fs::create_dir_all(dir).map_err(|e| format!("create {}: {e}", dir.display()))?; + out::create_dir_all(dir).map_err(|e| format!("create {}: {e}", dir.display()))?; } - fs::write(&path, want).map_err(|e| format!("write {}: {e}", path.display()))?; + out::write(&path, want).map_err(|e| format!("write {}: {e}", path.display()))?; println!("wrote {}", path.display()); } } @@ -1001,84 +975,7 @@ fn registry(args: &[String]) -> Result { Ok(true) } +/// Every command line goes through the catalogue's door. fn main() -> ExitCode { - let args: Vec = env::args().skip(1).collect(); - if args.is_empty() { - usage(); - return ExitCode::from(2); - } - if let Some(r) = dx::dispatch(&args) { - return match r { - Ok(code) => ExitCode::from(code), - Err(e) => die(e), - }; - } - let r = match args[0].as_str() { - #[cfg(feature = "platform_encode")] - "encode" if args.len() >= 3 && matches!(args[1].as_str(), "avc" | "hevc") => platform_encode_cli::encode(&args[1..]), - #[cfg(not(feature = "platform_encode"))] - "encode" if args.len() >= 3 && matches!(args[1].as_str(), "avc" | "hevc") => { - Err("built without platform_encode: `wai encode avc|hevc` needs a build with the feature `platform_encode`".into()) - } - #[cfg(feature = "platform_encode")] - "encoders" => platform_encode_cli::encoders(&args[1..]), - #[cfg(feature = "platform_encode")] - "encode-record" => match platform_encode_cli::record_cmd(&args[1..]) { - Ok(true) => Ok(()), - Ok(false) => return ExitCode::from(1), - Err(e) => Err(e), - }, - #[cfg(not(feature = "platform_encode"))] - "encoders" | "encode-record" => Err("built without platform_encode: this command needs a build with the feature `platform_encode`".into()), - "encode" if args.len() >= 3 => { - let out = opt(&args[3..], "-o").map(String::from).unwrap_or("out.wai".into()); - let q: u8 = opt(&args[3..], "-q").and_then(|s| s.parse().ok()).unwrap_or(75); - encode(&args[1], &args[2], &out, q) - } - "decode" if args.len() >= 4 => decode(&args[1], &args[3]), - "wrap" if args.len() >= 3 => { - let out = opt(&args[3..], "-o").map(String::from).unwrap_or("out.wai".into()); - match companion_args(&args[3..]) { - Ok(comps) => wrap(&args[1], &args[2], &out, opt(&args[3..], "--fallback"), - opt(&args[3..], "--intent"), &comps) - .map(|w| w.lines.iter().for_each(|l| println!("{l}"))), - Err(e) => Err(e), - } - } - "unwrap" if args.len() >= 2 => unwrap(&args[1], opt(&args[2..], "-o"), - opt(&args[2..], "--component")), - "inspect" if args.len() >= 2 => inspect(&args[1]), - "capabilities" => capabilities(), - "registry" => match registry(&args[1..]) { - Ok(true) => Ok(()), - Ok(false) => return ExitCode::from(1), - Err(e) => Err(e), - }, - "prior-set" if args.len() >= 2 => prior_set(&args[1]), - #[cfg(all(feature = "staged", feature = "video_int", feature = "audio_int", feature = "splat_int"))] - "stage" => stage::main(&args[1..]), - #[cfg(not(all(feature = "staged", feature = "video_int", feature = "audio_int", feature = "splat_int")))] - "stage" => Err("`wai stage` needs a build with the features staged, video_int, audio_int and splat_int".into()), - #[cfg(feature = "http")] - "http" => match http::main(&args[1..]) { - Ok(true) => Ok(()), - Ok(false) => return ExitCode::from(1), - Err(e) => Err(e), - }, - #[cfg(not(feature = "http"))] - "http" => Err("built without http: `wai http` needs a build with the feature `http`".into()), - #[cfg(feature = "generate")] - "generate" => generate::main(&args[1..]), - #[cfg(not(feature = "generate"))] - "generate" => Err("built without generate: `wai generate` needs a build with the feature `generate`".into()), - #[cfg(feature = "score")] - "score" => score::main(&args[1..]), - #[cfg(not(feature = "score"))] - "score" => Err("built without score: `wai score` needs a build with the feature `score`".into()), - _ => { usage(); return ExitCode::from(2); } - }; - match r { - Ok(()) => ExitCode::SUCCESS, - Err(e) => die(e), - } + door::main() } diff --git a/wai/wai-rs/src/bin/wai_cli/door.rs b/wai/wai-rs/src/bin/wai_cli/door.rs new file mode 100644 index 000000000..5800bde07 --- /dev/null +++ b/wai/wai-rs/src/bin/wai_cli/door.rs @@ -0,0 +1,672 @@ +//! The command line's door to the operation catalogue (`wai::catalogue`). +//! +//! A command line is resolved through the catalogue alone: its words name an operation (its +//! CLI spelling, an alias, or its mechanical form `wai `), and the operation's +//! implementation is found by its id in [`impls`]. A command the catalogue does not hold +//! cannot run, and help is written from the catalogue's entries. This module's tests hold +//! the two tables to each other: every implementation names an operation of the catalogue, +//! and every operation with a command line has exactly one implementation. +//! +//! The agent rules (`agent`) apply to every operation here: the exit codes, `--json`, +//! `--dry-run` and `--yes`. An operation that prints text runs again as a child for +//! `--json`, its output carried in the envelope's `data`; one with a JSON form of its own +//! carries that. + +use crate::dx::envelope::{self as agent, Ctx, OpError}; +use crate::out; +use serde_json::{Value, json}; +use std::process::{Command, ExitCode, Stdio}; +use wai::catalogue::{self, Effect, Op, OPS}; + +/// An operation's implementation: its arguments after the command's words, `Ok(true)` when +/// everything it checked held, `Ok(false)` when it ran and a check did not (exit 1). +pub type Run = fn(&[String]) -> Result; + +/// What an operation prints for `--json`. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Json { + /// Text: carried as `{"text": ...}`. + Text, + /// A JSON form of its own, asked for with `--json`. + Own, + /// Its own JSON form when this flag is among its arguments; text otherwise. + OwnWith(&'static str), + /// It prints the envelope itself. + Native, + /// It streams JSON Lines itself, unless this flag is among its arguments, when it has a + /// JSON form of its own. + StreamUnless(&'static str), +} + +/// One implementation, by its operation's id. +pub struct Impl { + pub id: &'static str, + pub run: Run, + pub json: Json, +} + +/// Every implementation: the developer's path (`dx::VERBS`), then the rest. +pub fn impls() -> impl Iterator { + crate::dx::VERBS.iter().chain(IMPLS.iter()) +} + +fn impl_of(id: &str) -> Option<&'static Impl> { + impls().find(|i| i.id == id) +} + +/// Whether `a` is a flag every operation takes (`catalogue::AGENT_FLAGS`), read wherever it is +/// on the command line. +fn global(a: &str) -> bool { + catalogue::is_agent_flag(a) +} + +pub fn main() -> ExitCode { + let raw: Vec = std::env::args().skip(1).collect(); + let child = std::env::var_os(agent::CHILD_ENV).is_some(); + let has = |f: &str| raw.iter().any(|a| a == f); + let c = Ctx { + // A child's `--json` is its operation's own flag, given by its parent. + json: !child && has("--json"), + dry_run: has("--dry-run"), + // `--confirm` is the deprecated spelling of `--yes`, and does the same. + yes: has("--yes") || has("--confirm"), + no_input: has("--no-input"), + child, + argv: raw.iter().filter(|a| *a != "--json").cloned().collect(), + }; + let json = c.json; + out::set_dry_run(c.dry_run); + agent::set(c); + let args: Vec = raw.iter().filter(|a| !global(a) || (child && *a == "--json")).cloned().collect(); + + match args.first().map(String::as_str) { + None => { + eprint!("{}", usage_text()); + return fail(None, &OpError::usage("name an operation: `wai ops list` lists them, `wai --help` describes them", ""), json); + } + Some("--help" | "-h") => { + print!("{}", usage_text()); + return ExitCode::SUCCESS; + } + Some("help") => { + match catalogue::resolve(&args[1..]) { + Some((op, _)) => print!("{}", op_help(op)), + None => print!("{}", usage_text()), + } + return ExitCode::SUCCESS; + } + _ => {} + } + let Some((op, n)) = catalogue::resolve(&args) else { + return fail(None, &unknown(&args), json); + }; + let rest = &args[n..]; + if rest.iter().any(|a| a == "--help" || a == "-h") { + print!("{}", op_help(op)); + return ExitCode::SUCCESS; + } + if !op.available() { + return fail(Some(op.id), ¬_built(op), json); + } + let Some(imp) = impl_of(op.id) else { + return fail(Some(op.id), &OpError::internal(format!("{} has no implementation in this binary", op.id)), json); + }; + if json { + match imp.json { + Json::Native => {} + Json::StreamUnless(f) if !rest.iter().any(|a| a == f) => { + let mut with = rest.to_vec(); + with.push("--json".into()); + return finish(op, imp, run(imp, &with), json); + } + Json::Text | Json::Own | Json::OwnWith(_) | Json::StreamUnless(_) => return enveloped(op, imp, rest), + } + } + finish(op, imp, run(imp, rest), json) +} + +/// Run an implementation, a panic in it an internal error. +fn run(imp: &Impl, args: &[String]) -> Result { + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| (imp.run)(args))) + .unwrap_or_else(|_| Err(OpError::internal(format!("{} stopped on an internal error (above)", imp.id)))) +} + +/// The exit of an operation run here, with what a dry run would have done (an operation that +/// prints its own envelope says that itself). +fn finish(op: &Op, imp: &Impl, r: Result, json: bool) -> ExitCode { + match r { + Ok(ok) => { + if out::dry_run() && op.effect != Effect::Reads && imp.json != Json::Native { + if agent::ctx().child { + eprintln!("{}{}", agent::TRAILER_PLAN, Value::Array(out::plan())); + } else if !json { + let lines = out::plan_lines(); + if lines.is_empty() { + println!("dry run: nothing would be written"); + } else { + println!("dry run: nothing was written. Without --dry-run, wai would:"); + for l in lines { + println!(" {l}"); + } + } + } + } + if ok { ExitCode::SUCCESS } else { ExitCode::from(1) } + } + Err(f) => fail(Some(op.id), &f, json), + } +} + +/// Report a failure (as an envelope with `--json`) and exit with its code; one the command +/// reported itself only exits. +fn fail(op: Option<&str>, f: &OpError, json: bool) -> ExitCode { + if f.reported { + return ExitCode::from(f.exit); + } + agent::report(op, f); + if json { + agent::print(&agent::error_envelope_with(op, f, None)); + } + ExitCode::from(f.exit) +} + +/// `--json` for an operation that prints text, or JSON of its own: it runs again as a child +/// of this process, and its output and exit become the envelope. +fn enveloped(op: &Op, imp: &Impl, rest: &[String]) -> ExitCode { + let own = match imp.json { + Json::Own => true, + Json::OwnWith(f) | Json::StreamUnless(f) => rest.iter().any(|a| a == f), + Json::Text | Json::Native => false, + }; + let mut a = agent::ctx().argv.clone(); + if own { + a.push("--json".into()); + } + let exe = match std::env::current_exe() { + Ok(e) => e, + Err(e) => return fail(Some(op.id), &OpError::internal(format!("this binary's path: {e}")), true), + }; + let o = match Command::new(exe).args(&a).env(agent::CHILD_ENV, "1").stdin(Stdio::inherit()).output() { + Ok(o) => o, + Err(e) => return fail(Some(op.id), &OpError::internal(format!("running {}: {e}", op.id)), true), + }; + let code = o.status.code().and_then(|c| u8::try_from(c).ok()).unwrap_or(7); + let (mut trailer, mut plan, mut logs) = (None::, None::, Vec::new()); + for line in String::from_utf8_lossy(&o.stderr).lines() { + if let Some(j) = line.strip_prefix(agent::TRAILER_ERROR) { + trailer = serde_json::from_str(j).ok(); + } else if let Some(j) = line.strip_prefix(agent::TRAILER_PLAN) { + plan = serde_json::from_str(j).ok(); + } else { + logs.push(line.to_string()); + } + } + for l in &logs { + eprintln!("{l}"); + } + let stdout = String::from_utf8_lossy(&o.stdout).into_owned(); + let data = || -> Value { + if own && let Ok(v) = serde_json::from_str::(stdout.trim()) { + return v; + } + json!({"text": stdout}) + }; + let v = match code { + 0 => agent::envelope_with(op.id, true, data(), plan.and_then(|p| p.as_array().cloned())), + 1 => { + let msg = logs.iter().rev().find(|l| !l.trim().is_empty()).cloned().unwrap_or_else(|| format!("{}: a check did not hold", op.id)); + agent::error_envelope_with(Some(op.id), &OpError::of(1, "check-failed", msg), Some(data())) + } + _ => match trailer { + Some(t) => { + let mut v = json!({"wai": catalogue::ENVELOPE, "op": op.id, "ok": false, "error": t["error"], "exit": code}); + if code == 4 { + v["proposed"] = t["proposed"].clone(); + v["confirm"] = t["confirm"].clone(); + } + v + } + None => { + let msg = logs.iter().rev().find(|l| !l.trim().is_empty()).cloned().unwrap_or_else(|| format!("{} exited {code}", op.id)); + let f = if code == 7 || o.status.code().is_none() { OpError::internal(msg) } else { OpError::from(msg) }; + agent::error_envelope_with(Some(op.id), &f, None) + } + }, + }; + agent::print(&v); + ExitCode::from(code) +} + +/// A command line that names no operation: exit 5, with the closest operations. +fn unknown(args: &[String]) -> OpError { + let words: Vec<&str> = args.iter().take_while(|a| !a.starts_with('-')).map(String::as_str).collect(); + let near: Vec = catalogue::search(&words.join(" "), 5).iter().filter(|m| m.op.cli.is_some()).take(3).map(|m| format!("{} ({})", m.op.command(), m.op.id)).collect(); + let f = OpError::of(5, "operation-not-found", format!("`wai {}` names no operation", words.join(" "))); + if near.is_empty() { + f.with_hint("`wai ops list` lists every operation; `wai ops search \"\"` finds one") + } else { + f.with_hint(format!("the closest: {}; `wai ops search \"\"` finds others", near.join(", "))) + } +} + +/// An operation this build does not hold: exit 5, naming the build that does. +pub fn not_built(op: &Op) -> OpError { + let need = match op.needs { + catalogue::Needs::Nothing => String::new(), + catalogue::Needs::All(f) => format!("the feature{} {}", if f.len() > 1 { "s" } else { "" }, f.join(", ")), + catalogue::Needs::Any(f) => format!("one of the features {}", f.join(", ")), + }; + OpError::of(5, "operation-not-in-build", format!("`{}` ({}) needs a build with {need}", op.command(), op.id)) + .with_hint("the release archive's `wai` holds the developer's path; a source build takes --features") +} + +// ─────────────────────────────── help, from the catalogue ─────────────────────────────── + +/// The help `wai --help` prints: every operation, the developer's path first. +pub fn usage_text() -> String { + let mut s = String::new(); + s += "wai — the WAI toolchain. Every operation has an id, noun.verb, and `wai ` runs it\n"; + s += "as well as the command shown.\n\nStart here:\n"; + let line = |o: &Op| -> String { + let cmd = o.command(); + let note = if o.available() { String::new() } else { " (not in this build)".into() }; + format!(" {cmd:<26} {:<20} {}{note}\n", o.id, o.summary) + }; + let start = ["toolchain.test", "project.create", "dev.start", "receipt.verify"]; + for id in start { + if let Some(o) = catalogue::op(id) { + s += &line(o); + } + } + s += "\nEvery other operation:\n"; + for o in OPS.iter().filter(|o| o.cli.is_some() && !start.contains(&o.id)) { + s += &line(o); + } + s += "\nFor every operation:\n"; + for f in catalogue::AGENT_FLAGS { + s += &format!(" {:<11} {}\n", f.flag, f.summary); + if let Some((alias, _)) = f.deprecated_alias { + s += &format!(" {alias:<11} deprecated: the same as {}, which replaces it\n", f.flag); + } + } + s += " --help the operation's usage, effect and id\n"; + s += "exit codes: "; + s += &catalogue::EXIT_CODES.iter().map(|(c, m)| format!("{c} {m}")).collect::>().join("; "); + s += "\n\n`wai ops search \"\"` finds an operation; `wai ops schema ` describes one as JSON.\n"; + s += "`wai` sends nothing off your machine unless you name a remote service.\n"; + s +} + +/// One operation's help. +pub fn op_help(o: &Op) -> String { + let mut s = format!("{} — {}\n{}\n\nusage:\n", o.command(), o.id, o.summary); + for l in o.usage.lines() { + if l.starts_with(' ') { + s += &format!(" {l}\n"); + } else { + s += &format!(" wai {l}\n"); + } + } + let forms: Vec = o.cli_forms().iter().map(|f| format!("wai {}", f.join(" "))).filter(|f| *f != o.command()).collect(); + if !forms.is_empty() { + s += &format!("also: {}\n", forms.join(", ")); + } + s += &format!("\neffect {} · runs {} · toolset {} · {}\n", o.effect.label(), o.placement.label(), o.toolset.label(), o.stability.label()); + if o.dry_run() { + s += "--dry-run writes nothing and says what would be written.\n"; + } + if let Some(when) = o.confirms().sentence() { + s += &format!("{when}: --yes, or yes at a terminal (--confirm, deprecated, is the same as --yes).\n"); + } + if !o.available() { + s += &format!("{}\n", not_built(o).message); + } + s +} + +// ─────────────────────────────── wai ops ─────────────────────────────── + +fn ops_list(a: &[String]) -> Result { + if let Some(x) = a.first() { + return Err(OpError::usage(format!("wai ops list: {x}: takes no argument"), "")); + } + let rows: Vec = OPS + .iter() + .map(|o| json!({"id": o.id, "summary": o.summary, "cli": o.cli.map(|_| o.command()), "effect": o.effect.label(), "placement": o.placement.label(), "toolset": o.toolset.label(), "stability": o.stability.label(), "available": o.available()})) + .collect(); + if agent::ctx().json { + agent::print(&agent::envelope_with("ops.list", true, Value::Array(rows), None)); + } else { + for o in OPS { + let cmd = if o.cli.is_some() { o.command() } else { format!("({} only)", o.route.map_or("MCP", |_| "HTTP")) }; + println!("{:<22} {:<30} {:<15}{}", o.id, cmd, o.effect.label(), if o.available() { "" } else { " (not in this build)" }); + } + } + Ok(true) +} + +fn ops_search(a: &[String]) -> Result { + if a.is_empty() || a.iter().any(|x| x.starts_with("--")) { + return Err(OpError::usage("usage: wai ops search \"\"", "")); + } + let found = catalogue::search(&a.join(" "), 5); + if agent::ctx().json { + let rows: Vec = found.iter().map(|m| json!({"id": m.op.id, "summary": m.op.summary, "cli": m.op.command(), "score": m.score})).collect(); + agent::print(&agent::envelope_with("ops.search", true, Value::Array(rows), None)); + } else if found.is_empty() { + println!("no operation matches; `wai ops list` lists them all"); + } else { + for m in &found { + println!("{:<22} {:<30} {}", m.op.id, m.op.command(), m.op.summary); + } + } + Ok(true) +} + +fn ops_schema(a: &[String]) -> Result { + let [id] = a else { + return Err(OpError::usage("usage: wai ops schema ", "")); + }; + let o = catalogue::op(id).ok_or_else(|| { + let near: Vec<&str> = catalogue::search(&id.replace(['.', '_'], " "), 3).iter().map(|m| m.op.id).collect(); + OpError::of(5, "operation-not-found", format!("no operation {id}")).with_hint(format!("the closest: {}", near.join(", "))) + })?; + if agent::ctx().json { + agent::print(&agent::envelope_with("ops.show", true, o.to_json(), None)); + } else { + println!("{}", serde_json::to_string_pretty(&o.to_json()).expect("json")); + } + Ok(true) +} + +fn ops_export(a: &[String]) -> Result { + let (mut out_path, mut check) = (None, None); + let mut i = 0; + while i < a.len() { + match (a[i].as_str(), a.get(i + 1)) { + ("--out", Some(v)) => out_path = Some(v.clone()), + ("--check", Some(v)) => check = Some(v.clone()), + (x, _) => return Err(OpError::usage(format!("wai ops export: {x}: not an option (--out FILE | --check FILE)"), "")), + } + i += 2; + } + let text = catalogue::export_text(); + let json = agent::ctx().json; + match (out_path, check) { + (Some(_), Some(_)) => Err(OpError::usage("wai ops export: --out or --check, not both", "")), + (None, Some(f)) => { + let have = std::fs::read_to_string(&f).map_err(|e| OpError::from(format!("{f}: {e}")))?; + let same = have == text; + if json { + let d = json!({"file": f, "up_to_date": same}); + if same { + agent::print(&agent::envelope_with("ops.export", true, d, None)); + } else { + agent::print(&agent::error_envelope_with(Some("ops.export"), &OpError::of(1, "check-failed", format!("{f} differs from the catalogue")), Some(d))); + } + } else if same { + println!("ops export: {f} is up to date"); + } else { + eprintln!("{f} differs from the catalogue in wai-rs/src/catalogue.rs"); + eprintln!("regenerate: cd wai/wai-rs && cargo run -q --no-default-features --bin wai -- ops export --out {f}"); + } + Ok(same) + } + (Some(f), None) => { + out::write(&f, &text).map_err(|e| OpError::from(format!("{f}: {e}")))?; + if json { + agent::print(&agent::envelope_with("ops.export", true, json!({"file": f, "bytes": text.len()}), out::dry_run().then(out::plan))); + } else if out::dry_run() { + println!("dry run: nothing was written; without --dry-run, wai would write {f} ({} bytes)", text.len()); + } else { + println!("wrote {f}"); + } + Ok(true) + } + (None, None) => { + if json { + agent::print(&agent::envelope_with("ops.export", true, catalogue::export(), None)); + } else { + print!("{text}"); + } + Ok(true) + } + } +} + +// ─────────────────────────────── the other implementations ─────────────────────────────── + +type R = Result; + +/// The usage failure of operation `id`. +fn usage_of(id: &str) -> OpError { + let o = catalogue::op(id).expect("an operation of the catalogue"); + OpError::usage(format!("usage: {}", op_help(o).split("usage:\n").nth(1).unwrap_or("").split("\n\n").next().unwrap_or("").trim()), "") +} + +/// The output path a writing command was given with `-o`, or its default; a default that +/// would replace a file waits for confirmation. +fn named_or_default<'a>(rest: &'a [String], default: &'a str) -> Result<&'a str, OpError> { + match crate::opt(rest, "-o") { + Some(o) => Ok(o), + None => { + if std::path::Path::new(default).exists() { + agent::confirm(&format!("{default} exists, and the command line did not name it as the output (-o)"), vec![json!({"replace": default})])?; + } + Ok(default) + } + } +} + +fn envelope_inspect(a: &[String]) -> R { + let [p, ..] = a else { return Err(usage_of("envelope.inspect")) }; + crate::inspect(p)?; + Ok(true) +} + +fn envelope_wrap(a: &[String]) -> R { + let [cap, payload, rest @ ..] = a else { return Err(usage_of("envelope.wrap")) }; + let comps = crate::companion_args(rest)?; + let out = named_or_default(rest, "out.wai")?; + let w = crate::wrap(cap, payload, out, crate::opt(rest, "--fallback"), crate::opt(rest, "--intent"), &comps)?; + for l in &w.lines { + println!("{l}"); + } + Ok(true) +} + +fn envelope_unwrap(a: &[String]) -> R { + let [p, rest @ ..] = a else { return Err(usage_of("envelope.unwrap")) }; + crate::unwrap(p, crate::opt(rest, "-o"), crate::opt(rest, "--component"))?; + Ok(true) +} + +fn media_encode(a: &[String]) -> R { + if matches!(a.first().map(String::as_str), Some("avc" | "hevc")) { + return platform_encode(a); + } + // In a build without `floor` or `codecs`, `crate::encode` says so. + let [codec, input, rest @ ..] = a else { return Err(usage_of("media.encode")) }; + let out = named_or_default(rest, "out.wai")?; + let q: u8 = crate::opt(rest, "-q").and_then(|s| s.parse().ok()).unwrap_or(75); + crate::encode(codec, input, out, q)?; + Ok(true) +} + +fn media_decode(a: &[String]) -> R { + let [p, rest @ ..] = a else { return Err(usage_of("media.decode")) }; + // The output was the third word (`wai decode in.wai -o out`), whatever the second. + let out = crate::opt(rest, "-o").or_else(|| rest.get(1).map(String::as_str)).ok_or_else(|| usage_of("media.decode"))?; + crate::decode(p, out)?; + Ok(true) +} + +fn registry_list(a: &[String]) -> R { + if let Some(x) = a.first() { + return Err(OpError::usage(format!("wai capabilities: {x}: takes no argument"), "")); + } + crate::capabilities()?; + Ok(true) +} + +fn registry_export(a: &[String]) -> R { + let mut v = vec!["export".to_string()]; + v.extend_from_slice(a); + Ok(crate::registry(&v)?) +} + +fn prior_list(a: &[String]) -> R { + let [root, ..] = a else { return Err(usage_of("prior.list")) }; + crate::prior_set(root)?; + Ok(true) +} + +/// `words` then `a`. +#[allow(dead_code)] +fn with(words: &[&str], a: &[String]) -> Vec { + words.iter().map(|w| w.to_string()).chain(a.iter().cloned()).collect() +} + +/// An implementation that needs build features: compiled with them, and without them a +/// function that says which (the door checks the catalogue's `needs` before either runs). +macro_rules! gated { + ($name:ident, $id:literal, cfg($($cfg:tt)*), |$a:ident| $body:expr) => { + #[cfg($($cfg)*)] + fn $name($a: &[String]) -> R { + $body + } + #[cfg(not($($cfg)*))] + fn $name(_: &[String]) -> R { + Err(not_built(catalogue::op($id).expect("listed"))) + } + }; +} + +gated!(platform_encode, "media.encode", cfg(feature = "platform_encode"), |a| { + crate::platform_encode_cli::encode(a)?; + Ok(true) +}); +gated!(media_generate, "media.generate", cfg(feature = "generate"), |a| { + crate::generate::main(a)?; + Ok(true) +}); +gated!(score_build, "score.build", cfg(feature = "score"), |a| { + crate::score::main(&with(&["build"], a))?; + Ok(true) +}); +gated!(score_render, "score.decode", cfg(feature = "score"), |a| { + crate::score::main(&with(&["render"], a))?; + Ok(true) +}); +gated!(encoder_list, "encoder.list", cfg(feature = "platform_encode"), |a| { + crate::platform_encode_cli::encoders(a)?; + Ok(true) +}); +gated!(encoderecord_verify, "encoder.verify", cfg(feature = "platform_encode"), |a| Ok(crate::platform_encode_cli::record_cmd(&with(&["verify"], a))?)); + +macro_rules! stage_op { + ($name:ident, $sub:literal, $id:literal) => { + gated!($name, $id, cfg(all(feature = "staged", feature = "video_int", feature = "audio_int", feature = "splat_int")), |a| { + crate::stage::main(&with(&[$sub], a))?; + Ok(true) + }); + }; +} +stage_op!(stage_refine, "refine", "stage.encode"); +stage_op!(stage_apply, "apply", "stage.decode"); +stage_op!(stage_base, "base", "stage.build"); +stage_op!(stage_inspect, "inspect", "stage.inspect"); +stage_op!(stage_pack, "pack", "stage.wrap"); +stage_op!(stage_explode, "explode", "stage.unwrap"); + +macro_rules! http_op { + ($name:ident, $sub:literal, $id:literal) => { + gated!($name, $id, cfg(feature = "http"), |a| Ok(crate::http::main(&with(&[$sub], a))?)); + }; +} +http_op!(http_publish, "publish", "http.export"); +http_op!(http_codecs, "codecs", "http.inspect"); +http_op!(http_origin, "origin", "http.start"); +http_op!(http_hls, "hls", "hls.build"); +http_op!(http_mpd, "mpd", "dash.build"); +http_op!(http_sealplaylist, "seal-playlist", "playlist.lock"); +http_op!(http_checkplaylist, "check-playlist", "playlist.verify"); +http_op!(http_vectors, "vectors", "corpus.generate"); +http_op!(http_bench, "bench", "http.load"); +http_op!(http_receipts, "receipts", "receipt.fetch"); + +/// Every implementation but the developer's path (`dx::VERBS`), by its operation's id. +pub const IMPLS: &[Impl] = &[ + Impl { id: "envelope.inspect", run: envelope_inspect, json: Json::Text }, + Impl { id: "envelope.wrap", run: envelope_wrap, json: Json::Text }, + Impl { id: "envelope.unwrap", run: envelope_unwrap, json: Json::Text }, + Impl { id: "media.encode", run: media_encode, json: Json::Text }, + Impl { id: "media.decode", run: media_decode, json: Json::Text }, + Impl { id: "media.generate", run: media_generate, json: Json::Own }, + Impl { id: "score.build", run: score_build, json: Json::Own }, + Impl { id: "score.decode", run: score_render, json: Json::Own }, + Impl { id: "encoder.list", run: encoder_list, json: Json::Text }, + Impl { id: "encoder.verify", run: encoderecord_verify, json: Json::Text }, + Impl { id: "registry.list", run: registry_list, json: Json::Text }, + Impl { id: "registry.export", run: registry_export, json: Json::Text }, + Impl { id: "prior.list", run: prior_list, json: Json::Text }, + Impl { id: "stage.encode", run: stage_refine, json: Json::Text }, + Impl { id: "stage.decode", run: stage_apply, json: Json::Own }, + Impl { id: "stage.build", run: stage_base, json: Json::Text }, + Impl { id: "stage.inspect", run: stage_inspect, json: Json::Text }, + Impl { id: "stage.wrap", run: stage_pack, json: Json::Text }, + Impl { id: "stage.unwrap", run: stage_explode, json: Json::Text }, + Impl { id: "http.export", run: http_publish, json: Json::Text }, + Impl { id: "http.inspect", run: http_codecs, json: Json::Text }, + Impl { id: "http.start", run: http_origin, json: Json::Text }, + Impl { id: "hls.build", run: http_hls, json: Json::Text }, + Impl { id: "dash.build", run: http_mpd, json: Json::Text }, + Impl { id: "playlist.lock", run: http_sealplaylist, json: Json::Text }, + Impl { id: "playlist.verify", run: http_checkplaylist, json: Json::Text }, + Impl { id: "corpus.generate", run: http_vectors, json: Json::Text }, + Impl { id: "http.load", run: http_bench, json: Json::Text }, + Impl { id: "receipt.fetch", run: http_receipts, json: Json::Text }, + Impl { id: "ops.list", run: ops_list, json: Json::Native }, + Impl { id: "ops.search", run: ops_search, json: Json::Native }, + Impl { id: "ops.show", run: ops_schema, json: Json::Native }, + Impl { id: "ops.export", run: ops_export, json: Json::Native }, +]; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn every_implementation_names_an_operation_of_the_catalogue() { + for i in impls() { + assert!(catalogue::op(i.id).is_some(), "the CLI runs {}, which the catalogue does not hold: add it to wai-rs/src/catalogue.rs", i.id); + } + } + + #[test] + fn every_operation_with_a_command_line_has_exactly_one_implementation() { + for o in OPS.iter().filter(|o| o.cli.is_some()) { + let n = impls().filter(|i| i.id == o.id).count(); + assert_eq!(n, 1, "{} ({}) has {n} implementations in the CLI", o.id, o.command()); + } + } + + #[test] + fn help_is_written_from_the_catalogue_and_names_every_operation() { + let u = usage_text(); + for o in OPS.iter().filter(|o| o.cli.is_some()) { + assert!(u.contains(o.id) && u.contains(&o.command()), "{} is missing from the help", o.id); + let h = op_help(o); + assert!(h.contains(o.id) && h.contains(o.summary), "{}'s own help", o.id); + assert_eq!(h.contains("--dry-run"), o.effect != Effect::Reads, "{}: the help says --dry-run exactly when it applies", o.id); + } + assert!(u.contains("sends nothing off your machine unless you name a remote service")); + } + + #[test] + fn a_usage_failure_quotes_the_operations_synopsis() { + let f = usage_of("envelope.wrap"); + assert_eq!(f.exit, 2); + assert!(f.message.contains("wai wrap "), "{}", f.message); + } +} diff --git a/wai/wai-rs/src/bin/wai_cli/dx/app.rs b/wai/wai-rs/src/bin/wai_cli/dx/app.rs index 3e639c350..e40d09b18 100644 --- a/wai/wai-rs/src/bin/wai_cli/dx/app.rs +++ b/wai/wai-rs/src/bin/wai_cli/dx/app.rs @@ -18,7 +18,7 @@ pub fn main(_: &[String]) -> Result { #[cfg(feature = "app")] pub fn main(args: &[String]) -> Result { - use super::{flag, positional}; + use super::positional; use std::path::Path; match args.first().map(String::as_str) { Some("guide") => { @@ -36,14 +36,10 @@ pub fn main(args: &[String]) -> Result { let at = Path::new(pos.first().copied().unwrap_or(".")); let report = check(at)?; let ok = report.failures.is_empty(); - if flag(args, "--json") || !std::io::IsTerminal::is_terminal(&std::io::stdout()) { - println!( - "{}", - serde_json::json!({ - "wai": "envelope/1", "op": "app.check", "ok": ok, - "result": { "program": report.program, "project": report.project, "room": report.room, "errors": report.errors, "failures": report.failures, "checked": report.checked }, - }) - ); + if super::envelope::json_wanted(args) { + let body = serde_json::json!({ "program": report.program, "project": report.project, "room": report.room, "errors": report.errors, "failures": report.failures, "checked": report.checked }); + let serde_json::Value::Object(body) = body else { unreachable!("an object") }; + println!("{}", super::envelope::envelope("app.check", ok, body)); } else { println!("wai app check: {}", report.program); for c in &report.checked { diff --git a/wai/wai-rs/src/bin/wai_cli/dx/conform/mod.rs b/wai/wai-rs/src/bin/wai_cli/dx/conform/mod.rs index f9b22c511..a90e9a060 100644 --- a/wai/wai-rs/src/bin/wai_cli/dx/conform/mod.rs +++ b/wai/wai-rs/src/bin/wai_cli/dx/conform/mod.rs @@ -10,7 +10,7 @@ //! each case's outcome, and the standard's version. //! //! Exit status: 0 when every case passed, 1 when one did not, 2 for a usage error, 4 when -//! the record would replace a file and `--confirm` was not given, 5 when the suite, corpus +//! the record would replace a file and `--yes` was not given, 5 when the suite, corpus //! or program is not found, 6 when the implementation's URL does not answer, 7 otherwise. mod adapter; @@ -18,7 +18,7 @@ mod http; pub mod record; pub mod suites; -use super::envelope::{self, EXIT_CONFIRM, EXIT_FAILED, EXIT_INTERNAL, EXIT_NOT_FOUND, EXIT_UNAVAILABLE, OpError}; +use super::envelope::{self, EXIT_FAILED, EXIT_INTERNAL, EXIT_NOT_FOUND, EXIT_UNAVAILABLE, OpError}; use serde_json::{Map, Value, json}; use std::collections::HashMap; use std::path::{Path, PathBuf}; @@ -27,7 +27,7 @@ use suites::{Case, Expect, Input, SUITES, Suite}; pub const OP: &str = "conformance.run"; -const USAGE: &str = "wai conform --suite --impl [--key ] [--out ] [--confirm]\n\ +const USAGE: &str = "wai conform --suite --impl [--key ] [--out ] [--yes]\n\ [--root ] [--timeout ] [--name ] [--version ] [--dry-run] [--all] [--json|--text]\n\ wai conform --list"; @@ -403,7 +403,7 @@ pub fn main(args: &[String]) -> Result { } fn run(args: &[String], json_mode: bool) -> Result { - let flags = ["--suite", "--impl", "--key", "--out", "--confirm", "--root", "--timeout", "--name", "--version", "--dry-run", "--all", "--json", "--text", "--list"]; + let flags = ["--suite", "--impl", "--key", "--out", "--root", "--timeout", "--name", "--version", "--dry-run", "--all", "--json", "--text", "--list"]; let with_value = ["--suite", "--impl", "--key", "--out", "--root", "--timeout", "--name", "--version"]; let pos = envelope::check_args(args, &flags, &with_value, 1, USAGE)?; let v = |f: &str| envelope::value(args, f, USAGE); @@ -451,6 +451,8 @@ fn run(args: &[String], json_mode: bool) -> Result { } let out = PathBuf::from(v("--out")?.map(String::from).unwrap_or_else(|| format!("{}{}", suite.name, record::SUFFIX))); if args.iter().any(|a| a == "--dry-run") { + // The record it would write, in the envelope's plan. + crate::out::would_write(&out); let first = &loaded.cases[0]; let plan = match &imp { Impl::Command(c) if whole => adapter::fill(c, &[("corpus", loaded.corpus_file.as_deref().unwrap_or(Path::new("")))]), @@ -471,12 +473,14 @@ fn run(args: &[String], json_mode: bool) -> Result { println!("{}", envelope::envelope(OP, true, m)); } else { println!("wai conform {kind} {}: {} cases from {}; would run: {plan}", suite.name, loaded.cases.len(), suite.dir); - println!("the record would be written to {}", out.display()); + println!("dry run: nothing was run and nothing written; the record would be written to {}", out.display()); } return Ok(true); } - if out.exists() && !args.iter().any(|a| a == "--confirm") { - return Err(OpError::new(EXIT_CONFIRM, "confirmation-required", format!("{} exists; the record would replace it", out.display()), format!("re-run with --confirm, or --out : wai conform {} --confirm", args.join(" ")))); + if out.exists() { + // `--yes` (or its deprecated spelling `--confirm`), or yes at a terminal; otherwise exit 4 + // with the proposal and the confirming command. + envelope::confirm(&format!("{} exists, and the record would replace it", out.display()), vec![json!({"write": out.display().to_string(), "replaces": true})])?; } let key = match v("--key")? { Some(p) => (record::key_from_file(Path::new(p)).map_err(|e| OpError::new(EXIT_NOT_FOUND, "key-not-read", e, "--key names a file holding a 32-byte Ed25519 seed in hexadecimal"))?, "supplied"), @@ -527,7 +531,7 @@ fn run(args: &[String], json_mode: bool) -> Result { rec.insert("key".into(), json!(key.1)); let rec = record::sign(rec, &key.0).map_err(|e| OpError::new(EXIT_INTERNAL, "internal", e, ""))?; let text = serde_json::to_string_pretty(&Value::Object(rec.clone())).map_err(|e| OpError::new(EXIT_INTERNAL, "internal", e.to_string(), ""))?; - std::fs::write(&out, format!("{text}\n")).map_err(|e| OpError::new(EXIT_INTERNAL, "internal", format!("{}: {e}", out.display()), ""))?; + crate::out::write(&out, format!("{text}\n")).map_err(|e| OpError::new(EXIT_INTERNAL, "internal", format!("{}: {e}", out.display()), ""))?; let pubkey = rec.get("signer_pubkey").and_then(Value::as_str).unwrap_or("").to_string(); if json_mode { let mut m = Map::new(); diff --git a/wai/wai-rs/src/bin/wai_cli/dx/create.rs b/wai/wai-rs/src/bin/wai_cli/dx/create.rs index 93514c706..94f9037a5 100644 --- a/wai/wai-rs/src/bin/wai_cli/dx/create.rs +++ b/wai/wai-rs/src/bin/wai_cli/dx/create.rs @@ -5,25 +5,27 @@ //! Each example's `test/` directory is the repository's check that the example works, and is //! not part of the project. Where a template names a generated file (a watch room's sample //! sound), it is made here. -//! - `wai create --from app.oui` (catalogue `app.export`; a build with `app`): from a -//! program made with Create. The app's files are `wai_app::export`'s, its runtime is copied -//! in from a Create runtime (`--runtime DIR`, `$WAI_APP_RUNTIME`, or `share/wai/app` beside -//! this binary) and checked file by file, and its project file names the program and the -//! room `wai dev` runs for it. +//! - `wai create --from app.oui` (also `wai app export`, catalogue `app.export`; a build +//! with `app`): from a program made with Create. The app's files are `wai_app::export`'s, its +//! runtime is copied in from a Create runtime (`--runtime DIR`, `$WAI_APP_RUNTIME`, or +//! `share/wai/app` beside this binary) and checked file by file, and its project file names +//! the program and the room `wai dev` runs for it. //! -//! Either way the project file is written by [`project::render`], and every project carries -//! the licence its files are under, Apache-2.0, as `LICENSE`. It writes into a directory that -//! does not exist or is empty, and never over a file. +//! Either way the project file is written by [`project::render`] and read before anything is +//! written, and every project carries the licence its files are under, Apache-2.0, as +//! `LICENSE` (each example holds it too). It writes into a directory that does not exist or is +//! empty, and never over a file; every write goes through `out`, so `--dry-run` writes none. #[path = "templates.rs"] mod templates; use super::{flag, opt, positional, project}; +use super::envelope::OpError; +use crate::out; use std::path::Path; -pub const USAGE: &str = "wai create --template | wai create --from app.oui [--runtime DIR] | wai create --list [--json]\n a new project from one of the examples, or from an app made with Create"; - -/// The licence every project `wai create` writes is under, and the file that says so. +/// The licence every project `wai create` writes is under, and the file that says so: one copy +/// in this binary, which each example's `LICENSE` equals byte for byte. const LICENSE: (&str, &[u8]) = ("LICENSE", include_bytes!("../../../../../LICENSE")); /// A file of a template: its path in the project, and its bytes. @@ -39,12 +41,12 @@ pub struct Template { pub generated: &'static [Generated], } -pub fn main(args: &[String]) -> Result { +pub fn main(args: &[String]) -> Result { if flag(args, "--list") { if flag(args, "--json") { let v: Vec = templates::TEMPLATES .iter() - .map(|t| serde_json::json!({ "template": t.name, "title": t.title, "files": t.files.iter().map(|f| f.0).chain(t.generated.iter().map(|g| g.0)).chain([LICENSE.0]).collect::>() })) + .map(|t| serde_json::json!({ "template": t.name, "title": t.title, "files": t.files.iter().map(|f| f.0).chain(t.generated.iter().map(|g| g.0)).collect::>() })) .collect(); println!("{}", serde_json::to_string_pretty(&v).expect("json")); } else { @@ -54,15 +56,16 @@ pub fn main(args: &[String]) -> Result { } return Ok(true); } + let usage = super::usage("project.create"); if let Some(a) = args.iter().find(|a| a.starts_with("--") && !["--template", "--from", "--runtime"].contains(&a.as_str())) { - return Err(format!("wai create: {a}: not an option ({USAGE})")); + return Err(OpError::usage(format!("wai create: {a}: not an option ({usage})"), "")); } if let Some(program) = opt(args, "--from") { let pos = positional(args, &["--from", "--runtime"]); let (Some(dir), 1, None) = (pos.first(), pos.len(), opt(args, "--template")) else { - return Err(format!("wai create --from app.oui [--runtime DIR]\n{USAGE}")); + return Err(OpError::usage(format!("wai create --from app.oui [--runtime DIR]\n{usage}"), "")); }; - return from_app(Path::new(program), Path::new(dir), opt(args, "--runtime").map(Path::new)).map(|written| { + return from_app(Path::new(program), Path::new(dir), opt(args, "--runtime").map(Path::new)).map_err(OpError::from).map(|written| { println!("wai create: an app made with Create, in {dir}"); for f in written.iter().filter(|f| !f.starts_with("vendor/")) { println!(" {f}"); @@ -74,7 +77,7 @@ pub fn main(args: &[String]) -> Result { } let pos = positional(args, &["--template"]); let (Some(dir), Some(name), 1) = (pos.first(), opt(args, "--template"), pos.len()) else { - return Err(format!("{USAGE}\ntemplates: {}", names())); + return Err(OpError::usage(format!("{usage}\ntemplates: {}", names()), "")); }; let t = templates::TEMPLATES.iter().find(|t| t.name == name).ok_or_else(|| format!("no template {name}; the templates: {}", names()))?; let dir = Path::new(dir); @@ -114,19 +117,20 @@ fn write_project(dir: &Path, mut files: Vec<(String, Vec)>) -> Result<(), St if !files.iter().any(|(p, _)| p == LICENSE.0) { files.push((LICENSE.0.into(), LICENSE.1.to_vec())); } + // The project file must read before anything is written: a template is checked as it is + // made. + let pf = files.iter().find(|(p, _)| *p == project::FILE).map(|(_, b)| String::from_utf8_lossy(b).into_owned()).unwrap_or_default(); + project::parse(dir, &pf).map_err(|e| format!("{}: {e}", dir.join(project::FILE).display()))?; for (rel, bytes) in &files { if rel.split('/').any(|s| s.is_empty() || s == "." || s == "..") || rel.starts_with('/') { return Err(format!("{rel}: not a path a template may write")); } let p = dir.join(rel); if let Some(parent) = p.parent() { - std::fs::create_dir_all(parent).map_err(|e| format!("{}: {e}", parent.display()))?; + out::create_dir_all(parent).map_err(|e| format!("{}: {e}", parent.display()))?; } - let mut f = std::fs::OpenOptions::new().write(true).create_new(true).open(&p).map_err(|e| format!("{}: {e}", p.display()))?; - std::io::Write::write_all(&mut f, bytes).map_err(|e| format!("{}: {e}", p.display()))?; + out::write_new(&p, bytes).map_err(|e| format!("{}: {e}", p.display()))?; } - // The project file must read: a project is checked as it is made. - project::load(dir)?.ok_or("the project file is missing")?; Ok(()) } diff --git a/wai/wai-rs/src/bin/wai_cli/dx/dev.rs b/wai/wai-rs/src/bin/wai_cli/dx/dev.rs index 5f70af6ed..8850b43f2 100644 --- a/wai/wai-rs/src/bin/wai_cli/dx/dev.rs +++ b/wai/wai-rs/src/bin/wai_cli/dx/dev.rs @@ -1,5 +1,5 @@ -//! `wai dev`: a relay (`joule-moq-relay`), a room service (`joule-moq-room`) and the room -//! page on one origin, on this machine, for development. +//! `wai dev`: a relay (`wai-relay`), a room service (`wai-room`) and the room page on one +//! origin, on this machine, for development. //! //! It makes the room's keys in its directory (`.wai/dev/` by default) once, writes the //! relay's and the service's configuration there, starts both on loopback ports of their @@ -12,12 +12,16 @@ //! WebAssembly, the service and its clock's session on the relay, a viewer's grant, the //! relay's WebSocket upgrade), prints how long it took, and stops: exit 0 when every check //! held. Without it, `wai dev` runs until Ctrl-C (which stops the relay and the service -//! with it) or `--for` seconds. +//! with it) or `--for` seconds; with `--json` its first line on stdout is +//! `{"ready": true, ...}`. `--dry-run` says what it would start and writes nothing. Its +//! usage and help are the catalogue's (`dev.start`). //! //! The same origin serves WAI Studio at `/_wai/` when its files are found ([`studio`]): the //! page, and the dev stack's own routes for it under `/_wai/api/`. It opens no listener. use super::{flag, opt, project}; +use super::envelope::OpError; +use crate::out; use serde_json::{Value, json}; use std::io::{BufRead, BufReader, Read, Write}; use std::net::{Shutdown, SocketAddr, TcpListener, TcpStream}; @@ -29,34 +33,6 @@ use std::time::{Duration, Instant}; #[path = "studio.rs"] mod studio; -pub const USAGE: &str = "wai dev [--port P] [--dir D] [--site DIR] [--watch FILE [--watch-ms MS]] [--call] [--remote]\n [--show [--audience N]] [--messaging] [--e2ee] [--publishers N] [--check [--json]] [--for S]\n a local relay, room service and room page on one origin (`wai dev --help`)"; - -const HELP: &str = "wai dev — a relay, a room service and the room page on one origin, on this machine - - --port P the origin's port on 127.0.0.1 (8080; 0 picks a free one) - --dir D keys, configuration and logs (.wai/dev) - --site DIR your page: DIR is served at /, the room client beside it at /room/ - --publishers N places in the room (4) - --watch FILE a watch room: everyone in it watches FILE together (.wav, .webm, .mp4, - .ogg, .mp3); --watch-ms MS its duration, read from a WAV file's header - --call the room is a call: /room/call.html places one, rings, answers - --remote the room is one host's shared screen; prints the host's invitation - --show the room is a show: hosts on camera, an audience that votes in polls - the room's clock opens and signs (co-performance); --audience N each - viewer's inputs to a poll (1) - --messaging a conversation beside the room, at /msg-api/: /room/messages.html - --e2ee every room end-to-end encrypted - --check start, check every piece through the origin, report, stop - --json with --check, the report as JSON - --for S stop after S seconds - --web DIR the room client's files (beside this binary: ../share/wai/web; $WAI_WEB) - --relay BIN joule-moq-relay (beside this binary, then $PATH; $WAI_RELAY_BIN) - --room BIN joule-moq-room (beside this binary, then $PATH; $WAI_ROOM_BIN) - -WAI Studio is served at /_wai/ when its files are found ($WAI_STUDIO, or share/wai/studio -beside this binary). A wai.json in the current directory (`wai create` writes one) gives -the defaults; a flag wins over it. Ctrl-C stops everything."; - /// The relay's WebSocket path on the origin. const RELAY_PATH: &str = "/moq"; /// The paths the services are served under. @@ -97,18 +73,56 @@ struct Opts { for_s: Option, } -pub fn main(args: &[String]) -> Result { - if flag(args, "--help") || flag(args, "-h") { - println!("{HELP}"); - return Ok(true); - } +pub fn main(args: &[String]) -> Result { let started = Instant::now(); let o = options(args)?; - run(o, started) + if out::dry_run() { + plan(&o); + return Ok(true); + } + // Past its options, what stops `wai dev` is the stack on this machine not coming up (a + // port held, a service that exits or does not answer): starting it again may succeed. + run(o, started).map_err(OpError::unavailable) +} + +/// What `wai dev` would start, for `--dry-run`: it starts nothing and writes nothing. +fn plan(o: &Opts) { + let mode = match &o.mode { + Mode::Room => "room".to_string(), + Mode::Watch { file, ms } => format!("watch {} ({ms} ms)", file.display()), + Mode::Call => "call".into(), + Mode::Remote => "remote".into(), + Mode::Show { audience } => format!("show ({audience} input(s) a viewer)"), + }; + let v = json!({ + "dry_run": true, + "origin": format!("http://127.0.0.1:{}", o.port), + "relay": o.relay.display().to_string(), + "room": o.room.display().to_string(), + "web": o.web.display().to_string(), + "site": o.site.as_ref().map(|s| s.display().to_string()), + "dir": o.dir.display().to_string(), + "mode": mode, + "publishers": o.publishers, + "messaging": o.messaging, + "e2ee": o.e2ee, + "check": o.check, + }); + if o.json { + println!("{v}"); + return; + } + println!("wai dev --dry-run: nothing started, nothing written. It would:"); + println!(" start {} (the relay) and {} (the room service){}", o.relay.display(), o.room.display(), if o.messaging { ", and a conversation" } else { "" }); + println!(" serve http://127.0.0.1:{}: {}the room client from {}", o.port, o.site.as_ref().map(|s| format!("your page from {}, ", s.display())).unwrap_or_default(), o.web.display()); + println!(" keep keys, configuration and logs in {}", o.dir.display()); + println!(" run a {mode} room, {} places{}{}", o.publishers, if o.e2ee { ", end-to-end encrypted" } else { "" }, if o.check { "; check every piece, then stop" } else { "" }); } fn options(args: &[String]) -> Result { let known_values = ["--port", "--dir", "--site", "--publishers", "--watch", "--watch-ms", "--for", "--web", "--relay", "--room", "--audience"]; + // `--json` reaches here as the operation's own flag: with --check, the report as JSON; + // without, the ready line. let known_flags = ["--call", "--remote", "--show", "--messaging", "--e2ee", "--check", "--json"]; let mut i = 0; while i < args.len() { @@ -186,11 +200,12 @@ fn options(args: &[String]) -> Result { let exe_dir = std::env::current_exe().ok().and_then(|e| std::fs::canonicalize(e).ok()).and_then(|e| e.parent().map(Path::to_path_buf)); let web = find_web(opt(args, "--web"), exe_dir.as_deref())?; let studio = studio::find(exe_dir.as_deref()); - let relay = find_bin("joule-moq-relay", opt(args, "--relay"), "WAI_RELAY_BIN", exe_dir.as_deref())?; - let room = find_bin("joule-moq-room", opt(args, "--room"), "WAI_ROOM_BIN", exe_dir.as_deref())?; + let relay = find_bin("wai-relay", opt(args, "--relay"), "WAI_RELAY_BIN", exe_dir.as_deref())?; + let room = find_bin("wai-room", opt(args, "--room"), "WAI_ROOM_BIN", exe_dir.as_deref())?; let dir = opt(args, "--dir").map(PathBuf::from).unwrap_or_else(|| cwd.join(".wai").join("dev")); - std::fs::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?; - let dir = std::fs::canonicalize(&dir).map_err(|e| e.to_string())?; + out::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?; + // A dry run makes no directory, so one that is not there yet is named as it would be. + let dir = if out::dry_run() && !dir.exists() { cwd.join(&dir) } else { std::fs::canonicalize(&dir).map_err(|e| e.to_string())? }; let for_s = num("--for")?; Ok(Opts { port, dir, site, page: d.page.clone(), web, studio, relay, room, publishers, mode, messaging, e2ee, check: flag(args, "--check"), json: flag(args, "--json"), for_s }) } @@ -320,9 +335,9 @@ fn free_port() -> Result { /// The relay lines of a room service's keys, made once in `keys`. fn keys(room: &Path, keys: &Path) -> Result { let verb = if keys.exists() { "relay-lines" } else { "keygen" }; - let out = Command::new(room).args([verb, &keys.to_string_lossy()]).output().map_err(|e| format!("joule-moq-room {verb}: {e}"))?; + let out = Command::new(room).args([verb, &keys.to_string_lossy()]).output().map_err(|e| format!("wai-room {verb}: {e}"))?; if !out.status.success() { - return Err(format!("joule-moq-room {verb}: {}", String::from_utf8_lossy(&out.stderr).trim())); + return Err(format!("wai-room {verb}: {}", String::from_utf8_lossy(&out.stderr).trim())); } Ok(String::from_utf8_lossy(&out.stdout).into_owned()) } @@ -424,11 +439,11 @@ fn run(o: Opts, started: Instant) -> Result { // The loopback address each process listens on, in the order they start. let mut listens: Vec = Vec::new(); let result = (|| -> Result { - procs.push(Proc::start("joule-moq-relay", &o.relay, &["--config", &relay_cfg.to_string_lossy()], o.dir.join("relay.log"), &[])?); + procs.push(Proc::start("wai-relay", &o.relay, &["--config", &relay_cfg.to_string_lossy()], o.dir.join("relay.log"), &[])?); listens.push(([127, 0, 0, 1], ws).into()); wait_for("the relay's WebSocket binding", 30_000, &mut procs, || TcpStream::connect(("127.0.0.1", ws)).is_ok())?; times.push(("relay", started.elapsed().as_secs_f64())); - procs.push(Proc::start("joule-moq-room", &o.room, &["--config", &room_cfg.to_string_lossy()], o.dir.join("room.log"), &[])?); + procs.push(Proc::start("wai-room", &o.room, &["--config", &room_cfg.to_string_lossy()], o.dir.join("room.log"), &[])?); let svc_addr: SocketAddr = ([127, 0, 0, 1], svc).into(); listens.push(svc_addr); wait_for("the room service's clock on the relay", 30_000, &mut procs, || { @@ -441,7 +456,7 @@ fn run(o: Opts, started: Instant) -> Result { let mport = free_port()?; let mcfg = o.dir.join("conversation.toml"); std::fs::write(&mcfg, conversation_toml(&o, mport)).map_err(|e| e.to_string())?; - procs.push(Proc::start("joule-moq-room (conversation)", &o.room, &["--config", &mcfg.to_string_lossy()], o.dir.join("conversation.log"), &[])?); + procs.push(Proc::start("wai-room (conversation)", &o.room, &["--config", &mcfg.to_string_lossy()], o.dir.join("conversation.log"), &[])?); let maddr: SocketAddr = ([127, 0, 0, 1], mport).into(); listens.push(maddr); wait_for("the conversation's service", 30_000, &mut procs, || http(maddr, "GET", "/msg", &[], b"").is_ok_and(|(s, _)| s == 200))?; @@ -486,7 +501,12 @@ fn run(o: Opts, started: Instant) -> Result { } passed } else { - announce(&o, &base); + if o.json { + // A streaming operation's first line (JSON Lines): the stack is up. + println!("{}", json!({"ready": true, "origin": base, "room_api": format!("{base}{ROOM_API}"), "relay": format!("ws://{origin}{RELAY_PATH}"), "dir": o.dir.display().to_string()})); + } else { + announce(&o, &base); + } let end = o.for_s.map(|s| Instant::now() + Duration::from_secs(s)); loop { if let Some(e) = procs.iter_mut().find_map(Proc::exited) { @@ -571,7 +591,7 @@ fn announce(o: &Opts, base: &str) { println!(" the host wai-remote-host run --room {base}{ROOM_API} --key host.key --invitation {inv} --relay ws://{}{RELAY_PATH}", base.trim_start_matches("http://")); println!(" (`wai-remote-host key host.key` makes the key; the invitation holds for an hour)"); } - _ => println!(" the host `joule-moq-room invite {}` makes the host's invitation", o.dir.join("room-keys.toml").display()), + _ => println!(" the host `wai-room invite {}` makes the host's invitation", o.dir.join("room-keys.toml").display()), } } Mode::Watch { file, ms } => println!(" watch room {base}/room/?service={ROOM_API} {} ({} s), in sync for everyone in it", file.display(), ms / 1000), diff --git a/wai/wai-rs/src/bin/wai_cli/dx/envelope.rs b/wai/wai-rs/src/bin/wai_cli/dx/envelope.rs index 8bd01fe70..667c08fcd 100644 --- a/wai/wai-rs/src/bin/wai_cli/dx/envelope.rs +++ b/wai/wai-rs/src/bin/wai_cli/dx/envelope.rs @@ -1,81 +1,278 @@ -//! The agent rules `wai breaking` and `wai conform` follow (WAI toolchain v1, rules A1–A3 and -//! A6): text on a terminal, the JSON envelope `envelope/1` when standard output is not a -//! terminal or with `--json` (`--text` keeps text), data on standard output and progress on -//! standard error, and one exit status for each outcome: +//! The agent rules every command of the catalogue keeps (WAI toolchain v1, rules A1 to A6), +//! for a person and for an agent alike, in this one place: //! -//! | exit | meaning | -//! |---|---| -//! | 0 | done; every check held | -//! | 1 | ran, and a check failed | -//! | 2 | usage error or invalid input | -//! | 4 | confirmation required; nothing changed | -//! | 5 | not found (file, ref, suite, program) | -//! | 6 | target unavailable; safe to retry | -//! | 7 | internal error | +//! - **exit codes**, one meaning each (`wai::catalogue::EXIT_CODES`): //! -//! The operation catalogue will hold these rules for every command; until it does, this is -//! the one place these two operations take them from. +//! | exit | meaning | +//! |---|---| +//! | 0 | done; every check held | +//! | 1 | ran, and a check failed | +//! | 2 | usage error or invalid input | +//! | 3 | authentication or permission | +//! | 4 | confirmation required; nothing changed | +//! | 5 | not found (file, ref, suite, program, operation, or a build feature) | +//! | 6 | target unavailable; safe to retry | +//! | 7 | internal error | +//! +//! - **`--json`**: stdout carries one JSON envelope, `{"wai": "envelope/1", "op", "ok", "data" +//! | "error": {"code", "message", "hint", "retryable"}, ...}`, and progress and logs stay on +//! stderr. A check that failed (exit 1) carries `error` and the report in `data`; a failure +//! carries its `exit`; a confirmation adds `proposed` and `confirm`; a dry run adds +//! `"dry_run": true` and the `plan` of what it would write. Text otherwise (`--text` asks for +//! it too). Off a terminal without `--json` the output is still text: every command's piped +//! output stays what it was. +//! - **`--dry-run`** on every operation whose effect is not `reads`: nothing is written, and +//! the command says what it would write (`out.rs`). +//! - **`--yes`**: an operation that would replace a file the command line did not name, or +//! change a target (as its catalogue entry's `confirm` says), asks on a terminal, and without +//! one (or with `--no-input`) exits 4 with the proposed change and the command that confirms +//! it, which names `--yes`. `--yes` is never implied. `--confirm` is its deprecated spelling +//! and does the same (`wai::catalogue::AGENT_FLAGS`). use serde_json::{Map, Value, json}; -use std::io::{IsTerminal, Write}; +use std::io::{BufRead, IsTerminal, Write}; +use std::sync::OnceLock; pub const EXIT_FAILED: u8 = 1; pub const EXIT_USAGE: u8 = 2; -#[cfg_attr(not(feature = "quickstart"), allow(dead_code))] +pub const EXIT_PERMISSION: u8 = 3; pub const EXIT_CONFIRM: u8 = 4; pub const EXIT_NOT_FOUND: u8 = 5; pub const EXIT_UNAVAILABLE: u8 = 6; pub const EXIT_INTERNAL: u8 = 7; -/// An operation that did not run to a verdict: its exit status, a stable code, what -/// happened, what to do about it, and whether running it again may help. -#[derive(Debug)] +/// The environment variable a parent `wai --json` sets on the `wai` it runs for an envelope. +pub const CHILD_ENV: &str = "WAI_ENVELOPE_CHILD"; +/// The prefix of the line a child writes last on stderr: its failure, as JSON. +pub const TRAILER_ERROR: &str = "wai-envelope-error: "; +/// The prefix of the line a child writes on stderr after a dry run: its plan, as JSON. +pub const TRAILER_PLAN: &str = "wai-envelope-plan: "; + +/// This run's agent flags, read wherever they are on the command line. +#[derive(Clone, Debug, Default)] +pub struct Ctx { + /// `--json`: one envelope on stdout. + pub json: bool, + /// `--dry-run`. + pub dry_run: bool, + /// `--yes`, or `--confirm`, its deprecated spelling. + pub yes: bool, + /// `--no-input`: never ask. + pub no_input: bool, + /// Run by a parent `wai --json` for its envelope. + pub child: bool, + /// The command line after `wai`, without `--json`, for the command that confirms. + pub argv: Vec, +} + +static CTX: OnceLock = OnceLock::new(); + +/// Set this run's flags, once. +pub fn set(c: Ctx) { + let _ = CTX.set(c); +} + +/// This run's flags. +pub fn ctx() -> &'static Ctx { + CTX.get_or_init(Ctx::default) +} + +/// An operation that did not run to a verdict: its exit status, a stable code, what happened, +/// what to do about it, whether running it again may help, and, waiting for confirmation, the +/// proposed changes and the command that confirms them. +#[derive(Clone, Debug, PartialEq)] pub struct OpError { pub exit: u8, + /// A stable string: `usage`, `invalid-input`, `check-failed`, `permission-denied`, + /// `confirmation-required`, `not-found`, `ref-not-found`, `operation-not-found`, + /// `operation-not-in-build`, `target-unavailable`, `internal`, ... pub code: &'static str, pub message: String, pub hint: String, pub retryable: bool, + /// With exit 4: the changes that wait for confirmation. + pub proposed: Vec, + /// With exit 4: the command that confirms them. + pub confirm: Option, + /// The command reported it itself, in text or in its envelope: only its exit is left. + pub reported: bool, } impl OpError { pub fn new(exit: u8, code: &'static str, message: impl Into, hint: impl Into) -> OpError { - OpError { exit, code, message: message.into(), hint: hint.into(), retryable: exit == EXIT_UNAVAILABLE } + OpError { exit, code, message: message.into(), hint: hint.into(), retryable: exit == EXIT_UNAVAILABLE, proposed: Vec::new(), confirm: None, reported: false } } + + /// A failure with no hint yet. + pub fn of(exit: u8, code: &'static str, message: impl Into) -> OpError { + OpError::new(exit, code, message, "") + } + + pub fn with_hint(mut self, h: impl Into) -> OpError { + self.hint = h.into(); + self + } + pub fn usage(message: impl Into, usage: &str) -> OpError { OpError::new(EXIT_USAGE, "usage", message, usage) } + + pub fn unavailable(message: impl Into) -> OpError { + OpError::new(EXIT_UNAVAILABLE, "target-unavailable", message, "start it again; nothing was changed") + } + + pub fn internal(message: impl Into) -> OpError { + OpError::of(EXIT_INTERNAL, "internal", message) + } + + /// A failure the command has reported itself; it ends with `exit`. + #[allow(dead_code)] + pub fn reported(exit: u8) -> OpError { + OpError { reported: true, ..OpError::of(exit, "reported", "") } + } + + /// The envelope's `error` member. + pub fn to_json(&self) -> Value { + json!({"code": self.code, "message": self.message, "hint": (!self.hint.is_empty()).then_some(&self.hint), "retryable": self.retryable}) + } } -/// Whether the output is the JSON envelope: `--json` asks for it, `--text` refuses it, and -/// otherwise it is used when standard output is not a terminal. -pub fn json_wanted(args: &[String]) -> bool { - if args.iter().any(|a| a == "--json") { - return true; +/// An error a command reports as text, read for its exit code: a refused permission (3), a +/// file or a binary that is not there, or an operation this build does not hold (5), a usage +/// line (2), and any other refusal of the input (2). +impl From for OpError { + fn from(message: String) -> OpError { + let m = message.to_ascii_lowercase(); + let has = |s: &[&str]| s.iter().any(|x| m.contains(x)); + if has(&["(os error 13)", "permission denied", "access is denied"]) { + OpError::of(EXIT_PERMISSION, "permission-denied", message) + } else if has(&["(os error 2)", "(os error 3)", "no such file or directory", "cannot find the file", "cannot find the path", "nor on path", "is not a file", "are not found"]) { + OpError::of(EXIT_NOT_FOUND, "not-found", message) + } else if has(&["needs a build with", "built without"]) { + OpError::of(EXIT_NOT_FOUND, "operation-not-in-build", message) + } else if m.starts_with("usage") || m.starts_with("wai ") || has(&["not an option", "takes a value", "is required"]) { + OpError::of(EXIT_USAGE, "usage", message) + } else { + OpError::of(EXIT_USAGE, "invalid-input", message) + } } +} + +impl From<&str> for OpError { + fn from(m: &str) -> OpError { + OpError::from(m.to_string()) + } +} + +/// Whether the output is the JSON envelope: `--json` asks for it (the agent flag `wai` read, +/// or the operation's own when a parent `wai --json` runs it), `--text` refuses it. +pub fn json_wanted(args: &[String]) -> bool { if args.iter().any(|a| a == "--text") { return false; } - !std::io::stdout().is_terminal() + ctx().json || args.iter().any(|a| a == "--json") } -/// The envelope of an operation that reached a verdict: `ok` and the body's members. +/// A word as a POSIX shell reads it back. +fn shell_word(w: &str) -> String { + if !w.is_empty() && w.bytes().all(|c| c.is_ascii_alphanumeric() || b"-_./=:,@%+".contains(&c)) { + w.to_string() + } else { + format!("'{}'", w.replace('\'', "'\\''")) + } +} + +/// The command that runs this one again, confirmed. +pub fn confirm_command() -> String { + let mut words = vec!["wai".to_string()]; + words.extend(ctx().argv.iter().filter(|a| *a != "--yes" && *a != "--confirm").map(|a| shell_word(a))); + words.push("--yes".into()); + words.join(" ") +} + +/// Ask before `proposed` changes are made. Go ahead with `--yes`, in a dry run (which makes +/// none), or when a person at a terminal answers yes; otherwise nothing changes and the +/// command exits 4 with the proposal and the command that confirms it. +pub fn confirm(what: &str, proposed: Vec) -> Result<(), OpError> { + let c = ctx(); + if c.yes || c.dry_run { + return Ok(()); + } + let interactive = !c.json && !c.child && !c.no_input && std::io::stdin().is_terminal() && std::io::stdout().is_terminal(); + if interactive { + eprint!("wai: {what}. Go ahead? [y/N] "); + let _ = std::io::stderr().flush(); + let mut line = String::new(); + if std::io::stdin().lock().read_line(&mut line).is_ok() && matches!(line.trim(), "y" | "Y" | "yes") { + return Ok(()); + } + } + Err(OpError { + proposed, + confirm: Some(confirm_command()), + ..OpError::new(EXIT_CONFIRM, "confirmation-required", format!("{what}; nothing was changed"), "read the proposed changes, then run the confirming command") + }) +} + +/// The envelope of an operation that reached a verdict, its body as `data`; in a dry run, +/// with the plan of what it would have written. pub fn envelope(op: &str, ok: bool, body: Map) -> Value { - let mut m = Map::new(); - m.insert("wai".into(), json!("envelope/1")); - m.insert("op".into(), json!(op)); - m.insert("ok".into(), json!(ok)); - m.extend(body); - Value::Object(m) + envelope_with(op, ok, Value::Object(body), crate::out::dry_run().then(crate::out::plan)) +} + +/// The envelope of an operation that reached a verdict: `data`, and a dry run's plan. +pub fn envelope_with(op: &str, ok: bool, data: Value, plan: Option>) -> Value { + let mut v = json!({"wai": wai::catalogue::ENVELOPE, "op": op, "ok": ok, "data": data}); + if let Some(p) = plan { + v["dry_run"] = Value::Bool(true); + v["plan"] = Value::Array(p); + } + v } /// The envelope of an operation that did not. pub fn error_envelope(op: &str, e: &OpError) -> Value { - json!({ - "wai": "envelope/1", "op": op, "ok": false, - "error": {"code": e.code, "message": e.message, "hint": e.hint, "retryable": e.retryable}, - "exit": e.exit, - }) + error_envelope_with(Some(op), e, None) +} + +/// The envelope of a failure, for an operation or for a command line that named none; with +/// exit 1 (a check failed) it carries the command's `data` as well, the report of what did not +/// hold. +pub fn error_envelope_with(op: Option<&str>, e: &OpError, data: Option) -> Value { + let mut v = json!({"wai": wai::catalogue::ENVELOPE, "op": op, "ok": false, "error": e.to_json(), "exit": e.exit}); + if let Some(d) = data { + v["data"] = d; + } + if e.exit == EXIT_CONFIRM { + v["proposed"] = Value::Array(e.proposed.clone()); + v["confirm"] = json!(e.confirm); + } + v +} + +/// Print an envelope on stdout, one JSON document. +pub fn print(v: &Value) { + println!("{}", serde_json::to_string_pretty(v).expect("an envelope is JSON")); +} + +/// Report a failure: as text on stderr, with what waits for confirmation; in a child, also as +/// the trailer line its parent reads. +pub fn report(op: Option<&str>, e: &OpError) { + eprintln!("error: {}", e.message); + if e.exit == EXIT_CONFIRM { + for p in &e.proposed { + eprintln!(" would {}", p); + } + if let Some(c) = &e.confirm { + eprintln!("to go ahead: {c}"); + } + } else if !e.hint.is_empty() { + eprintln!("hint: {}", e.hint); + } + if ctx().child { + let t = json!({"error": e.to_json(), "exit": e.exit, "proposed": e.proposed, "confirm": e.confirm, "op": op}); + eprintln!("{TRAILER_ERROR}{t}"); + } } /// Print an error the way the output mode asks, then exit with its status. @@ -83,10 +280,7 @@ pub fn fail(op: &str, json_mode: bool, e: &OpError) -> ! { if json_mode { println!("{}", error_envelope(op, e)); } else { - eprintln!("error: {}", e.message); - if !e.hint.is_empty() { - eprintln!("hint: {}", e.hint); - } + report(Some(op), e); } exit(e.exit) } @@ -133,3 +327,47 @@ pub fn check_args<'a>(args: &'a [String], known: &[&str], with_value: &[&str], m } Ok(pos) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_text_error_is_read_for_its_exit_code() { + let e = |m: &str| OpError::from(m.to_string()).exit; + assert_eq!(e("x.wai: No such file or directory (os error 2)"), EXIT_NOT_FOUND); + assert_eq!(e("read x: Permission denied (os error 13)"), EXIT_PERMISSION); + assert_eq!(e("`wai hello` needs a build with the feature `quickstart`"), EXIT_NOT_FOUND); + assert_eq!(e("built without generate: `wai generate` needs ..."), EXIT_NOT_FOUND); + assert_eq!(e("wai create: --x: not an option"), EXIT_USAGE); + assert_eq!(e("'wai.x' is not a registered capability"), EXIT_USAGE); + assert_eq!(e("wai-relay is not beside `wai` nor on PATH"), EXIT_NOT_FOUND); + } + + #[test] + fn the_codes_are_the_catalogues_table() { + let named = [(EXIT_FAILED, 1), (EXIT_USAGE, 2), (EXIT_PERMISSION, 3), (EXIT_CONFIRM, 4), (EXIT_NOT_FOUND, 5), (EXIT_UNAVAILABLE, 6), (EXIT_INTERNAL, 7)]; + for (c, n) in named { + assert_eq!(c, n); + assert!(wai::catalogue::EXIT_CODES.iter().any(|(x, _)| *x == c), "{c} is in the catalogue's table"); + } + assert_eq!(wai::catalogue::EXIT_CODES.len(), 8); + } + + #[test] + fn the_confirming_command_quotes_what_a_shell_would_split() { + assert_eq!(shell_word("out.wai"), "out.wai"); + assert_eq!(shell_word("a b"), "'a b'"); + assert_eq!(shell_word("it's"), "'it'\\''s'"); + } + + #[test] + fn a_failure_envelope_names_its_code_and_a_confirmation_its_command() { + let e = OpError { proposed: vec![json!({"replace": "out.wai"})], confirm: Some("wai wrap x y --yes".into()), ..OpError::of(EXIT_CONFIRM, "confirmation-required", "m") }; + let v = error_envelope("envelope.wrap", &e); + assert_eq!((v["ok"].as_bool(), v["error"]["code"].as_str(), v["confirm"].as_str(), v["exit"].as_u64()), (Some(false), Some("confirmation-required"), Some("wai wrap x y --yes"), Some(4))); + assert_eq!(v["wai"], wai::catalogue::ENVELOPE); + let ok = envelope_with("envelope.inspect", true, json!({"text": "t"}), None); + assert!(ok["ok"] == true && ok.get("error").is_none() && ok.get("dry_run").is_none() && ok["data"]["text"] == "t"); + } +} diff --git a/wai/wai-rs/src/bin/wai_cli/dx/fleet.rs b/wai/wai-rs/src/bin/wai_cli/dx/fleet.rs index 9d780cf05..bf1f5ef30 100644 --- a/wai/wai-rs/src/bin/wai_cli/dx/fleet.rs +++ b/wai/wai-rs/src/bin/wai_cli/dx/fleet.rs @@ -1,133 +1,51 @@ -//! The fleet's operations (extensions/fleet.md §9), on the toolchain's rules: `wai enrolment -//! create` (`enrolment.create`), `wai enrolment list` (`enrolment.list`), `wai report create` -//! (`report.create`) and `wai report export` (`report.export`). A bundle is checked by `wai verify`. +//! The fleet's operations (extensions/fleet.md §9): `wai enrolment create` (`enrolment.create`), +//! `wai enrolment list` (`enrolment.list`), `wai report create` (`report.create`) and `wai report +//! export` (`report.export`), against a fleet service. A bundle is checked by `wai verify`. //! -//! - Output: text on a terminal; the JSON envelope (`{"wai": "envelope/1", "op", "ok", ...}`) with -//! `--json` or when standard output is not a terminal. Data to standard output. -//! - Errors: `{code, message, hint, retryable}`; a refusal by the fleet's text has that text's -//! reason as its code (`enrolment_duplicate`, `signer_not_enrolled`, ...). -//! - Exit statuses: 0 done, 1 refused, 2 usage, 3 permission, 4 confirmation required, 5 not -//! found, 6 the service unavailable (retry), 7 internal. -//! - Every operation that writes takes `--dry-run`, which writes and sends nothing and says what -//! it would. One that changes a service (`--target`), or replaces a file, waits for -//! `--confirm` (asked for on a terminal); without it, it exits 4 with the confirming command. +//! They keep the agent rules of [`super::envelope`], as every operation of the catalogue does: +//! the exit codes, the JSON envelope with `--json` (data to standard output, logs to standard +//! error), `--dry-run` (every file through `out.rs`, every request the service would be sent named +//! in the plan, nothing written and nothing sent), and `--yes` before a fleet service is changed +//! or a file replaced (asked at a terminal; without one, exit 4 with the proposed changes and the +//! confirming command). A refusal by the fleet's text has that text's reason as its code +//! (`enrolment_duplicate`, `signer_not_enrolled`, ...); the service unavailable is exit 6. //! //! A target is a fleet service's `http://` address (`wai-fleet serve`, which listens on a //! loopback address unless told otherwise). -use serde_json::{json, Value}; -use std::io::{IsTerminal, Read, Write}; +use super::envelope::{self as agent, EXIT_FAILED, EXIT_INTERNAL, EXIT_NOT_FOUND, EXIT_PERMISSION, EXIT_UNAVAILABLE, OpError}; +use crate::out; +use serde_json::{Value, json}; +use std::io::{Read, Write}; -pub const ENROLMENT_USAGE: &str = "wai enrolment create --role --key [--label L] [--from µs] [--until µs] [--out F] [--target URL] [--dry-run] [--confirm] [--json]\n a node's enrolment, signed with the organisation key; with --target, given to a fleet service\n (enrolment.create). wai enrolment list --target URL [--json]: the fleet's nodes (enrolment.list)"; -pub const REPORT_USAGE: &str = "wai report create --target URL --start µs --end µs [--dry-run] [--confirm] [--json]\n the fleet report of a period, signed by a fleet service (report.create).\n wai report export --target URL [--out F] [--dry-run] [--confirm] [--json]: its bundle,\n verified here and written to a file (report.export)"; +type R = Result; -const DONE: u8 = 0; -const REFUSED: u8 = 1; -const USAGE: u8 = 2; +/// Print what an operation did: its envelope with `--json`, its text otherwise; in a dry run of +/// an operation that changes something, with the plan of what it would have done. +fn done(op: &str, writes: bool, data: Value, text: &str) -> R { + let plan = (writes && out::dry_run()).then(out::plan); + if agent::ctx().json { + agent::print(&agent::envelope_with(op, true, data, plan)); + } else if plan.is_some() { + println!("{text}"); + println!("dry run: nothing was written or sent. Without --dry-run, wai would:"); + for l in out::plan_lines() { + println!(" {l}"); + } + } else { + println!("{text}"); + } + Ok(true) +} + +/// A file that could not be read or written. #[cfg_attr(not(feature = "fleet"), allow(dead_code))] -const PERMISSION: u8 = 3; -const CONFIRM: u8 = 4; -const NOT_FOUND: u8 = 5; -const UNAVAILABLE: u8 = 6; -const INTERNAL: u8 = 7; - -/// How one operation reports. -struct Out { - op: &'static str, - json: bool, -} - -/// Why an operation stops. -struct Fail { - exit: u8, - code: String, - message: String, - hint: String, - retryable: bool, -} - -fn fail(exit: u8, code: &str, message: impl Into, hint: impl Into) -> Fail { - Fail { exit, code: code.into(), message: message.into(), hint: hint.into(), retryable: exit == UNAVAILABLE } -} - -fn usage(message: impl Into, hint: &str) -> Fail { - fail(USAGE, "usage", message, hint) -} - -impl Out { - fn new(op: &'static str, args: &[String]) -> Out { - Out { op, json: super::flag(args, "--json") || !std::io::stdout().is_terminal() } +fn io_fail(path: &str, e: &std::io::Error, hint: &str) -> OpError { + match e.kind() { + std::io::ErrorKind::NotFound => OpError::new(EXIT_NOT_FOUND, "not-found", format!("{path}: {e}"), hint), + std::io::ErrorKind::PermissionDenied => OpError::new(EXIT_PERMISSION, "permission-denied", format!("{path}: {e}"), "the file's owner runs this"), + _ => OpError::new(EXIT_INTERNAL, "io-error", format!("{path}: {e}"), hint), } - - fn envelope(&self, ok: bool) -> serde_json::Map { - let mut m = serde_json::Map::new(); - m.insert("wai".into(), json!("envelope/1")); - m.insert("op".into(), json!(self.op)); - m.insert("ok".into(), json!(ok)); - m - } - - fn done(&self, data: Value, text: &str) -> u8 { - if self.json { - let mut m = self.envelope(true); - m.insert("data".into(), data); - println!("{}", Value::Object(m)); - } else { - println!("{text}"); - } - DONE - } - - fn fail(&self, f: Fail) -> u8 { - if self.json { - let mut m = self.envelope(false); - m.insert("error".into(), json!({"code": f.code, "message": f.message, "hint": f.hint, "retryable": f.retryable})); - println!("{}", Value::Object(m)); - } else { - eprintln!("wai: {} ({})\n {}", f.message, f.code, f.hint); - } - f.exit - } - - /// The proposed changes, and the command that confirms them: status 4, nothing changed. - fn confirmation(&self, proposed: Value, confirm: String) -> u8 { - if self.json { - let mut m = self.envelope(false); - m.insert("error".into(), json!({"code": "confirmation-required", "message": "a change waits for confirmation", "hint": "re-run with --confirm", "retryable": false})); - m.insert("proposed".into(), proposed); - m.insert("confirm".into(), json!(confirm)); - println!("{}", Value::Object(m)); - } else { - eprintln!("wai: a change waits for confirmation:\n {proposed}\n to make it: {confirm}"); - } - CONFIRM - } - - /// A dry run: what would be done, and nothing done. - fn dry_run(&self, proposed: Value) -> u8 { - let text = format!("dry run, nothing written or sent: {proposed}"); - self.done(json!({"dry_run": true, "proposed": proposed}), &text) - } -} - -/// Whether a change is confirmed: by `--confirm`, or by a person answering `y` on a terminal. -fn confirmed(args: &[String], out: &Out, what: &str) -> bool { - if super::flag(args, "--confirm") { - return true; - } - if out.json || !std::io::stdin().is_terminal() || !std::io::stdout().is_terminal() { - return false; - } - eprint!("{what} [y/N] "); - let _ = std::io::stderr().flush(); - let mut line = String::new(); - std::io::stdin().read_line(&mut line).is_ok() && line.trim().eq_ignore_ascii_case("y") -} - -/// The command line again, with `--confirm`. -fn confirm_command(words: &str, args: &[String]) -> String { - let rest: Vec = args.iter().filter(|a| *a != "--json").map(|a| if a.chars().all(|c| c.is_ascii_alphanumeric() || "-_./:".contains(c)) { a.clone() } else { format!("'{}'", a.replace('\'', "'\\''")) }).collect(); - format!("wai {words} {} --confirm", rest.join(" ")) } // --------------------------------------------------------------------------------------- @@ -136,14 +54,14 @@ fn confirm_command(words: &str, args: &[String]) -> String { /// One request to a fleet service at `target` (`http://host:port`), and its answer: the /// status and the body read as JSON. -fn call(target: &str, method: &str, path: &str, body: Option<&Value>) -> Result<(u16, Value), Fail> { +fn call(target: &str, method: &str, path: &str, body: Option<&Value>) -> Result<(u16, Value), OpError> { let Some(rest) = target.strip_prefix("http://") else { - return Err(usage(format!("{target}: a fleet service's http:// address"), "give --target http://HOST:PORT, the address wai-fleet serve listens on")); + return Err(OpError::usage(format!("{target}: a fleet service's http:// address"), "give --target http://HOST:PORT, the address wai-fleet serve listens on")); }; let authority = rest.split('/').next().unwrap_or(""); let host = authority.rsplit_once(':').map_or(authority, |(h, _)| h); let addr = if authority.contains(':') { authority.to_string() } else { format!("{authority}:80") }; - let unavailable = |e: String| fail(UNAVAILABLE, "target-unavailable", format!("{target}: {e}"), "start the fleet service, or check its address"); + let unavailable = |e: String| OpError::new(EXIT_UNAVAILABLE, "target-unavailable", format!("{target}: {e}"), "start the fleet service, or check its address"); use std::net::ToSocketAddrs; let sock = addr.to_socket_addrs().map_err(|e| unavailable(e.to_string()))?.next().ok_or_else(|| unavailable("no address".into()))?; let mut s = std::net::TcpStream::connect_timeout(&sock, std::time::Duration::from_secs(10)).map_err(|e| unavailable(e.to_string()))?; @@ -165,16 +83,30 @@ fn call(target: &str, method: &str, path: &str, body: Option<&Value>) -> Result< Ok((status, v)) } +/// A reason of extensions/fleet.md §10 as the stable code it is; `refused` for a reason this +/// build does not hold (the message carries the service's own). +fn reason_code(code: &str) -> &'static str { + #[cfg(feature = "fleet")] + { + use wai::fleet::Reason; + if let Some(r) = Reason::ALL.iter().chain(Reason::REUSED.iter()).find(|r| r.code() == code) { + return r.code(); + } + } + let _ = code; + "refused" +} + /// An answer that is not a success, as the toolchain's failure. -fn answer_fail(status: u16, v: &Value) -> Fail { +fn answer_fail(status: u16, v: &Value) -> OpError { match status { 422 => { let r = v["reason"].as_str().unwrap_or("refused"); - fail(REFUSED, r, format!("the fleet service refused it: {r}"), "see extensions/fleet.md §10 for the reason") + OpError::new(EXIT_FAILED, reason_code(r), format!("the fleet service refused it: {r}"), "see extensions/fleet.md §10 for the reason") } - 404 => fail(NOT_FOUND, "not-found", v["error"].as_str().unwrap_or("not found").to_string(), "list what the service holds"), - 400 => usage(v["error"].as_str().unwrap_or("a bad request").to_string(), "see the operation's usage"), - _ => fail(INTERNAL, "service-error", format!("HTTP {status}: {v}"), "see the fleet service's log"), + 404 => OpError::new(EXIT_NOT_FOUND, "not-found", v["error"].as_str().unwrap_or("not found").to_string(), "list what the service holds"), + 400 => OpError::usage(v["error"].as_str().unwrap_or("a bad request").to_string(), "see the operation's usage"), + _ => OpError::new(EXIT_INTERNAL, "service-error", format!("HTTP {status}: {v}"), "see the fleet service's log"), } } @@ -191,87 +123,90 @@ fn unhex(s: &str) -> Option> { (0..s.len()).step_by(2).map(|i| u8::from_str_radix(s.get(i..i + 2)?, 16).ok()).collect() } -fn number(args: &[String], flag: &str, default: u64) -> Result { +fn number(args: &[String], flag: &str, default: u64) -> Result { match super::opt(args, flag) { None => Ok(default), - Some(v) => v.parse().map_err(|_| usage(format!("{flag} {v}: microseconds since 1970, an integer"), "give a number of microseconds")), + Some(v) => v.parse().map_err(|_| OpError::usage(format!("{flag} {v}: microseconds since 1970, an integer"), "give a number of microseconds")), } } +/// The value of `flag`, which the operation requires. +fn required<'a>(args: &'a [String], flag: &str, what: &str, usage: &str) -> Result<&'a str, OpError> { + super::opt(args, flag).ok_or_else(|| OpError::usage(format!("{flag}: {what}"), usage)) +} + // --------------------------------------------------------------------------------------- // enrolment.create, enrolment.list // --------------------------------------------------------------------------------------- -/// `wai enrolment create|list`. -pub fn enrolment(args: &[String]) -> u8 { - match args.first().map(String::as_str) { - Some("create") => { - let out = Out::new("enrolment.create", args); - match enrolment_create(&args[1..], &out) { - Ok(c) => c, - Err(f) => out.fail(f), - } - } - Some("list") => { - let out = Out::new("enrolment.list", args); - match enrolment_list(&args[1..], &out) { - Ok(c) => c, - Err(f) => out.fail(f), - } - } - _ => Out::new("enrolment.create", args).fail(usage(ENROLMENT_USAGE, "wai enrolment create … or wai enrolment list …")), - } -} - #[cfg(not(feature = "fleet"))] -fn enrolment_create(_: &[String], _: &Out) -> Result { - Err(fail(USAGE, "feature-missing", "`wai enrolment create` needs a build with the feature `fleet`", "cargo install --path wai/wai-rs --no-default-features --features quickstart")) +pub fn enrolment_create(_: &[String]) -> R { + Err(crate::door::not_built(wai::catalogue::op("enrolment.create").expect("listed"))) } +/// `wai enrolment create --role --key ...`: the node's enrolment, signed +/// with the organisation key, written to a file and, with `--target`, given to a fleet service. #[cfg(feature = "fleet")] -fn enrolment_create(args: &[String], out: &Out) -> Result { - use wai::fleet::{role, Enrolment, NO_END}; +pub fn enrolment_create(args: &[String]) -> R { + use wai::fleet::{Enrolment, NO_END, role}; + let usage = super::usage("enrolment.create"); let with_value = ["--role", "--key", "--label", "--from", "--until", "--out", "--target"]; - let pos = super::positional(args, &with_value); - let node: [u8; 32] = pos.first().and_then(|s| unhex(s)).and_then(|b| b.try_into().ok()).ok_or_else(|| usage("the node's key: 64 hexadecimal digits", ENROLMENT_USAGE))?; - let role_arg = super::opt(args, "--role").ok_or_else(|| usage("--role: relay, compositor, ingest, room, ledger or audit", ENROLMENT_USAGE))?; - let r = role::code(role_arg).or_else(|| role_arg.parse::().ok().filter(|r| role::name(*r).is_some())).ok_or_else(|| usage(format!("--role {role_arg}: relay, compositor, ingest, room, ledger or audit"), ENROLMENT_USAGE))?; - let key_path = super::opt(args, "--key").ok_or_else(|| usage("--key: the organisation key's file (32 bytes, or 64 hexadecimal digits)", ENROLMENT_USAGE))?; - let raw = std::fs::read(key_path).map_err(|e| match e.kind() { - std::io::ErrorKind::NotFound => fail(NOT_FOUND, "not-found", format!("{key_path}: {e}"), "give the organisation key's file"), - std::io::ErrorKind::PermissionDenied => fail(PERMISSION, "permission-denied", format!("{key_path}: {e}"), "the key's owner runs this"), - _ => fail(INTERNAL, "io-error", format!("{key_path}: {e}"), "check the file"), - })?; - let seed: [u8; 32] = if raw.len() == 32 { raw.try_into().expect("32") } else { std::str::from_utf8(&raw).ok().and_then(|s| unhex(s.trim())).and_then(|b| b.try_into().ok()).ok_or_else(|| usage(format!("{key_path}: 32 bytes, or 64 hexadecimal digits"), "the organisation key's seed"))? }; + let pos = agent::check_args(args, &with_value, &with_value, 1, &usage)?; + let node: [u8; 32] = pos.first().and_then(|s| unhex(s)).and_then(|b| b.try_into().ok()).ok_or_else(|| OpError::usage("the node's key: 64 hexadecimal digits", &usage))?; + let role_arg = required(args, "--role", "relay, compositor, ingest, room, ledger or audit", &usage)?; + let r = role::code(role_arg) + .or_else(|| role_arg.parse::().ok().filter(|r| role::name(*r).is_some())) + .ok_or_else(|| OpError::usage(format!("--role {role_arg}: relay, compositor, ingest, room, ledger or audit"), &usage))?; + let key_path = required(args, "--key", "the organisation key's file (32 bytes, or 64 hexadecimal digits)", &usage)?; + let raw = std::fs::read(key_path).map_err(|e| io_fail(key_path, &e, "give the organisation key's file"))?; + let seed: [u8; 32] = if raw.len() == 32 { + raw.try_into().expect("32") + } else { + std::str::from_utf8(&raw).ok().and_then(|s| unhex(s.trim())).and_then(|b| b.try_into().ok()).ok_or_else(|| OpError::usage(format!("{key_path}: 32 bytes, or 64 hexadecimal digits"), "the organisation key's seed"))? + }; let key = ed25519_dalek::SigningKey::from_bytes(&seed); let label = super::opt(args, "--label").unwrap_or(""); let (from, until) = (number(args, "--from", 0)?, number(args, "--until", NO_END)?); let e = Enrolment::new(key.verifying_key().to_bytes(), node, r, from, until, label).sign(&key); let bytes = e.to_bytes(); // Its own rules, before anything is written or sent. - Enrolment::parse(&bytes).map_err(|x| fail(USAGE, x.code(), format!("the enrolment is refused: {x}"), "extensions/fleet.md §3.3: from below until, a label of at most 64 bytes, a node that is not the organisation"))?; + Enrolment::parse(&bytes).map_err(|x| { + OpError::new(agent::EXIT_USAGE, x.code(), format!("the enrolment is refused: {x}"), "extensions/fleet.md §3.3: from below until, a label of at most 64 bytes, a node that is not the organisation") + })?; let target = super::opt(args, "--target"); let file = super::opt(args, "--out").map(str::to_string).or_else(|| target.is_none().then(|| format!("{}.wfe", &hex(&node)[..16]))); let data = json!({ "node": hex(&node), "role": role::name(r), "label": label, "from": from, "until": until, "organisation": hex(&key.verifying_key().to_bytes()), "digest": hex(&e.digest()), "enrolment": hex(&bytes), "file": file, "target": target, }); - let mut proposed = Vec::new(); - if let Some(f) = &file { - proposed.push(json!({"action": "write", "file": f, "bytes": bytes.len(), "replaces": std::path::Path::new(f).exists()})); + let who = format!("{} as {} ({label:?}), digest {}", &hex(&node)[..16], role::name(r).unwrap_or("?"), &hex(&e.digest())[..16]); + if out::dry_run() { + if let Some(f) = &file { + out::write(f, &bytes).map_err(|e| io_fail(f, &e, "check the directory"))?; + } + if let Some(t) = target { + out::would_send(t, "POST", "/v1/fleet/enrolments"); + } + return done("enrolment.create", true, data, &format!("would enrol {who}")); } - if let Some(t) = target { - proposed.push(json!({"action": "enrol", "target": t, "node": hex(&node), "role": role::name(r)})); - } - if super::flag(args, "--dry-run") { - return Ok(out.dry_run(Value::Array(proposed))); - } - let replaces = file.as_ref().is_some_and(|f| std::path::Path::new(f).exists()); - if (target.is_some() || replaces) && !confirmed(args, out, &format!("{}?", proposed.iter().map(Value::to_string).collect::>().join("; "))) { - return Ok(out.confirmation(Value::Array(proposed), confirm_command("enrolment create", args))); + let replaces = file.as_ref().filter(|f| std::path::Path::new(f).exists()); + if target.is_some() || replaces.is_some() { + let mut what = Vec::new(); + let mut proposed = Vec::new(); + if let Some(f) = replaces { + what.push(format!("{f} exists, and the enrolment would replace it")); + proposed.push(json!({"write": f, "bytes": bytes.len(), "replaces": true})); + } else if let Some(f) = &file { + proposed.push(json!({"write": f, "bytes": bytes.len(), "replaces": false})); + } + if let Some(t) = target { + what.push(format!("the fleet service at {t} would hold the enrolment of {}…", &hex(&node)[..16])); + proposed.push(json!({"request": "POST /v1/fleet/enrolments", "target": t, "node": hex(&node), "role": role::name(r)})); + } + agent::confirm(&what.join("; "), proposed)?; } if let Some(f) = &file { - std::fs::write(f, &bytes).map_err(|e| fail(if e.kind() == std::io::ErrorKind::PermissionDenied { PERMISSION } else { INTERNAL }, "io-error", format!("{f}: {e}"), "check the directory"))?; + out::write(f, &bytes).map_err(|e| io_fail(f, &e, "check the directory"))?; } if let Some(t) = target { let (status, v) = call(t, "POST", "/v1/fleet/enrolments", Some(&json!({"enrolment": hex(&bytes)})))?; @@ -280,19 +215,18 @@ fn enrolment_create(args: &[String], out: &Out) -> Result { } } let text = format!( - "enrolled {} as {} ({:?}), digest {}{}{}", - &hex(&node)[..16], - role::name(r).unwrap_or("?"), - label, - &hex(&e.digest())[..16], + "enrolled {who}{}{}", file.as_ref().map(|f| format!("; written to {f}")).unwrap_or_default(), target.map(|t| format!("; held by {t}")).unwrap_or_default() ); - Ok(out.done(data, &text)) + done("enrolment.create", true, data, &text) } -fn enrolment_list(args: &[String], out: &Out) -> Result { - let target = super::opt(args, "--target").ok_or_else(|| usage("--target: the fleet service's http:// address", ENROLMENT_USAGE))?; +/// `wai enrolment list --target URL`: the nodes a fleet service holds. +pub fn enrolment_list(args: &[String]) -> R { + let usage = super::usage("enrolment.list"); + agent::check_args(args, &["--target"], &["--target"], 0, &usage)?; + let target = required(args, "--target", "the fleet service's http:// address", &usage)?; let (status, v) = call(target, "GET", "/v1/fleet/enrolments", None)?; if status != 200 { return Err(answer_fail(status, &v)); @@ -300,46 +234,29 @@ fn enrolment_list(args: &[String], out: &Out) -> Result { let list = v["enrolments"].as_array().cloned().unwrap_or_default(); let mut text = format!("{} node(s) enrolled at {target}", list.len()); for e in &list { - text.push_str(&format!( - "\n {}… {:<10} {:<16} records {}", - &e["node"].as_str().unwrap_or("")[..16.min(e["node"].as_str().unwrap_or("").len())], - e["role"].as_str().unwrap_or("?"), - e["label"].as_str().unwrap_or(""), - e["records"] - )); + let node = e["node"].as_str().unwrap_or(""); + text.push_str(&format!("\n {}… {:<10} {:<16} records {}", &node[..16.min(node.len())], e["role"].as_str().unwrap_or("?"), e["label"].as_str().unwrap_or(""), e["records"])); } - Ok(out.done(json!({"enrolments": list}), &text)) + done("enrolment.list", false, json!({"enrolments": list}), &text) } // --------------------------------------------------------------------------------------- // report.create, report.export // --------------------------------------------------------------------------------------- -/// `wai report create|export`. -pub fn report(args: &[String]) -> u8 { - match args.first().map(String::as_str) { - Some("create") => { - let out = Out::new("report.create", args); - match report_create(&args[1..], &out) { - Ok(c) => c, - Err(f) => out.fail(f), - } - } - Some("export") => { - let out = Out::new("report.export", args); - match report_export(&args[1..], &out) { - Ok(c) => c, - Err(f) => out.fail(f), - } - } - _ => Out::new("report.create", args).fail(usage(REPORT_USAGE, "wai report create … or wai report export …")), - } -} - /// A report's outcome, for a person: its period, its lines and each scope's coverage. fn summarise(o: &Value) -> String { let cov = |e: &Value| format!("sessions {}, hops {}", e["sessions"]["coverage"].as_str().unwrap_or("?"), e["hops"]["coverage"].as_str().unwrap_or("?")); - let mut t = format!("report {} of [{}, {}): {} node line(s), {} room line(s), {} record(s)", &o["digest"].as_str().unwrap_or("")[..16.min(o["digest"].as_str().unwrap_or("").len())], o["start"], o["end"], o["nodes"].as_array().map_or(0, Vec::len), o["rooms"].as_array().map_or(0, Vec::len), o["records"]); + let digest = o["digest"].as_str().unwrap_or(""); + let mut t = format!( + "report {} of [{}, {}): {} node line(s), {} room line(s), {} record(s)", + &digest[..16.min(digest.len())], + o["start"], + o["end"], + o["nodes"].as_array().map_or(0, Vec::len), + o["rooms"].as_array().map_or(0, Vec::len), + o["records"] + ); for n in o["nodes"].as_array().into_iter().flatten() { t.push_str(&format!("\n node {:<16} {:<10} sessions {} links {} bytes in {} out {}; energy: {}", n["label"].as_str().unwrap_or(""), n["role"].as_str().unwrap_or(""), n["sessions"], n["links"], n["bytes_in"], n["bytes_out"], cov(&n["energy"]))); } @@ -350,44 +267,54 @@ fn summarise(o: &Value) -> String { t } -fn report_create(args: &[String], out: &Out) -> Result { - let target = super::opt(args, "--target").ok_or_else(|| usage("--target: the fleet service's http:// address", REPORT_USAGE))?; - let start = number(args, "--start", 0)?; - let end = number(args, "--end", 0)?; +/// `wai report create --target URL --start µs --end µs`: the fleet report of a period, which the +/// service signs. It changes the service, so it waits for `--yes`. +pub fn report_create(args: &[String]) -> R { + let usage = super::usage("report.create"); + let with_value = ["--target", "--start", "--end"]; + agent::check_args(args, &with_value, &with_value, 0, &usage)?; + let target = required(args, "--target", "the fleet service's http:// address", &usage)?; + let (start, end) = (number(args, "--start", 0)?, number(args, "--end", 0)?); if super::opt(args, "--start").is_none() || super::opt(args, "--end").is_none() || start >= end { - return Err(usage("--start and --end: the period, in microseconds since 1970, start below end", REPORT_USAGE)); + return Err(OpError::usage("--start and --end: the period, in microseconds since 1970, start below end", &usage)); } - let proposed = json!([{"action": "sign a report", "target": target, "start": start, "end": end}]); - if super::flag(args, "--dry-run") { - return Ok(out.dry_run(proposed)); - } - if !confirmed(args, out, &format!("have {target} sign the report of [{start}, {end})?")) { - return Ok(out.confirmation(proposed, confirm_command("report create", args))); + let period = json!({"target": target, "start": start, "end": end}); + if out::dry_run() { + out::would_send(target, "POST", "/v1/fleet/reports"); + return done("report.create", true, period, &format!("would have {target} sign the fleet report of [{start}, {end})")); } + agent::confirm( + &format!("the fleet service at {target} would sign the fleet report of [{start}, {end})"), + vec![json!({"request": "POST /v1/fleet/reports", "target": target, "start": start, "end": end})], + )?; let (status, v) = call(target, "POST", "/v1/fleet/reports", Some(&json!({"start": start, "end": end})))?; if status != 201 { return Err(answer_fail(status, &v)); } let o = &v["outcome"]; - let text = summarise(o); - Ok(out.done(json!({"report": v["report"], "outcome": o}), &text)) + done("report.create", true, json!({"report": v["report"], "outcome": o}), &summarise(o)) } #[cfg(not(feature = "fleet"))] -fn report_export(_: &[String], _: &Out) -> Result { - Err(fail(USAGE, "feature-missing", "`wai report export` needs a build with the feature `fleet`", "cargo install --path wai/wai-rs --no-default-features --features quickstart")) +pub fn report_export(_: &[String]) -> R { + Err(crate::door::not_built(wai::catalogue::op("report.export").expect("listed"))) } +/// `wai report export --target URL [--out F]`: a report's bundle, fetched, verified here +/// (the service is not trusted) and written to a file. #[cfg(feature = "fleet")] -fn report_export(args: &[String], out: &Out) -> Result { - let target = super::opt(args, "--target").ok_or_else(|| usage("--target: the fleet service's http:// address", REPORT_USAGE))?; - let pos = super::positional(args, &["--target", "--out"]); - let digest = pos.first().filter(|d| d.len() == 64 && unhex(d).is_some()).ok_or_else(|| usage("the report's digest: 64 hexadecimal digits", REPORT_USAGE))?; +pub fn report_export(args: &[String]) -> R { + let usage = super::usage("report.export"); + let with_value = ["--target", "--out"]; + let pos = agent::check_args(args, &with_value, &with_value, 1, &usage)?; + let target = required(args, "--target", "the fleet service's http:// address", &usage)?; + let digest = *pos.first().filter(|d| d.len() == 64 && unhex(d).is_some()).ok_or_else(|| OpError::usage("the report's digest: 64 hexadecimal digits", &usage))?; let file = super::opt(args, "--out").map(str::to_string).unwrap_or_else(|| format!("{}{}", &digest[..16], wai::fleet::BUNDLE_SUFFIX)); let replaces = std::path::Path::new(&file).exists(); - let proposed = json!([{"action": "write", "file": file, "replaces": replaces}]); - if super::flag(args, "--dry-run") { - return Ok(out.dry_run(proposed)); + if out::dry_run() { + out::would_send(target, "GET", &format!("/v1/fleet/reports/{digest}")); + out::would_write(&file); + return done("report.export", true, json!({"digest": digest, "target": target, "file": file}), &format!("would export the bundle of report {}… from {target} to {file}", &digest[..16])); } let (status, v) = call(target, "GET", &format!("/v1/fleet/reports/{digest}"), None)?; if status != 200 { @@ -396,13 +323,14 @@ fn report_export(args: &[String], out: &Out) -> Result { // The bundle verified here, by this build, before it is written: the service is not trusted. let o = wai::fleet::outcome_json(&wai::fleet::verify(&v["bundle"])); if o["outcome"] != "accept" || o["digest"] != json!(digest) { - return Err(fail(REFUSED, o["reason"].as_str().unwrap_or("report_mismatch"), format!("the service's bundle does not verify: {o}"), "report it to the fleet's operator")); + let r = o["reason"].as_str().unwrap_or("report_mismatch"); + return Err(OpError::new(EXIT_FAILED, reason_code(r), format!("the service's bundle does not verify: {o}"), "report it to the fleet's operator")); } - if replaces && !confirmed(args, out, &format!("replace {file}?")) { - return Ok(out.confirmation(proposed, confirm_command("report export", args))); + if replaces { + agent::confirm(&format!("{file} exists, and the bundle would replace it"), vec![json!({"write": file, "replaces": true})])?; } let text_out = serde_json::to_string_pretty(&v["bundle"]).expect("JSON") + "\n"; - std::fs::write(&file, text_out).map_err(|e| fail(if e.kind() == std::io::ErrorKind::PermissionDenied { PERMISSION } else { INTERNAL }, "io-error", format!("{file}: {e}"), "check the directory"))?; + out::write(&file, text_out).map_err(|e| io_fail(&file, &e, "check the directory"))?; let text = format!("{}\nwritten to {file}; check it anywhere with `wai verify {file}`", summarise(&o)); - Ok(out.done(json!({"file": file, "outcome": o}), &text)) + done("report.export", true, json!({"file": file, "outcome": o}), &text) } diff --git a/wai/wai-rs/src/bin/wai_cli/dx/hello.rs b/wai/wai-rs/src/bin/wai_cli/dx/hello.rs index 7530d431b..d35092385 100644 --- a/wai/wai-rs/src/bin/wai_cli/dx/hello.rs +++ b/wai/wai-rs/src/bin/wai_cli/dx/hello.rs @@ -15,38 +15,42 @@ //! The key's secret is never written: the receipt proves the bytes were signed by the key //! it names, and a later `wai verify` checks that again, offline. -pub const USAGE: &str = "wai hello [] WAI's four steps (name, dispatch, rebuild, receipt) on a sample, written to (wai-hello)"; +use super::envelope::OpError; #[cfg(not(feature = "quickstart"))] -pub fn main(_: &[String]) -> Result { - Err("`wai hello` needs a build with the feature `quickstart`".into()) +pub fn main(_: &[String]) -> Result { + Err(OpError::from("`wai hello` needs a build with the feature `quickstart`")) } #[cfg(feature = "quickstart")] -pub fn main(args: &[String]) -> Result { +pub fn main(args: &[String]) -> Result { use super::hex; + use crate::out; use std::path::PathBuf; use wai::codecs::image; use wai::http_binding::receipts::{IndexItem, RECEIPT_DIR, group_file, index_file}; use wai::jwp_delivery::{GroupBuilder, ObjectLabel, WaiReceiptProperty}; use wai::{CAP_IMAGE_PNG, Conditioning, Manifest, ModelRequirement, Wai}; + let usage = super::usage("toolchain.test"); if let Some(a) = args.iter().find(|a| a.starts_with("--")) { - return Err(format!("wai hello: {a}: not an option ({USAGE})")); + return Err(OpError::usage(format!("wai hello: {a}: not an option ({usage})"), "")); } let pos = super::positional(args, &[]); if pos.len() > 1 { - return Err(USAGE.into()); + return Err(OpError::usage(usage, "")); } let dir = PathBuf::from(pos.first().copied().unwrap_or("wai-hello")); - // Only a directory that is empty, or one an earlier `wai hello` wrote, is written to. + // Only a directory that is empty, or one an earlier `wai hello` wrote, is written to; an + // earlier run's files are replaced only once that is confirmed. + let mut earlier = Vec::new(); if dir.exists() { let theirs = std::fs::read_dir(&dir).map_err(|e| format!("{}: {e}", dir.display()))?.flatten().any(|e| { let n = e.file_name().to_string_lossy().into_owned(); !(n == "rcpt" || n.ends_with(".wai") || n.ends_with(".wai.receipts")) }); if theirs { - return Err(format!("{}: holds files `wai hello` did not write; name another directory", dir.display())); + return Err(OpError::from(format!("{}: holds files `wai hello` did not write; name another directory", dir.display()))); } // An earlier run's object, index and receipt files, and nothing else. for e in std::fs::read_dir(&dir).map_err(|e| e.to_string())?.flatten() { @@ -54,15 +58,24 @@ pub fn main(args: &[String]) -> Result { if e.file_name() == "rcpt" { for r in std::fs::read_dir(&p).map_err(|e| e.to_string())?.flatten() { if r.file_name().to_string_lossy().ends_with(".json") { - let _ = std::fs::remove_file(r.path()); + earlier.push(r.path()); } } } else { - let _ = std::fs::remove_file(&p); + earlier.push(p); } } } - std::fs::create_dir_all(dir.join(RECEIPT_DIR)).map_err(|e| format!("{}: {e}", dir.display()))?; + if !earlier.is_empty() { + super::envelope::confirm( + &format!("{} holds an earlier `wai hello`'s {} files, which this run replaces", dir.display(), earlier.len()), + earlier.iter().map(|p| serde_json::json!({"remove": p.display().to_string()})).collect(), + )?; + for p in &earlier { + let _ = out::remove_file(p); + } + } + out::create_dir_all(dir.join(RECEIPT_DIR)).map_err(|e| format!("{}: {e}", dir.display()))?; println!("wai hello: WAI's four steps, on this machine\n"); // 1. Name. @@ -79,7 +92,7 @@ pub fn main(args: &[String]) -> Result { let envelope = Wai::new(manifest, png).to_bytes().map_err(|e| e.to_string())?; let cid = wai::jwp_hash::content_hash(&envelope); let object = dir.join(format!("{}.wai", hex(&cid))); - std::fs::write(&object, &envelope).map_err(|e| e.to_string())?; + out::write(&object, &envelope).map_err(|e| e.to_string())?; println!("1 Name a {w}×{h} picture, packed in an envelope that names the capability it needs:"); println!(" {CAP_IMAGE_PNG} (PNG, one of the mandatory floor every sink implements)"); println!(" {} ({} bytes)\n", object.display(), envelope.len()); @@ -111,16 +124,21 @@ pub fn main(args: &[String]) -> Result { b.add(cid, 0, "wai hello", Some(ObjectLabel::Unmetered)); let (group, objects) = b.finish(&key, &wai::signer_id::did_key(&pk), 0, 0, &start, &end, None, true).ok_or("the group would not seal")?; let link = group.link_hash(); - std::fs::write(dir.join(format!("{RECEIPT_DIR}{}.json", hex(&link))), group_file(&group)?).map_err(|e| e.to_string())?; + out::write(dir.join(format!("{RECEIPT_DIR}{}.json", hex(&link))), group_file(&group)?).map_err(|e| e.to_string())?; let item = IndexItem { property: WaiReceiptProperty { content_hash: cid, joules_micro: 0, acquisition: String::new(), acquisition_evidence: Vec::new(), group_receipt: link }, object_receipt: objects[0].clone(), }; - std::fs::write(dir.join(format!("{}.wai.receipts", hex(&cid))), index_file(&cid, &[item], &[])?).map_err(|e| e.to_string())?; + let index = dir.join(format!("{}.wai.receipts", hex(&cid))); + out::write(&index, index_file(&cid, &[item], &[])?).map_err(|e| e.to_string())?; println!("4 Receipt the object's BLAKE3 content hash, in a group receipt signed with a key made for"); println!(" this run (Ed25519 {}…; its secret is not kept). Energy is stated as not", &hex(&pk)[..16]); println!(" measured: this run reads no meter"); - let (ok, lines) = super::verify::check_index_file(&dir.join(format!("{}.wai.receipts", hex(&cid))))?; + if out::dry_run() { + println!(" (a dry run: the receipt is not written, so it is not checked here)"); + return Ok(true); + } + let (ok, lines) = super::verify::check_index_file(&index)?; for l in lines { println!(" {l}"); } diff --git a/wai/wai-rs/src/bin/wai_cli/dx/mod.rs b/wai/wai-rs/src/bin/wai_cli/dx/mod.rs index 69cc99943..0d3be1f07 100644 --- a/wai/wai-rs/src/bin/wai_cli/dx/mod.rs +++ b/wai/wai-rs/src/bin/wai_cli/dx/mod.rs @@ -1,25 +1,34 @@ -//! Every verb kept in a module of its own, one verb a module, every verb in [`VERBS`]: +//! The developer's path through WAI and the agent door, one operation a module: //! -//! - `hello`: the four steps of WAI (name, dispatch, rebuild, receipt) on a bundled sample; -//! - `create`: a new project from one of the examples (`wai/examples/`), or from an app made -//! with Create (`--from app.oui`, a build with `app`); -//! - `dev`: a relay, a room service and the room page on one origin, on this machine; -//! - `verify`: the receipts under a directory, conformance results and fleet report bundles, -//! checked offline; -//! - `app` (`app.guide`, `app.check`; a build with `app`): an app made with Create, its guide -//! and its check; -//! - `mcp` (`agent.mcp`): WAI's operations as tools for a coding agent, a Model Context +//! - `toolchain.test` (`wai hello`): the four steps of WAI (name, dispatch, rebuild, +//! receipt) on a bundled sample; +//! - `project.create` (`wai create`): a new project from one of the examples +//! (`wai/examples/`), or from an app made with Create (`--from app.oui`, `app.export`); +//! - `dev.start` (`wai dev`): a relay, a room service and the room page on one origin, on this +//! machine; +//! - `receipt.verify` (`wai verify`): the receipts under a directory, conformance results and +//! fleet report bundles, checked offline; +//! - `compat.check` (`wai breaking`): what the working tree breaks against a git ref; +//! - `conformance.run` (`wai conform`): WAI's conformance corpus run against another +//! implementation, its result a signed record; `conformance.list` (`wai conformance list`, +//! also `wai conform --list`) the kinds and suites; +//! - `app.guide`, `app.check`, `app.export` (`wai app guide|check|export`; a build with +//! `app`): an app made with Create; +//! - `mcp.start` (`wai mcp`): WAI's operations as tools for a coding agent, a Model Context //! Protocol server on standard input and output, in toolsets (`../mcp/`); -//! - `setup-agent` (`agent.setup`): a project made ready for coding agents (`../setup_agent.rs`); -//! - `breaking`: what the working tree breaks against a git ref (operation `compat.check`); -//! - `conform`: WAI's conformance corpus run against another implementation (operation -//! `conformance.run`), its result a signed record; -//! - `enrolment` (`enrolment.create`, `enrolment.list`) and `report` (`report.create`, -//! `report.export`): a fleet's nodes enrolled and its reports, against a fleet service -//! (extensions/fleet.md §9; `fleet.rs`). +//! - `agent.setup` (`wai setup-agent`): a project made ready for coding agents +//! (`../setup_agent.rs`); +//! - `enrolment.create`, `enrolment.list` (`wai enrolment create|list`) and `report.create`, +//! `report.export` (`wai report create|export`): a fleet's nodes enrolled and its reports, +//! against a fleet service (extensions/fleet.md §9; `fleet.rs`). //! -//! A verb is named in this table and nowhere else: renaming or regrouping one is a line -//! here. The project file a template writes (`wai.json`) is read by [`project`] alone. +//! [`envelope`] holds the agent rules every command keeps: the exit codes, the JSON envelope, +//! `--dry-run`, `--yes`. +//! +//! [`VERBS`] is this module's view of the operation catalogue (`wai::catalogue`): each +//! entry names its operation by id, and the name a person types, its usage and its help come +//! from the catalogue's entry. The project file a template writes (`wai.json`) is read by +//! [`project`] alone. pub mod project; @@ -29,7 +38,7 @@ mod breaking; pub(crate) mod conform; mod create; mod dev; -mod envelope; +pub(crate) mod envelope; mod fleet; mod hello; mod verify; @@ -40,85 +49,115 @@ mod mcp; #[path = "../setup_agent.rs"] mod setup_agent; +use crate::door::{Impl, Json}; +use envelope::OpError; + /// The verb `wai mcp` is called by; `setup-agent` registers the server under it. pub const MCP: &str = "mcp"; -/// How a verb ends. -pub enum Run { - /// `Ok(true)` when everything it checked held (exit 0), `Ok(false)` when it ran and - /// something did not (exit 1); an error is a usage error (exit 2). - Checks(fn(&[String]) -> Result), - /// An exit status of the toolchain's table (0 done, 1 a check failed, 2 usage, 3 - /// permission, 4 confirmation required, 5 not found, 6 unavailable, 7 internal), the - /// verb's output already written. - Status(fn(&[String]) -> u8), -} - -/// One verb: its name, its usage, and what runs it. -pub struct Verb { - pub name: &'static str, - pub usage: &'static str, - pub run: Run, -} - -pub const VERBS: &[Verb] = &[ - Verb { name: "hello", usage: hello::USAGE, run: Run::Checks(hello::main) }, - Verb { name: "create", usage: create::USAGE, run: Run::Checks(create::main) }, - Verb { name: "dev", usage: dev::USAGE, run: Run::Checks(dev::main) }, - Verb { name: "verify", usage: verify::USAGE, run: Run::Checks(verify::main) }, - Verb { name: "app", usage: app::USAGE, run: Run::Checks(app::main) }, - Verb { name: MCP, usage: MCP_USAGE, run: Run::Checks(mcp_main) }, - Verb { name: "setup-agent", usage: setup_agent::USAGE, run: Run::Status(setup_agent::main) }, - // Operations of the toolchain's catalogue, kept here until it lands: `compat.check` and - // `conformance.run`. - Verb { name: "breaking", usage: breaking::USAGE, run: Run::Checks(breaking::main) }, - Verb { name: "conform", usage: CONFORM_USAGE, run: Run::Checks(conform_main) }, - Verb { name: "enrolment", usage: fleet::ENROLMENT_USAGE, run: Run::Status(fleet::enrolment) }, - Verb { name: "report", usage: fleet::REPORT_USAGE, run: Run::Status(fleet::report) }, +/// The developer's path, by operation id. +pub const VERBS: &[Impl] = &[ + Impl { id: "toolchain.test", run: hello::main, json: Json::Text }, + Impl { id: "project.create", run: create::main, json: Json::OwnWith("--list") }, + Impl { id: "dev.start", run: dev::main, json: Json::StreamUnless("--check") }, + Impl { id: "receipt.verify", run: verify::main, json: Json::Text }, + Impl { id: "compat.check", run: compat_check, json: Json::Native }, + Impl { id: "conformance.run", run: conformance_run, json: Json::Native }, + Impl { id: "conformance.list", run: conformance_list, json: Json::Native }, + Impl { id: "app.guide", run: app_guide, json: Json::Text }, + Impl { id: "app.check", run: app_check, json: Json::Native }, + Impl { id: "app.export", run: app_export, json: Json::Text }, + Impl { id: "mcp.start", run: mcp_main, json: Json::Native }, + Impl { id: "agent.setup", run: setup_agent::main, json: Json::Native }, + Impl { id: "enrolment.create", run: fleet::enrolment_create, json: Json::Native }, + Impl { id: "enrolment.list", run: fleet::enrolment_list, json: Json::Native }, + Impl { id: "report.create", run: fleet::report_create, json: Json::Native }, + Impl { id: "report.export", run: fleet::report_export, json: Json::Native }, ]; -const MCP_USAGE: &str = "wai mcp [--toolset core,dev,app] [--read-only] [--project DIR] [--spec-root DIR] [--room-bin BIN]\n WAI's operations as tools for a coding agent: a Model Context Protocol server on standard\n input and output (a build with `mcp`)"; - -#[cfg(feature = "mcp")] -fn mcp_main(args: &[String]) -> Result { - mcp::main(args) +/// An operation's synopsis, from the catalogue, for a usage error. +pub(crate) fn usage(id: &str) -> String { + let o = wai::catalogue::op(id).expect("an operation of the catalogue"); + o.usage.lines().next().map(|l| format!("wai {l}")).unwrap_or_default() } -#[cfg(not(feature = "mcp"))] -fn mcp_main(_: &[String]) -> Result { - Err(format!("`wai {MCP}` needs a build with the feature `mcp` (cargo install --path wai/wai-rs --no-default-features --features mcp)")) +/// `args` with the agent flags `wai` read off the command line handed back, those of `takes` +/// that the operation reads itself (`--json`, `--dry-run`). `--yes` is read where a change +/// waits for it, from [`envelope::ctx`] ([`envelope::confirm`]). +fn with_agent_flags(args: &[String], takes: &[&str]) -> Vec { + let c = envelope::ctx(); + let mut a = args.to_vec(); + for (on, flag) in [(c.json, "--json"), (c.dry_run, "--dry-run")] { + if on && takes.contains(&flag) && !a.iter().any(|x| x == flag) { + a.push(flag.into()); + } + } + a } -const CONFORM_USAGE: &str = "wai conform --suite --impl [--key ] [--out ] [--confirm]\n\ - [--root ] [--dry-run] [--json|--text] WAI's conformance corpus run against another\n\ - implementation, the result a signed record `wai verify` checks; `wai conform --list` names the\n\ - kinds and suites (operation conformance.run)"; +fn compat_check(args: &[String]) -> Result { + Ok(breaking::main(&with_agent_flags(args, &["--json"]))?) +} #[cfg(feature = "quickstart")] -fn conform_main(args: &[String]) -> Result { - conform::main(args) +fn conformance_run(args: &[String]) -> Result { + Ok(conform::main(&with_agent_flags(args, &["--json", "--dry-run"]))?) +} + +#[cfg(feature = "quickstart")] +fn conformance_list(args: &[String]) -> Result { + let mut a = vec!["--list".to_string()]; + a.extend(with_agent_flags(args, &["--json"])); + Ok(conform::main(&a)?) } #[cfg(not(feature = "quickstart"))] -fn conform_main(_: &[String]) -> Result { - Err("`wai conform` needs a build with the feature `quickstart`".into()) +fn conformance_run(_: &[String]) -> Result { + Err(crate::door::not_built(wai::catalogue::op("conformance.run").expect("listed"))) } -/// The verb `args[0]` names, run on the rest, and its exit status; `None` when it names none -/// of these. -pub fn dispatch(args: &[String]) -> Option> { - let v = VERBS.iter().find(|v| Some(v.name) == args.first().map(String::as_str))?; - Some(match v.run { - Run::Checks(run) => run(&args[1..]).map(|held| if held { 0 } else { 1 }), - Run::Status(run) => Ok(run(&args[1..])), - }) +#[cfg(not(feature = "quickstart"))] +fn conformance_list(_: &[String]) -> Result { + Err(crate::door::not_built(wai::catalogue::op("conformance.list").expect("listed"))) } -/// Each verb's usage line, for `wai`'s own usage. -pub fn usage() { - for v in VERBS { - eprintln!("{}", v.usage); +/// `words` then `args`. +fn with(words: &[&str], args: &[String]) -> Vec { + words.iter().map(|w| w.to_string()).chain(args.iter().cloned()).collect() +} + +fn app_guide(args: &[String]) -> Result { + Ok(app::main(&with(&["guide"], args))?) +} + +fn app_check(args: &[String]) -> Result { + Ok(app::main(&with(&["check"], &with_agent_flags(args, &["--json"])))?) +} + +/// `wai app export [--runtime DIR]`: `wai create --from `. +fn app_export(args: &[String]) -> Result { + let pos = positional(args, &["--runtime"]); + let [program, dir] = pos.as_slice() else { + return Err(OpError::usage(usage("app.export"), "")); + }; + let mut a = vec![dir.to_string(), "--from".into(), program.to_string()]; + if let Some(r) = opt(args, "--runtime") { + a.extend(["--runtime".into(), r.to_string()]); } + create::main(&a) +} + +#[cfg(feature = "mcp")] +fn mcp_main(args: &[String]) -> Result { + if crate::out::dry_run() { + return mcp::plan(args).map_err(OpError::from); + } + mcp::main(args).map_err(OpError::from) +} + +#[cfg(not(feature = "mcp"))] +fn mcp_main(_: &[String]) -> Result { + Err(crate::door::not_built(wai::catalogue::op("mcp.start").expect("listed"))) } /// The value after `flag` among `args`. diff --git a/wai/wai-rs/src/bin/wai_cli/dx/templates.rs b/wai/wai-rs/src/bin/wai_cli/dx/templates.rs index a7da76137..2d8daef04 100644 --- a/wai/wai-rs/src/bin/wai_cli/dx/templates.rs +++ b/wai/wai-rs/src/bin/wai_cli/dx/templates.rs @@ -1,8 +1,13 @@ //! The templates `wai create` writes: each example of `wai/examples/` but its `test/` //! directory, its files held in this binary. `tests/cli_dx.rs` holds this table to the -//! examples on disk, file for file and byte for byte, so the two cannot drift. +//! examples on disk, file for file and byte for byte, so the two cannot drift. Every +//! template is Apache-2.0 and carries the license's text as `LICENSE`: one copy in this +//! binary, `wai/LICENSE`, which each example's `LICENSE` must equal byte for byte. -use super::Template; +use super::{File, Template}; + +/// The Apache-2.0 text every template carries: the one copy `wai create` holds. +const LICENSE: File = super::LICENSE; macro_rules! ex { ($t:literal, $f:literal) => { @@ -15,6 +20,7 @@ pub const TEMPLATES: &[Template] = &[ name: "video-room", title: "A video room in one page", files: &[ + LICENSE, ex!("video-room", ".gitignore"), ex!("video-room", "README.md"), ex!("video-room", "index.html"), @@ -26,6 +32,7 @@ pub const TEMPLATES: &[Template] = &[ name: "watch-together", title: "Watch together", files: &[ + LICENSE, ex!("watch-together", ".gitignore"), ex!("watch-together", "README.md"), ex!("watch-together", "index.html"), @@ -37,6 +44,7 @@ pub const TEMPLATES: &[Template] = &[ name: "messaging", title: "Messaging and a call", files: &[ + LICENSE, ex!("messaging", ".gitignore"), ex!("messaging", "README.md"), ex!("messaging", "index.html"), @@ -48,6 +56,7 @@ pub const TEMPLATES: &[Template] = &[ name: "encoder-ingest", title: "Stream from an encoder into a room", files: &[ + LICENSE, ex!("encoder-ingest", ".gitignore"), ex!("encoder-ingest", "Cargo.toml"), ex!("encoder-ingest", "README.md"), @@ -60,6 +69,7 @@ pub const TEMPLATES: &[Template] = &[ name: "package-and-deliver", title: "Package a title and play it", files: &[ + LICENSE, ex!("package-and-deliver", ".gitignore"), ex!("package-and-deliver", "README.md"), ex!("package-and-deliver", "floor/v.obu"), @@ -76,6 +86,7 @@ pub const TEMPLATES: &[Template] = &[ name: "verify-receipts", title: "Verify receipts in Rust and in Python", files: &[ + LICENSE, ex!("verify-receipts", ".gitignore"), ex!("verify-receipts", "README.md"), ex!("verify-receipts", "python/_blake3.py"), @@ -91,6 +102,7 @@ pub const TEMPLATES: &[Template] = &[ name: "apple-app", title: "A native app on Apple platforms", files: &[ + LICENSE, ex!("apple-app", ".gitignore"), ex!("apple-app", "Package.swift"), ex!("apple-app", "README.md"), @@ -106,6 +118,7 @@ pub const TEMPLATES: &[Template] = &[ name: "android-app", title: "The native sink in Kotlin, for Android", files: &[ + LICENSE, ex!("android-app", ".gitignore"), ex!("android-app", "README.md"), ex!("android-app", "replay/build.gradle.kts"), @@ -120,6 +133,7 @@ pub const TEMPLATES: &[Template] = &[ name: "remote-desktop", title: "Remote desktop in a room", files: &[ + LICENSE, ex!("remote-desktop", ".gitignore"), ex!("remote-desktop", "README.md"), ex!("remote-desktop", "host.sh"), diff --git a/wai/wai-rs/src/bin/wai_cli/dx/verify.rs b/wai/wai-rs/src/bin/wai_cli/dx/verify.rs index 992e7f0ba..3672eb232 100644 --- a/wai/wai-rs/src/bin/wai_cli/dx/verify.rs +++ b/wai/wai-rs/src/bin/wai_cli/dx/verify.rs @@ -15,23 +15,23 @@ //! A fleet report's bundle (`*.wai-fleet.json`, extensions/fleet.md §7) under a path is verified //! too, in a build with `fleet`: the report recomputed from its enrolments and records. -pub const USAGE: &str = "wai verify ... the receipts (receipt indexes *.wai.receipts), conformance results\n\ - (*.wai-conform.json) and fleet report bundles (*.wai-fleet.json) under each path, checked offline"; +use super::envelope::OpError; #[cfg(not(feature = "quickstart"))] -pub fn main(_: &[String]) -> Result { - Err("`wai verify` needs a build with the feature `quickstart`".into()) +pub fn main(_: &[String]) -> Result { + Err(OpError::from("`wai verify` needs a build with the feature `quickstart`")) } #[cfg(feature = "quickstart")] -pub fn main(args: &[String]) -> Result { +pub fn main(args: &[String]) -> Result { use std::path::{Path, PathBuf}; + let usage = super::usage("receipt.verify"); if let Some(a) = args.iter().find(|a| a.starts_with("--")) { - return Err(format!("wai verify: {a}: not an option ({USAGE})")); + return Err(OpError::usage(format!("wai verify: {a}: not an option ({usage})"), "")); } let paths = super::positional(args, &[]); if paths.is_empty() { - return Err(USAGE.into()); + return Err(OpError::usage(usage, "")); } fn walk(p: &Path, out: &mut Vec, bundles: &mut Vec, depth: usize) -> Result<(), String> { if p.is_file() { @@ -64,10 +64,10 @@ pub fn main(args: &[String]) -> Result { walk(Path::new(p), &mut indexes, &mut bundles, 0)?; } if indexes.is_empty() && bundles.is_empty() { - return Err(format!( + return Err(OpError::from(format!( "no receipt index (*.wai.receipts), conformance result (*.wai-conform.json) or fleet bundle (*.wai-fleet.json) under {}", paths.join(", ") - )); + ))); } let mut all = true; let (mut n_ix, mut n_rec) = (0, 0); diff --git a/wai/wai-rs/src/bin/wai_cli/encode.rs b/wai/wai-rs/src/bin/wai_cli/encode.rs index e3d96f444..43de7a1e2 100644 --- a/wai/wai-rs/src/bin/wai_cli/encode.rs +++ b/wai/wai-rs/src/bin/wai_cli/encode.rs @@ -15,7 +15,7 @@ fn read(p: &Path) -> Result, String> { } fn write(p: &str, b: &[u8]) -> Result<(), String> { - std::fs::write(p, b).map_err(|e| format!("{p}: {e}")) + crate::out::write(p, b).map_err(|e| format!("{p}: {e}")) } fn seed(s: &str) -> Result<[u8; 32], String> { diff --git a/wai/wai-rs/src/bin/wai_cli/generate.rs b/wai/wai-rs/src/bin/wai_cli/generate.rs index 02c434302..9dc5bc6d9 100644 --- a/wai/wai-rs/src/bin/wai_cli/generate.rs +++ b/wai/wai-rs/src/bin/wai_cli/generate.rs @@ -86,7 +86,7 @@ fn flags(args: &[String]) -> Result<(String, u32, u32), String> { } fn write_and_report(rgb: &[u8], w: u32, h: u32, out: &str, json: bool) -> Result<(), String> { - std::fs::write(out, rgb).map_err(|e| format!("write {out}: {e}"))?; + crate::out::write(out, rgb).map_err(|e| format!("write {out}: {e}"))?; let sha = wai::pin::sha256_hex(rgb); if json { println!("{}", serde_json::json!({ "w": w, "h": h, "bytes": rgb.len(), "sha256": sha })); @@ -113,7 +113,7 @@ pub fn main(args: &[String]) -> Result<(), String> { let object = i.canonical(); // What is written is what a sink reads. debug_assert_eq!(Instruction::parse(&object).as_ref(), Ok(&i)); - std::fs::write(dest, &object).map_err(|e| format!("write {dest}: {e}"))?; + crate::out::write(dest, &object).map_err(|e| format!("write {dest}: {e}"))?; println!("instruction object → {dest} ({} B)", object.len()); return Ok(()); } diff --git a/wai/wai-rs/src/bin/wai_cli/http.rs b/wai/wai-rs/src/bin/wai_cli/http.rs index 7beb60378..e1439efc1 100644 --- a/wai/wai-rs/src/bin/wai_cli/http.rs +++ b/wai/wai-rs/src/bin/wai_cli/http.rs @@ -133,7 +133,7 @@ fn read(p: &str) -> Result, String> { fn publish(args: &[String]) -> Result<(), String> { let out = PathBuf::from(opt(args, "--out").ok_or("publish needs --out ")?); - std::fs::create_dir_all(&out).map_err(|e| e.to_string())?; + crate::out::create_dir_all(&out).map_err(|e| e.to_string())?; let inputs = positional(args, &["--out"]); if inputs.is_empty() { return Err(usage()); @@ -147,9 +147,9 @@ fn publish(args: &[String]) -> Result<(), String> { let cid = content_hash(&env); let name = object_name(&cid); let proof = HeadProof::build(&env).map_err(|e| format!("{p}: {e}"))?; - std::fs::write(out.join(&name), &env).map_err(|e| e.to_string())?; - std::fs::write(out.join(format!("{name}.hp")), proof.to_bytes()).map_err(|e| e.to_string())?; - std::fs::write(out.join(format!("{name}.etag")), format!("\"{}\"\n", hex(&cid))).map_err(|e| e.to_string())?; + crate::out::write(out.join(&name), &env).map_err(|e| e.to_string())?; + crate::out::write(out.join(format!("{name}.hp")), proof.to_bytes()).map_err(|e| e.to_string())?; + crate::out::write(out.join(format!("{name}.etag")), format!("\"{}\"\n", hex(&cid))).map_err(|e| e.to_string())?; let element = codecs::element_of(&head).map(|e| codecs::render(&e)).unwrap_or_else(|e| format!("({e})")); println!("{}\tn={}\th={}\t{element}\t{p}", hex(&cid), env.len(), head.covered_len()); } @@ -625,6 +625,14 @@ fn origin_cmd(args: &[String]) -> Result<(), String> { None => None, }; let cfg = OriginConfig { root: root.clone(), delay, live, hits, max_connections, max_per_peer, read_timeout, lifetime, active, peers: Default::default(), live_edge }; + // `--dry-run`: say what would be served, and serve nothing. + if crate::out::dry_run() { + if !root.is_dir() { + return Err(format!("--root {}: no such directory", root.display())); + } + println!("wai http origin --dry-run: nothing served. It would serve {} on http://127.0.0.1:{port}/", root.display()); + return Ok(()); + } let listener = TcpListener::bind(("127.0.0.1", port)).map_err(|e| format!("bind 127.0.0.1:{port}: {e}"))?; println!("serving {} on http://127.0.0.1:{}/", root.display(), listener.local_addr().map_err(|e| e.to_string())?.port()); for stream in listener.incoming().flatten() { @@ -861,7 +869,7 @@ fn bench(args: &[String]) -> Result<(), String> { "refinement_deadline_ms": deadline, "energy": "omitted: not measured", }); - std::fs::write(out, serde_json::to_string_pretty(&rec).expect("json") + "\n").map_err(|e| e.to_string())?; + crate::out::write(out, serde_json::to_string_pretty(&rec).expect("json") + "\n").map_err(|e| e.to_string())?; println!("wrote {out}"); Ok(()) } diff --git a/wai/wai-rs/src/bin/wai_cli/http_playlists.rs b/wai/wai-rs/src/bin/wai_cli/http_playlists.rs index ee62960ec..0d448e609 100644 --- a/wai/wai-rs/src/bin/wai_cli/http_playlists.rs +++ b/wai/wai-rs/src/bin/wai_cli/http_playlists.rs @@ -62,9 +62,9 @@ fn read(p: &Path) -> Result, String> { fn write(p: &Path, b: &[u8]) -> Result<(), String> { if let Some(d) = p.parent() { - std::fs::create_dir_all(d).map_err(|e| format!("{}: {e}", d.display()))?; + crate::out::create_dir_all(d).map_err(|e| format!("{}: {e}", d.display()))?; } - std::fs::write(p, b).map_err(|e| format!("{}: {e}", p.display())) + crate::out::write(p, b).map_err(|e| format!("{}: {e}", p.display())) } /// One variant of an objects list, read: its classical playlist and the objects that diff --git a/wai/wai-rs/src/bin/wai_cli/http_vectors.rs b/wai/wai-rs/src/bin/wai_cli/http_vectors.rs index f4ea976aa..d950f2414 100644 --- a/wai/wai-rs/src/bin/wai_cli/http_vectors.rs +++ b/wai/wai-rs/src/bin/wai_cli/http_vectors.rs @@ -67,8 +67,8 @@ pub fn main(args: &[String]) -> Result { let files = generate(&dir)?; for (rel, bytes) in &files { let p = dir.join(rel); - std::fs::create_dir_all(p.parent().expect("a parent")).map_err(|e| e.to_string())?; - std::fs::write(&p, bytes).map_err(|e| format!("{}: {e}", p.display()))?; + crate::out::create_dir_all(p.parent().expect("a parent")).map_err(|e| e.to_string())?; + crate::out::write(&p, bytes).map_err(|e| format!("{}: {e}", p.display()))?; } println!("wrote {} files under {}", files.len(), dir.display()); Ok(true) @@ -79,7 +79,7 @@ pub fn main(args: &[String]) -> Result { let count: usize = opt(args, "--count").and_then(|s| s.parse().ok()).ok_or("fuzz needs --count")?; let out = opt(args, "--out").ok_or("fuzz needs --out")?; let (bytes, n, stats) = fuzz(Path::new(dir), seed, count)?; - std::fs::write(out, bytes).map_err(|e| e.to_string())?; + crate::out::write(out, bytes).map_err(|e| e.to_string())?; println!("wrote {n} cases to {out}"); for (family, (generated, refused)) in &stats { println!(" {family}: {generated} generated, {refused} refused"); @@ -91,7 +91,7 @@ pub fn main(args: &[String]) -> Result { let count: usize = opt(args, "--count").and_then(|s| s.parse().ok()).ok_or("diff needs --count")?; let out = opt(args, "--out").ok_or("diff needs --out")?; let _ = dir; - std::fs::write(out, differential(seed, count)?).map_err(|e| e.to_string())?; + crate::out::write(out, differential(seed, count)?).map_err(|e| e.to_string())?; println!("wrote {count} envelopes' prefixes and proofs to {out}"); Ok(true) } diff --git a/wai/wai-rs/src/bin/wai_cli/mcp/dev_room.rs b/wai/wai-rs/src/bin/wai_cli/mcp/dev_room.rs index 255ad8ac6..866a439b3 100644 --- a/wai/wai-rs/src/bin/wai_cli/mcp/dev_room.rs +++ b/wai/wai-rs/src/bin/wai_cli/mcp/dev_room.rs @@ -1,8 +1,8 @@ //! The `dev` toolset: a relay and a room service on loopback, run as the room service's `dev` -//! mode (`joule-moq-room dev`, a child process driven by JSON control lines) by `dev_start` and +//! mode (`wai-room dev`, a child process driven by JSON control lines) by `dev_start` and //! `dev_stop`; and on that room, the target `local`, grants minted for test principals //! (`room_grant`), those principals joining, publishing and leaving (`room_join`, `room_leave`), -//! and the room's session as a verifier is given it (`room_session`). +//! and the room's session as a verifier is given it (`session_show`). //! //! **Development only.** The room binds 127.0.0.1 alone and its keys live only in the child //! process. A grant is asked for over HTTP from a service whose address is a loopback address, @@ -137,8 +137,8 @@ fn dev_mut(server: &mut Server) -> Result<&mut Dev, String> { fn room_bin(server: &Server, args: &Value) -> Result { let given = opt_str(args, "room_bin").map(String::from).or_else(|| server.room_bin.as_ref().map(|p| p.display().to_string())); let exe_dir = std::env::current_exe().ok().and_then(|e| std::fs::canonicalize(e).ok()).and_then(|e| e.parent().map(Path::to_path_buf)); - super::super::dev::find_bin("joule-moq-room", given.as_deref(), "WAI_ROOM_BIN", exe_dir.as_deref()).map_err(|e| { - format!("{e}. The development room runs the room service joule-moq-room (built from joule-code/joule-code-rs/crates/joule-moq-room): give room_bin, --room-bin or WAI_ROOM_BIN") + super::super::dev::find_bin("wai-room", given.as_deref(), "WAI_ROOM_BIN", exe_dir.as_deref()).map_err(|e| { + format!("{e}. The development room runs the room service wai-room (built from joule-code/joule-code-rs/crates/joule-moq-room): give room_bin, --room-bin or WAI_ROOM_BIN") }) } diff --git a/wai/wai-rs/src/bin/wai_cli/mcp/mod.rs b/wai/wai-rs/src/bin/wai_cli/mcp/mod.rs index aa64bde46..f28dacbc5 100644 --- a/wai/wai-rs/src/bin/wai_cli/mcp/mod.rs +++ b/wai/wai-rs/src/bin/wai_cli/mcp/mod.rs @@ -1,4 +1,4 @@ -//! `wai mcp` (catalogue `agent.mcp`): WAI's operations as tools for a coding assistant, served +//! `wai mcp` (catalogue `mcp.start`): WAI's operations as tools for a coding assistant, served //! over the Model Context Protocol (revision 2025-06-18) on standard input and output, one //! JSON-RPC message per line. Standard output carries the protocol alone; anything else goes to //! standard error. @@ -28,7 +28,7 @@ //! comma-separated). `--spec-root` (or `WAI_SPEC_ROOT`) is the `wai/` directory whose //! specification text and conformance corpora the tools read; by default the one this binary //! was built from, when it is still there. `--room-bin` (or `WAI_ROOM_BIN`) is the room service -//! program the development room runs (`joule-moq-room`); by default the one beside this binary, +//! program the development room runs (`wai-room`); by default the one beside this binary, //! then on `PATH`. `--project` is the directory the server may write in (the packaging tool's //! envelopes), by default the one it starts in; it writes nowhere else. `--read-only` offers only //! the tools that read or run a verifier: nothing that writes a file, starts a process that @@ -210,8 +210,16 @@ fn usage() -> String { ) } -/// `wai mcp`: serve until standard input closes. -pub fn main(args: &[String]) -> Result { +/// What `wai mcp` is started with. +struct Options { + toolsets: Vec, + spec_root: Option, + room_bin: Option, + project: PathBuf, + read_only: bool, +} + +fn options(args: &[String]) -> Result { let mut toolsets: Vec = Vec::new(); let mut spec_root = None; let mut room_bin = None; @@ -244,8 +252,32 @@ pub fn main(args: &[String]) -> Result { toolsets.push(tools::Toolset::Core); } let project = project.map_or_else(std::env::current_dir, Ok).and_then(|p| p.canonicalize()).map_err(|e| format!("the project directory: {e}"))?; + Ok(Options { toolsets, spec_root, room_bin, project, read_only }) +} + +/// `wai mcp --dry-run`: the tools it would serve, and nothing served (no bridge started). +pub fn plan(args: &[String]) -> Result { + let o = options(args)?; + let sets: Vec<&str> = o.toolsets.iter().map(|t| t.name()).collect(); + let server = Server::new(o.toolsets.clone(), o.spec_root, o.room_bin, o.project.clone(), o.read_only); + let names: Vec = tools::offered(&server).map(|t| t.name()).collect(); + if crate::dx::envelope::ctx().json { + let data = json!({"toolsets": sets, "read_only": o.read_only, "project": o.project.display().to_string(), "tools": names}); + crate::dx::envelope::print(&crate::dx::envelope::envelope_with("mcp.start", true, data, Some(Vec::new()))); + } else { + println!("wai mcp --dry-run: nothing served. On standard input and output it would serve the toolsets {}{}, {} tools:", + sets.join(", "), if o.read_only { " read-only" } else { "" }, names.len()); + println!(" {}", names.join(", ")); + println!(" writing only inside {}", o.project.display()); + } + Ok(true) +} + +/// `wai mcp`: serve until standard input closes. +pub fn main(args: &[String]) -> Result { + let o = options(args)?; #[cfg_attr(not(feature = "app"), allow(unused_mut))] - let mut server = Server::new(toolsets, spec_root, room_bin, project, read_only); + let mut server = Server::new(o.toolsets, o.spec_root, o.room_bin, o.project, o.read_only); #[cfg(feature = "app")] if server.toolsets.contains(&tools::Toolset::App) && !server.read_only { let b = wai_app::bridge::Bridge::start(0).map_err(|e| format!("the bridge for Create pages: {e}"))?; diff --git a/wai/wai-rs/src/bin/wai_cli/mcp/tools.rs b/wai/wai-rs/src/bin/wai_cli/mcp/tools.rs index 29c436b54..b5315f432 100644 --- a/wai/wai-rs/src/bin/wai_cli/mcp/tools.rs +++ b/wai/wai-rs/src/bin/wai_cli/mcp/tools.rs @@ -1,7 +1,9 @@ //! The tools `wai mcp` serves, in one table ([`TOOLS`]): each tool's operation (its name in the //! toolchain's catalogue), its toolset, title, one-line description, input schema, its safety //! hints and the function that runs it. A tool's name is its operation's, with `_` for the dot -//! (`wai::ops::mcp_tool_name`); nothing else names it. +//! (`wai::catalogue::Op::names`); nothing else names it. Its tests hold this table to the +//! catalogue both ways: every tool is an operation the catalogue says `wai mcp` serves, in the +//! catalogue's toolset, and every such operation is a tool here. #[cfg(feature = "app")] use super::app; @@ -76,7 +78,7 @@ pub(crate) struct Tool { impl Tool { /// The tool's name: its operation's, with `_` for the dot. pub(crate) fn name(&self) -> String { - wai::ops::mcp_tool_name(self.op) + wai::catalogue::op(self.op).map_or_else(|| self.op.replace('.', "_"), |o| o.names().mcp) } } @@ -90,7 +92,7 @@ pub(crate) const TOOLS: &[Tool] = &[ description: "One capability's registry entry, whether this build decodes it natively, whether it is dispatched standalone, companion-only or a candidate, and the specification lines that name it." }, Tool { op: "receipt.verify", toolset: Toolset::Core, title: "Verify a receipt, a claim, a room session or an audit bundle", effect: Effect::Reads, schema: s_verify, run: receipt_verify, description: "Verify, by kind (given, or read from the arguments): a JWP delivery receipt (jwp-receipts §2.2 to §3, a group receipt or an object receipt against its group), a claim receipt (§2.4, §3.1, with the context given), a live-room session (live-rooms §9) or a stream audit bundle (stream-audit §9, §10). The verifier's verdict, and the step and reason code it refuses at." }, - Tool { op: "package.build", toolset: Toolset::Core, title: "Package a media file", effect: Effect::Writes, schema: s_package, run: package_build, + Tool { op: "envelope.wrap", toolset: Toolset::Core, title: "Package a media file", effect: Effect::Writes, schema: s_package, run: package_build, description: "Carry a media file's bytes in a WAI envelope under a registered capability, with an optional fallback or companions, as `wai wrap` does and under the same rules; writes the envelope inside the project directory and returns what it holds." }, Tool { op: "conformance.list", toolset: Toolset::Core, title: "List conformance corpora", effect: Effect::Reads, schema: s_none, run: conformance_list, description: "The conformance corpora in the specification tree and the independent verifier that checks each, as CI runs them." }, @@ -108,7 +110,7 @@ pub(crate) const TOOLS: &[Tool] = &[ description: "DEVELOPMENT ONLY. On the target local: enter the development room as a test principal with its grant and a proof of its key, publish its audio and video tracks with a few objects, and wait until the room's clock has signed its join and publishes." }, Tool { op: "room.leave", toolset: Toolset::Dev, title: "Leave as a test principal", effect: Effect::DevRoom, schema: s_room_principal, run: dev_room::leave, description: "DEVELOPMENT ONLY. On the target local: close a test principal's session in the development room and wait for the clock's leave." }, - Tool { op: "room.session", toolset: Toolset::Dev, title: "Read the development room's session", effect: Effect::DevRoom, schema: s_target, run: dev_room::session, + Tool { op: "session.show", toolset: Toolset::Dev, title: "Read the development room's session", effect: Effect::DevRoom, schema: s_target, run: dev_room::session, description: "DEVELOPMENT ONLY. On the target local: the development room as a verifier is given it (descriptor, grants, key-log head and the clock's event chain), ready for receipt_verify." }, #[cfg(feature = "app")] Tool { op: "app.guide", toolset: Toolset::App, title: "The guide to writing a WAI app", effect: Effect::Reads, schema: s_none, run: app::guide, @@ -264,7 +266,7 @@ fn s_target() -> Value { fn s_dev_start() -> Value { obj( json!({ - "room_bin": {"type": "string", "description": "The room service program (joule-moq-room); default --room-bin, WAI_ROOM_BIN, beside wai, then PATH."}, + "room_bin": {"type": "string", "description": "The room service program (wai-room); default --room-bin, WAI_ROOM_BIN, beside wai, then PATH."}, "publishers": {"type": "integer", "minimum": 1, "maximum": 64, "description": "Publishers at once (default 4)."} }), &[], @@ -964,7 +966,9 @@ mod tests { fn every_tool_is_an_operation_named_by_the_rule_once() { let mut seen = std::collections::BTreeSet::new(); for t in TOOLS { - assert_eq!(wai::ops::check_id(t.op), Ok(()), "{}", t.op); + let op = wai::catalogue::op(t.op).unwrap_or_else(|| panic!("the tool {} serves {}, which the operation catalogue does not hold", t.name(), t.op)); + assert!(op.mcp, "{}: the catalogue does not say wai mcp serves it", t.op); + assert_eq!(op.toolset.label(), t.toolset.name(), "{}: the catalogue's toolset", t.op); assert!(seen.insert(t.op), "{} twice", t.op); assert_eq!(t.name(), t.op.replace('.', "_")); assert!(!t.description.contains('\n'), "{}: one line", t.op); @@ -975,4 +979,36 @@ mod tests { assert!(TOOLS.iter().any(|x| x.toolset == *t), "{}: a toolset with no tool", t.name()); } } + + #[test] + fn every_operation_the_catalogue_says_wai_mcp_serves_is_a_tool() { + // An operation of a toolset this build does not hold (`app` without the feature) is not + // asked for here. + for o in wai::catalogue::OPS.iter().filter(|o| o.mcp && o.available()) { + assert!(TOOLS.iter().any(|t| t.op == o.id), "the catalogue says wai mcp serves {}, and no tool does", o.id); + } + } + + /// An operation wai mcp alone carries takes the catalogue's input: the tool's arguments + /// are the catalogue's, the required ones too. + #[test] + fn a_tool_of_mcp_alone_takes_the_catalogues_input() { + for t in TOOLS { + let o = wai::catalogue::op(t.op).expect("a catalogue operation"); + if o.cli.is_some() || o.route.is_some() { + continue; + } + let schema = (t.schema)(); + let mut have: Vec<&str> = schema["properties"].as_object().unwrap().keys().map(String::as_str).collect(); + let mut want: Vec<&str> = o.input.iter().map(|a| a.name).collect(); + have.sort(); + want.sort(); + assert_eq!(have, want, "{}: the tool's arguments are the catalogue's input", t.op); + let mut req: Vec<&str> = schema["required"].as_array().unwrap().iter().map(|r| r.as_str().unwrap()).collect(); + let mut want_req: Vec<&str> = o.input.iter().filter(|a| a.required).map(|a| a.name).collect(); + req.sort(); + want_req.sort(); + assert_eq!(req, want_req, "{}: the required arguments", t.op); + } + } } diff --git a/wai/wai-rs/src/bin/wai_cli/out.rs b/wai/wai-rs/src/bin/wai_cli/out.rs new file mode 100644 index 000000000..b68c74951 --- /dev/null +++ b/wai/wai-rs/src/bin/wai_cli/out.rs @@ -0,0 +1,135 @@ +//! Every file the `wai` command line writes, makes or removes goes through here, so that +//! `--dry-run` writes nothing: each call is recorded in the plan instead, and the plan is +//! what the command reports it would have done. A request that would change a service the +//! command line names (a fleet service) is named in the plan the same way, and not sent. + +use serde_json::{Value, json}; +use std::io::{self, Write}; +use std::path::Path; +use std::sync::Mutex; +use std::sync::atomic::{AtomicBool, Ordering}; + +static DRY_RUN: AtomicBool = AtomicBool::new(false); +static PLAN: Mutex> = Mutex::new(Vec::new()); + +/// Turn dry running on or off for the rest of the process. +pub fn set_dry_run(on: bool) { + DRY_RUN.store(on, Ordering::SeqCst); +} + +/// Whether this run writes nothing. +pub fn dry_run() -> bool { + DRY_RUN.load(Ordering::SeqCst) +} + +fn note(v: Value) { + PLAN.lock().unwrap_or_else(|e| e.into_inner()).push(v); +} + +/// What a dry run would have done, in order: `{"write": path, "bytes": n}`, +/// `{"create-dir": path}`, `{"remove": path}`, `{"request": "METHOD /path", "target": url}`. +pub fn plan() -> Vec { + PLAN.lock().unwrap_or_else(|e| e.into_inner()).clone() +} + +fn shown(p: &Path) -> String { + p.display().to_string() +} + +/// A file a dry run would write whose bytes it does not make (a record signed only after a +/// run): named in the plan, in a dry run alone. +#[cfg_attr(not(feature = "quickstart"), allow(dead_code))] +pub fn would_write(path: impl AsRef) { + let p = path.as_ref(); + if dry_run() { + note(json!({"write": shown(p), "replaces": p.exists()})); + } +} + +/// A request a dry run would send to the service at `target` (and does not): named in the +/// plan, in a dry run alone. +pub fn would_send(target: &str, method: &str, path: &str) { + if dry_run() { + note(json!({"request": format!("{method} {path}"), "target": target})); + } +} + +/// `std::fs::write`. +pub fn write(path: impl AsRef, bytes: impl AsRef<[u8]>) -> io::Result<()> { + let (p, b) = (path.as_ref(), bytes.as_ref()); + if dry_run() { + note(json!({"write": shown(p), "bytes": b.len(), "replaces": p.exists()})); + return Ok(()); + } + std::fs::write(p, b) +} + +/// A new file that must not exist yet (`create_new`), with its bytes. +pub fn write_new(path: impl AsRef, bytes: impl AsRef<[u8]>) -> io::Result<()> { + let (p, b) = (path.as_ref(), bytes.as_ref()); + if dry_run() { + if p.exists() { + return Err(io::Error::new(io::ErrorKind::AlreadyExists, "exists")); + } + note(json!({"write": shown(p), "bytes": b.len(), "replaces": false})); + return Ok(()); + } + let mut f = std::fs::OpenOptions::new().write(true).create_new(true).open(p)?; + f.write_all(b) +} + +/// `std::fs::create_dir_all`. +pub fn create_dir_all(path: impl AsRef) -> io::Result<()> { + let p = path.as_ref(); + if dry_run() { + if !p.is_dir() { + note(json!({"create-dir": shown(p)})); + } + return Ok(()); + } + std::fs::create_dir_all(p) +} + +/// `std::fs::remove_file`. +#[cfg_attr(not(feature = "quickstart"), allow(dead_code))] +pub fn remove_file(path: impl AsRef) -> io::Result<()> { + let p = path.as_ref(); + if dry_run() { + note(json!({"remove": shown(p)})); + return Ok(()); + } + std::fs::remove_file(p) +} + +/// `std::fs::File::create`, as a writer: one that discards in a dry run. +#[cfg_attr(not(feature = "codecs"), allow(dead_code))] +pub fn create(path: impl AsRef) -> io::Result> { + let p = path.as_ref(); + if dry_run() { + note(json!({"write": shown(p), "replaces": p.exists()})); + return Ok(Box::new(io::sink())); + } + Ok(Box::new(std::fs::File::create(p)?)) +} + +/// The plan as lines, for a person. +pub fn plan_lines() -> Vec { + plan() + .iter() + .map(|v| { + if let Some(p) = v["write"].as_str() { + let n = v["bytes"].as_u64().map(|n| format!(" ({n} bytes)")).unwrap_or_default(); + let r = if v["replaces"] == true { ", replacing the file there" } else { "" }; + format!("write {p}{n}{r}") + } else if let Some(p) = v["create-dir"].as_str() { + format!("make the directory {p}") + } else if let Some(p) = v["remove"].as_str() { + format!("remove {p}") + } else if let (Some(r), Some(t)) = (v["request"].as_str(), v["target"].as_str()) { + format!("send {r} to {t}") + } else { + v.to_string() + } + }) + .collect() +} diff --git a/wai/wai-rs/src/bin/wai_cli/score.rs b/wai/wai-rs/src/bin/wai_cli/score.rs index 9f2d4237e..5331e451c 100644 --- a/wai/wai-rs/src/bin/wai_cli/score.rs +++ b/wai/wai-rs/src/bin/wai_cli/score.rs @@ -204,7 +204,7 @@ fn build(args: &[String]) -> Result<(), String> { let bytes = std::fs::read(input).map_err(|e| format!("read {input}: {e}"))?; let s = from_notes_json(&bytes).map_err(|e| format!("{input}: refused: {e}"))?; let wscr = s.to_bytes(); - std::fs::write(out, &wscr).map_err(|e| format!("write {out}: {e}"))?; + crate::out::write(out, &wscr).map_err(|e| format!("write {out}: {e}"))?; let score_hash = hex(&s.score_hash()); if args.iter().any(|a| a == "--json") { println!("{}", serde_json::json!({ @@ -224,7 +224,7 @@ fn render(args: &[String]) -> Result<(), String> { let pcm_sha256 = wai::pin::sha256_hex(&pcm); let (mixdown, score_hash) = (hex(&s.mixdown_hash()), hex(&s.score_hash())); if let Some(p) = opt(args, "--pcm") { - std::fs::write(p, &pcm).map_err(|e| format!("write {p}: {e}"))?; + crate::out::write(p, &pcm).map_err(|e| format!("write {p}: {e}"))?; } if args.iter().any(|a| a == "--json") { println!("{}", serde_json::json!({ diff --git a/wai/wai-rs/src/bin/wai_cli/setup_agent.rs b/wai/wai-rs/src/bin/wai_cli/setup_agent.rs index 1a4d346ba..e23b308d0 100644 --- a/wai/wai-rs/src/bin/wai_cli/setup_agent.rs +++ b/wai/wai-rs/src/bin/wai_cli/setup_agent.rs @@ -5,16 +5,16 @@ //! documented (`TARGETS`). //! //! ```text -//! wai setup-agent [--dir ] [--dry-run] [--confirm] [--json] [--all] [--only [,...]] +//! wai setup-agent [--dir ] [--dry-run] [--yes] [--json] [--all] [--only [,...]] //! [--toolset [,...]] [--command ] //! ``` //! //! - It writes nothing outside the project directory: every path is checked against the //! directory's canonical path, and a link among the files it would write is refused. //! - A file that does not exist is created. A file that exists is changed only after its diff is -//! shown and the change is accepted: asked for on a terminal, or given with `--confirm`. -//! Without a terminal and without `--confirm`, nothing that exists is changed and the command -//! exits 4, with every proposed change and the command that confirms them. +//! shown and the change is accepted: asked for on a terminal, or given with `--yes` (also +//! `--confirm`). Without a terminal and without `--yes`, nothing that exists is changed and the +//! command exits 4, with every proposed change and the command that confirms them. //! - `--dry-run` shows what would change and writes nothing. //! - In Markdown it owns one block between two markers, which it adds or replaces; the rest of //! the file is kept. In JSON it sets one member, `wai`, under the file's server list; the @@ -22,20 +22,20 @@ //! left alone, and the entry to add by hand is printed. //! - `--toolset` names the toolsets the registered server offers (`wai mcp --toolset ...`); //! without it the server offers its default, `core`. -//! - Output is text on a terminal, and otherwise (or with `--json`) the toolchain's JSON -//! envelope (`{"wai": "envelope/1", "op": "agent.setup", "ok", ...}`). +//! - With `--json`, the toolchain's JSON envelope (`{"wai": "envelope/1", "op": +//! "agent.setup", "ok", "data" | "error", ...}`); text otherwise. +//! - `wai` reads `--dry-run`, `--yes` and `--json` wherever they are on the command line (the +//! agent rules every command keeps, `agent.rs`), and hands them on here. //! //! Exit status, the toolchain's table: 0 done (or a dry run); 2 a usage error; 3 a file it may //! not read or write; 4 changes to existing files wait for confirmation; 5 the project //! directory is not there; 7 a file could not be read or written otherwise. +use crate::dx::envelope::OpError; use serde_json::{json, Map, Value}; use std::io::{BufRead, IsTerminal, Write}; use std::path::{Path, PathBuf}; -/// The command's usage, for `wai`'s own. -pub const USAGE: &str = "wai setup-agent [--dir D] [--dry-run] [--confirm] [--json] [--all] [--only FILE,...] [--toolset core,dev,app]\n write AGENTS.md (and an import of it in a CLAUDE.md already there) and register `wai mcp`\n in the project's agent configuration"; - /// The operation, as the catalogue names it. const OP: &str = "agent.setup"; @@ -104,7 +104,7 @@ struct Plan { } fn usage() -> &'static str { - "usage: wai setup-agent [--dir ] [--dry-run] [--confirm] [--json] [--all] [--only [,...]] [--toolset [,...]] [--command ]" + "usage: wai setup-agent [--dir ] [--dry-run] [--yes] [--json] [--all] [--only [,...]] [--toolset [,...]] [--command ]" } /// The toolsets this build's `wai mcp` serves, for `--toolset`. @@ -403,18 +403,35 @@ fn write(project: &Path, path: &str, text: &str) -> Result<(), Fail> { /// The toolchain's JSON envelope for this command (`envelope/1`). fn envelope(ok: bool, fail: Option<&Fail>) -> Map { let mut v = Map::new(); - v.insert("wai".into(), json!("envelope/1")); + v.insert("wai".into(), json!(wai::catalogue::ENVELOPE)); v.insert("op".into(), json!(OP)); v.insert("ok".into(), json!(ok)); if let Some(f) = fail { v.insert("error".into(), json!({"code": f.code, "message": f.message, "hint": f.hint, "retryable": false})); + v.insert("exit".into(), json!(f.exit)); } v } +/// `wai setup-agent` (`agent.setup`), through the catalogue's door: the agent flags `wai` read +/// are handed on, and the command reports its own outcome. +pub fn main(args: &[String]) -> Result { + let c = crate::dx::envelope::ctx(); + let mut a = args.to_vec(); + for (on, flag) in [(c.dry_run, "--dry-run"), (c.yes, "--confirm"), (c.json, "--json")] { + if on { + a.push(flag.into()); + } + } + match run(&a) { + 0 => Ok(true), + code => Err(OpError::reported(code)), + } +} + /// `wai setup-agent`: its exit status, its output written. -pub fn main(args: &[String]) -> u8 { - let json_out = args.iter().any(|a| a == "--json") || !std::io::stdout().is_terminal(); +fn run(args: &[String]) -> u8 { + let json_out = args.iter().any(|a| a == "--json"); let fail = |f: Fail| { if json_out { println!("{}", Value::Object(envelope(false, Some(&f)))); @@ -486,7 +503,7 @@ pub fn main(args: &[String]) -> u8 { f.insert("action".into(), json!(action)); files.push(Value::Object(f)); } - let confirm = format!("wai setup-agent --dir {} --confirm{}{}{}", shell_word(&project.display().to_string()), + let confirm = format!("wai setup-agent --dir {} --yes{}{}{}", shell_word(&project.display().to_string()), o.only.as_ref().map(|x| format!(" --only {}", x.join(","))).unwrap_or_default(), o.toolsets.as_ref().map(|x| format!(" --toolset {}", x.join(","))).unwrap_or_default(), if o.all { " --all" } else { "" }); @@ -495,13 +512,20 @@ pub fn main(args: &[String]) -> u8 { exit: CONFIRMATION_REQUIRED, code: "confirmation-required", message: format!("{} change{} to existing files", proposed.len(), if proposed.len() == 1 { "" } else { "s" }), - hint: "read each diff, then re-run with --confirm", + hint: "read each diff, then re-run with --yes", }; let stop = failed.as_ref().or(if waiting { Some(&pending) } else { None }); let code = stop.map_or(0, |f| f.exit); if json_out { let mut v = envelope(code == 0, stop); - v.insert("result".into(), json!({"project": project.display().to_string(), "dry_run": o.dry_run, "files": files})); + v.insert("data".into(), json!({"project": project.display().to_string(), "dry_run": o.dry_run, "files": files})); + if o.dry_run { + // The envelope's dry-run members, as every command gives them. + let plan: Vec = files.iter().filter(|f| f["action"] == "would_create" || f["action"] == "would_update") + .map(|f| json!({"write": f["path"], "replaces": f["action"] == "would_update"})).collect(); + v.insert("dry_run".into(), json!(true)); + v.insert("plan".into(), json!(plan)); + } if waiting { v.insert("proposed".into(), json!(proposed)); v.insert("confirm".into(), json!(confirm)); @@ -521,6 +545,9 @@ pub fn main(args: &[String]) -> u8 { if waiting { println!("existing files would change and nothing confirmed it; to apply: {confirm}"); } + if o.dry_run { + println!("dry run: nothing was written"); + } if let Some(f) = &failed { eprintln!("error: {}\nhint: {}", f.message, f.hint); } diff --git a/wai/wai-rs/src/bin/wai_cli/stage.rs b/wai/wai-rs/src/bin/wai_cli/stage.rs index 46bb4ea23..787ea289f 100644 --- a/wai/wai-rs/src/bin/wai_cli/stage.rs +++ b/wai/wai-rs/src/bin/wai_cli/stage.rs @@ -464,7 +464,7 @@ fn base_cmd(args: &[String]) -> Result<(), String> { if units.len() != recon.len() || units.iter().zip(&recon).any(|(u, r)| u.samples != Samples::U8(r.clone())) { return Err("the base does not decode to the encoder's reconstruction".into()); } - std::fs::write(out, &bytes).map_err(|e| format!("write {out}: {e}"))?; + crate::out::write(out, &bytes).map_err(|e| format!("write {out}: {e}"))?; let err = frames.iter().flatten().zip(recon.iter().flatten()).map(|(a, b)| a.abs_diff(*b)).max().unwrap_or(0); println!("base → {out} ({} B) {cap} {n} frames of {h}x{w} q {q} tier {} largest error {err}", bytes.len(), tier::token(info.tier)); println!(" content hash {}", hex(&info.content_hash)); @@ -572,7 +572,7 @@ fn refine(args: &[String]) -> Result<(), String> { let env = Wai::new(m, payload); let bytes = env.to_emit_bytes().map_err(|e| e.to_string())?; env.check_component_roles().map_err(|e| e.to_string())?; - std::fs::write(out, &bytes).map_err(|e| format!("write {out}: {e}"))?; + crate::out::write(out, &bytes).map_err(|e| format!("write {out}: {e}"))?; let err: u64 = recon.iter().zip(&targets).map(|(r, t)| match (r, t) { (Next::U8(a), Samples::U8(b)) => a.iter().zip(b).map(|(x, y)| (*x as i64 - *y as i64).unsigned_abs()).max().unwrap_or(0), (Next::I16(a), Samples::I16(b)) => a.iter().zip(b).map(|(x, y)| (*x as i64 - *y as i64).unsigned_abs()).max().unwrap_or(0), @@ -637,7 +637,7 @@ fn pack(args: &[String]) -> Result<(), String> { let bytes = WaiMulti::pack(&refs).map_err(|e| e.to_string())?; let w = WaiMulti::from_bytes(&bytes).map_err(|e| e.to_string())?; w.check_component_roles().map_err(|e| e.to_string())?; - std::fs::write(out, &bytes).map_err(|e| format!("write {out}: {e}"))?; + crate::out::write(out, &bytes).map_err(|e| format!("write {out}: {e}"))?; println!("packed {} envelopes → {out} ({} B)", files.len(), bytes.len()); Ok(()) } @@ -659,7 +659,7 @@ fn explode(args: &[String]) -> Result<(), String> { let bytes = WaiMulti::explode(&envelope, i).map_err(|e| e.to_string())?; println!("component {i}: {} B content hash {}", bytes.len(), hex(&wai::jwp_hash::content_hash(&bytes))); if let Some(out) = opt(args, "-o") { - std::fs::write(out, &bytes).map_err(|e| format!("write {out}: {e}"))?; + crate::out::write(out, &bytes).map_err(|e| format!("write {out}: {e}"))?; } Ok(()) } diff --git a/wai/wai-rs/src/bin/wai_http_vectors.rs b/wai/wai-rs/src/bin/wai_http_vectors.rs index 6c6c39928..727aadf25 100644 --- a/wai/wai-rs/src/bin/wai_http_vectors.rs +++ b/wai/wai-rs/src/bin/wai_http_vectors.rs @@ -14,6 +14,12 @@ use std::process::ExitCode; #[path = "wai_cli/http_vectors.rs"] mod http_vectors; +// The writes `http_vectors` makes go through `out`, as they do in `wai http vectors`, which +// takes `--dry-run`; this tool never sets it. +#[allow(dead_code)] +#[path = "wai_cli/out.rs"] +mod out; + fn main() -> ExitCode { let args: Vec = std::env::args().skip(1).collect(); match http_vectors::main(&args) { diff --git a/wai/wai-rs/src/catalogue.rs b/wai/wai-rs/src/catalogue.rs new file mode 100644 index 000000000..8e78adca8 --- /dev/null +++ b/wai/wai-rs/src/catalogue.rs @@ -0,0 +1,2178 @@ +//! The operation catalogue: every operation the WAI toolchain offers, once, as data. +//! +//! An operation is named `noun.verb` (`envelope.inspect`, `project.create`). Every door +//! names it by a mechanical transform of that id ([`Op::names`]): +//! +//! | Door | Form | +//! |---|---| +//! | CLI | `wai envelope inspect`, or the spelling the CLI has today (`wai inspect`) | +//! | HTTP API | `operationId` = the id, at the operation's [`Route`] | +//! | Rust | `wai::envelope::inspect` | +//! | C | `wai_envelope_inspect` | +//! | JavaScript, Swift, Kotlin | `wai.envelope.inspect` (an object per noun, a method per verb) | +//! | MCP | `envelope_inspect` | +//! +//! The table lists only what works today: an operation joins it when it is built. One that +//! needs a build feature names it ([`Op::needs`]); [`Op::available`] says whether this build +//! holds it. `wai ops export` writes the table as JSON (`wai/catalogue/ops.json`), which +//! the doors' checks read, and `wai ops search` ranks it against a task, offline. +//! +//! The rules every entry keeps, held by this module's tests: +//! - the id is `noun.verb`, lowercase ASCII letters and digits, the noun singular, and the +//! verb one of the closed list of naming rule N3 ([`VERBS`]), never a near-synonym of +//! another (`get`, `describe` and `info` are `show`; `keygen`, `init` and `new` are +//! `create`; `up` is `dev`) — [`NEAR_SYNONYMS`]. A command a person already types keeps its +//! spelling as the operation's CLI form (`wai hello` is `toolchain.test`); +//! - no two operations answer to the same command line; +//! - an operation has an HTTP route exactly when it runs on a service (`service` or `both`); +//! - an operation whose effect is not `reads` takes `--dry-run`, and one that would change +//! a file the command line did not name, or is `destructive`, or acts on a target that is +//! not local, waits for `--yes` ([`Op::confirms`] says when; the `wai` binary's tests hold +//! its doors to these). `--confirm` is a deprecated spelling of `--yes` ([`AGENT_FLAGS`]). + +use serde_json::{Map, Value, json}; + +/// The export's format identifier. +pub const FORMAT: &str = "wai.ops/1"; + +/// The version of the JSON envelope `wai --json` prints. +pub const ENVELOPE: &str = "envelope/1"; + +/// The verbs an operation's id ends with (WAI toolchain v1, naming rule N3): a closed list. +pub const VERBS: &[&str] = &[ + "create", "list", "show", "update", "delete", "check", "verify", "run", "build", "export", "import", "start", "stop", + "join", "leave", "grant", "invite", "revoke", "rotate", "tail", "search", "test", "load", "encode", "decode", "inspect", + "wrap", "unwrap", "fetch", "generate", "promote", "rollback", "lock", "upgrade", "setup", "guide", "stream", +]; + +/// Verbs that are another verb's near-synonym, with the verb to use: an id never ends in one. +pub const NEAR_SYNONYMS: &[(&str, &str)] = &[ + ("get", "show"), + ("describe", "show"), + ("info", "show"), + ("keygen", "create"), + ("init", "create"), + ("new", "create"), + ("up", "start (wai dev)"), +]; + +/// Where an operation runs. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Placement { + /// On this machine, touching nothing else. + Local, + /// On this machine, against a service it names (a URL, a room). + Client, + /// On a service, reached through its HTTP route. + Service, + /// Either: on this machine or through the service's route. + Both, +} + +/// What an operation does to the world, which decides `--dry-run` and confirmation. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Effect { + /// Reads only. + Reads, + /// Runs something (a local server, a development stack) and writes only its own state. + Runs, + /// Writes files on this machine. + WritesProject, + /// Changes a target that is not this machine. + WritesTarget, + /// Removes or replaces what cannot be recovered. + Destructive, +} + +/// When an operation of the command line waits for confirmation (`--yes`, or yes at a +/// terminal; off a terminal without it, exit 4 and nothing changed). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Confirm { + /// Never. + Never, + /// When it would replace a file the command line did not name (`out.wai` without `-o`). + UnnamedFile, + /// When it would replace a file that exists, named or not. + AnyFile, + /// When it would change the target it names, or replace a file that exists. + TargetOrFile, + /// Every time it changes something. + Always, +} + +/// The MCP toolset an operation belongs to. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Toolset { + Core, + Dev, + App, + Quantum, + Maintainer, +} + +/// How far an operation's name, flags and output are promised. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Stability { + /// Changed only with a deprecation window. + Stable, + /// Published and working; its shape may still change. + Preview, + /// Never published. + Internal, +} + +/// The JSON type of an input. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Ty { + String, + /// A path on this machine. + Path, + Integer, + Boolean, + /// Repeated: a list of strings. + Strings, + /// Any JSON value. + Json, +} + +/// How an input is given on the command line. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Cli { + /// In order, after the command's words. + Positional, + /// `--flag `. + Flag(&'static str), + /// `--switch`, a boolean. + Switch(&'static str), + /// A member of a JSON request alone (an MCP tool's, an HTTP body's): no command line. + Member, +} + +/// One input of an operation. +#[derive(Clone, Copy, Debug)] +pub struct Arg { + pub name: &'static str, + pub ty: Ty, + pub required: bool, + pub cli: Cli, + pub doc: &'static str, +} + +/// An HTTP route: the service that serves it, its method and its path. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Route { + /// The service's binary (`wai-room`). + pub service: &'static str, + pub method: &'static str, + pub path: &'static str, +} + +/// Every service with an HTTP API, and the OpenAPI document (from the repository's root) that +/// describes it, each `operationId` an operation of this catalogue at its route. +pub const API_DOCUMENTS: &[(&str, &str)] = &[("wai-room", "joule-code/joule-code-rs/crates/joule-moq-room/openapi.json")]; + +/// A route of the room service. +const fn room(method: &'static str, path: &'static str) -> Route { + Route { service: "wai-room", method, path } +} + +/// The build features an operation needs. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Needs { + /// Every build holds it. + Nothing, + /// A build with all of these. + All(&'static [&'static str]), + /// A build with any one of these. + Any(&'static [&'static str]), +} + +/// What an operation's `--json` envelope carries in `data`. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Output { + /// The text it prints, as `{"text": ...}`. + Text, + /// A JSON value of this schema (JSON Schema text). + Json(&'static str), +} + +/// A renamed operation, kept answering under its old name. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Deprecated { + pub since: &'static str, + pub use_instead: &'static str, +} + +/// One operation. +#[derive(Clone, Copy, Debug)] +pub struct Op { + /// `noun.verb`. + pub id: &'static str, + /// One line, for help, docs, MCP descriptions and the search. + pub summary: &'static str, + /// The command line's words today (after `wai`), when the CLI carries it. Every operation + /// also answers to its mechanical form, `wai `. + pub cli: Option<&'static str>, + /// Further CLI spellings, declared here and nowhere else. + pub aliases: &'static [&'static str], + /// The synopsis: one or more lines, each after `wai `. + pub usage: &'static str, + pub placement: Placement, + pub effect: Effect, + pub toolset: Toolset, + pub stability: Stability, + pub input: &'static [Arg], + pub output: Output, + /// For a streaming operation: the JSON Schema of each JSON Lines line. + pub stream: Option<&'static str>, + /// For an operation that runs on a service: its HTTP route. + pub route: Option, + /// The toolchain version that first carries it in this catalogue. + pub since: &'static str, + pub deprecated: Option, + /// Whether it writes a signed receipt or record that a `verify` operation checks. + pub receipt: bool, + pub needs: Needs, + /// Whether `wai mcp` serves it, as the tool `noun_verb` in its [`Op::toolset`]. + pub mcp: bool, + /// When it waits for confirmation, where its effect alone does not say ([`Op::confirms`]). + pub confirm: Option, +} + +const fn pos(name: &'static str, ty: Ty, required: bool, doc: &'static str) -> Arg { + Arg { name, ty, required, cli: Cli::Positional, doc } +} +const fn flag(name: &'static str, f: &'static str, ty: Ty, required: bool, doc: &'static str) -> Arg { + Arg { name, ty, required, cli: Cli::Flag(f), doc } +} +const fn switch(name: &'static str, f: &'static str, doc: &'static str) -> Arg { + Arg { name, ty: Ty::Boolean, required: false, cli: Cli::Switch(f), doc } +} +const fn member(name: &'static str, ty: Ty, required: bool, doc: &'static str) -> Arg { + Arg { name, ty, required, cli: Cli::Member, doc } +} + +/// The toolchain version the catalogue's first entries carry. +const V0: &str = "0.3.0"; + +const STAGE: Needs = Needs::All(&["staged", "video_int", "audio_int", "splat_int"]); +const HTTP: Needs = Needs::All(&["http"]); + +/// The default entry every operation below starts from. +const OP: Op = Op { + id: "", + summary: "", + cli: None, + aliases: &[], + usage: "", + placement: Placement::Local, + effect: Effect::Reads, + toolset: Toolset::Core, + stability: Stability::Stable, + input: &[], + output: Output::Text, + stream: None, + route: None, + since: V0, + deprecated: None, + receipt: false, + needs: Needs::Nothing, + mcp: false, + confirm: None, +}; + +const S_OPS_LIST: &str = r#"{"type":"array","items":{"type":"object","required":["id","summary","cli","effect","placement","toolset","stability","available"]}}"#; +const S_OPS_SEARCH: &str = r#"{"type":"array","maxItems":5,"items":{"type":"object","required":["id","summary","cli","score"],"properties":{"id":{"type":"string"},"summary":{"type":"string"},"cli":{"type":["string","null"]},"score":{"type":"integer"}}}}"#; +const S_OPS_EXPORT: &str = r#"{"type":"object","required":["format","toolchain","envelope","operations"],"properties":{"format":{"const":"wai.ops/1"}}}"#; +const S_OPS_SCHEMA: &str = r#"{"type":"object","required":["id","summary","input","output","effect","placement"]}"#; +const S_TEMPLATES: &str = r#"{"type":"array","items":{"type":"object","required":["template","title","files"]}}"#; +const S_DEV_CHECK: &str = r#"{"type":"object","required":["ready","seconds","steps","checks","origin"]}"#; +const S_DEV_READY: &str = r#"{"type":"object","required":["ready"],"properties":{"ready":{"type":"boolean"},"origin":{"type":"string"}}}"#; +const S_GENERATE: &str = r#"{"type":"object","description":"The picture's or instruction object's digests, as `wai generate --json` prints them"}"#; +const S_SCORE: &str = r#"{"type":"object","description":"The score's digests, as `wai score build|render --json` print them"}"#; +const S_BREAKING: &str = r#"{"type":"object","required":["against","findings"],"description":"Each finding's id, category, what changed and whether the CHANGELOG acknowledges it"}"#; +const S_CONFORM: &str = r#"{"type":"object","required":["conformant","cases","passed","failed"]}"#; +const S_CONFORM_LIST: &str = r#"{"type":"object","required":["suites"]}"#; +const S_ANY: &str = r#"{"description": "The tool's structured result"}"#; +const S_JSONRPC: &str = r#"{"description": "One JSON-RPC 2.0 message per line, the Model Context Protocol"}"#; +const S_SETUP: &str = r#"{"type":"object","required":["project","dry_run","files"]}"#; +const S_APP_CHECK: &str = r#"{"type":"object","required":["program","errors","failures","checked"]}"#; +const S_ENROLMENT: &str = r#"{"type":"object","required":["node","role","organisation","digest","enrolment"],"description":"The enrolment (WFE1, hexadecimal), its digest, and the file and service it went to"}"#; +const S_ENROLMENTS: &str = r#"{"type":"object","required":["enrolments"],"description":"Each node the fleet service holds enrolled: its key, role, label and records"}"#; +const S_REPORT: &str = r#"{"type":"object","required":["report","outcome"],"description":"The report's digest and its outcome as extensions/fleet.md §7.3 states it"}"#; +const S_REPORT_EXPORT: &str = r#"{"type":"object","required":["file","outcome"],"description":"The bundle's file and its outcome, recomputed here"}"#; +const S_STAGE_APPLY: &str = r#"{"description":"The trace of every stage reached, as `wai stage apply --json` prints it"}"#; + +/// Every operation, in the order help lists them: the developer's path first. +pub const OPS: &[Op] = &[ + // ─── the developer's path ─── + Op { + id: "toolchain.test", + summary: "WAI's four steps (name, dispatch, rebuild, receipt) on a picture made here, its receipt checked offline", + cli: Some("hello"), + usage: "hello [] WAI's four steps (name, dispatch, rebuild, receipt) on a sample, written to (wai-hello)", + effect: Effect::WritesProject, + input: &[pos("dir", Ty::Path, false, "Where the object and its receipts are written (wai-hello).")], + receipt: true, + needs: Needs::All(&["quickstart"]), + ..OP + }, + Op { + id: "project.create", + summary: "A new project from one of the examples, written into a new or empty directory; --list names the templates", + cli: Some("create"), + usage: "create --template | create --list a new project from one of the examples", + effect: Effect::WritesProject, + input: &[ + pos("dir", Ty::Path, false, "A directory that does not exist or is empty."), + flag("template", "--template", Ty::String, false, "The template, one of `wai create --list`."), + switch("list", "--list", "List the templates instead."), + ], + output: Output::Json(S_TEMPLATES), + ..OP + }, + Op { + id: "dev.start", + summary: "A relay, a room service and the room page on one origin on this machine, for development; --check starts, checks every piece and stops", + cli: Some("dev"), + usage: "dev [--port P] [--dir D] [--site DIR] [--watch FILE [--watch-ms MS]] [--call] [--remote] + [--show [--audience N]] [--messaging] [--e2ee] [--publishers N] [--check] [--for S] [--web DIR] + [--relay BIN] [--room BIN] + --port P the origin's port on 127.0.0.1 (8080; 0 picks a free one) + --dir D keys, configuration and logs (.wai/dev) + --site DIR your page: DIR is served at /, the room client beside it at /room/ + --publishers N places in the room (4) + --watch FILE a watch room: everyone in it watches FILE together (.wav, .webm, .mp4, + .ogg, .mp3); --watch-ms MS its duration, read from a WAV file's header + --call the room is a call: /room/call.html places one, rings, answers + --remote the room is one host's shared screen; prints the host's invitation + --show the room is a show: hosts on camera, an audience that votes in polls + the room's clock opens and signs (co-performance); --audience N each + viewer's inputs to a poll (1) + --messaging a conversation beside the room, at /msg-api/: /room/messages.html + --e2ee every room end-to-end encrypted + --check start, check every piece through the origin, report, stop + --for S stop after S seconds + --web DIR the room client's files (beside this binary: ../share/wai/web; $WAI_WEB) + --relay BIN wai-relay (beside this binary, then $PATH; $WAI_RELAY_BIN) + --room BIN wai-room (beside this binary, then $PATH; $WAI_ROOM_BIN) + WAI Studio is served at /_wai/ when its files are found ($WAI_STUDIO, or share/wai/studio + beside this binary). + A wai.json in the current directory (`wai create` writes one) gives the defaults; a flag + wins over it. Ctrl-C stops everything. With --json and without --check, the first line + is {\"ready\": true, ...}.", + effect: Effect::Runs, + toolset: Toolset::Dev, + input: &[ + flag("port", "--port", Ty::Integer, false, "The origin's port on 127.0.0.1 (8080; 0 picks a free one)."), + flag("dir", "--dir", Ty::Path, false, "Keys, configuration and logs (.wai/dev)."), + flag("site", "--site", Ty::Path, false, "A directory served at /."), + flag("watch", "--watch", Ty::Path, false, "A file everyone in a watch room watches together."), + flag("watch_ms", "--watch-ms", Ty::Integer, false, "The watch file's duration in milliseconds."), + switch("call", "--call", "The room is a call."), + switch("remote", "--remote", "The room is one host's shared screen."), + switch("show", "--show", "The room is a show: hosts on camera, an audience that votes in polls."), + flag("audience", "--audience", Ty::Integer, false, "With --show, each viewer's inputs to a poll (1)."), + switch("messaging", "--messaging", "A conversation beside the room."), + switch("e2ee", "--e2ee", "Every room end-to-end encrypted."), + flag("publishers", "--publishers", Ty::Integer, false, "Places in the room (4)."), + switch("check", "--check", "Start, check every piece, report and stop."), + flag("for_s", "--for", Ty::Integer, false, "Stop after this many seconds."), + flag("web", "--web", Ty::Path, false, "The room client's files."), + flag("relay", "--relay", Ty::Path, false, "The relay's binary."), + flag("room", "--room", Ty::Path, false, "The room service's binary."), + ], + output: Output::Json(S_DEV_CHECK), + stream: Some(S_DEV_READY), + mcp: true, + ..OP + }, + Op { + id: "receipt.verify", + summary: "Every receipt index (*.wai.receipts) under each path, its group receipts and claims checked offline, and every conformance result and fleet report bundle there", + cli: Some("verify"), + usage: "verify ... the receipts (receipt indexes *.wai.receipts), conformance results (*.wai-conform.json) + and fleet report bundles (*.wai-fleet.json) under each path, checked offline", + input: &[pos("paths", Ty::Strings, true, "Files or directories holding receipt indexes, conformance results or fleet report bundles.")], + needs: Needs::All(&["quickstart"]), + mcp: true, + ..OP + }, + // ─── envelopes and media ─── + Op { + id: "envelope.inspect", + summary: "What an envelope holds and whether this build decodes it: media, intent, pin, capability, fallback, the dispatch verdict and the conformance checks", + cli: Some("inspect"), + usage: "inspect what a WAI1 or WAI2 envelope holds, and the verdict of SPEC §4's dispatch here", + input: &[pos("path", Ty::Path, true, "A .wai envelope.")], + mcp: true, + ..OP + }, + Op { + id: "envelope.wrap", + summary: "Carry a registered capability's bytes in an envelope, byte for byte, under SPEC's intent, role and candidate rules", + cli: Some("wrap"), + usage: "wrap [-o out.wai] [--fallback ] [--intent ] + [--companion ]... WAI2: companions bound to the payload", + effect: Effect::WritesProject, + input: &[ + pos("capability", Ty::String, true, "A registered capability (`wai capabilities`)."), + pos("payload", Ty::Path, true, "The bytes to carry."), + flag("out", "-o", Ty::Path, false, "The envelope (out.wai)."), + flag("fallback", "--fallback", Ty::String, false, "A WAI1 fallback capability."), + flag("intent", "--intent", Ty::String, false, "The envelope's intent (replicate where SPEC §7 allows it)."), + flag("companion", "--companion", Ty::Strings, false, "A companion capability and its payload, bound to the payload."), + ], + mcp: true, + ..OP + }, + Op { + id: "envelope.unwrap", + summary: "An envelope's capability and its checks, and with -o its payload or one component, exactly as wrapped", + cli: Some("unwrap"), + usage: "unwrap [-o payload] [--component ] extract any registered capability's bytes", + effect: Effect::WritesProject, + input: &[ + pos("path", Ty::Path, true, "A .wai envelope."), + flag("out", "-o", Ty::Path, false, "Where the payload is written."), + flag("component", "--component", Ty::Integer, false, "A WAI2 component's index."), + ], + ..OP + }, + Op { + id: "media.encode", + summary: "Encode a file with a codec this build holds, in an envelope; AVC and HEVC through the device's own encoders, with a signed record", + cli: Some("encode"), + usage: "encode [-o out.wai] [-q quality] codecs: png jpeg avif jxl jxl-lossless opus flac av1 av1-lossless zstd xz + a build with `floor` alone encodes png and zstd; the others need `codecs` + image codecs: .rgb (with .meta holding 'H W') or a PNG/JPEG file; audio: .f32 (raw mono float32) + with .meta holding the sample rate; video: packed RGB frames with .meta 'N H W fps_num fps_den'; + text codecs: any file +encode avc|hevc [-o out.wai] [--encoder auto|platform|va|gpu-driver|software] [--library ] + [--device ] [--hardware yes|no] [--kbps ] [--size WxH] [--idr-every | + --idr-plan ] [--es ] [--record --key ] [--meter [--rounds ]] + (a build with `platform_encode`)", + effect: Effect::WritesProject, + input: &[ + pos("codec", Ty::String, true, "png, jpeg, avif, jxl, jxl-lossless, opus, flac, av1, av1-lossless, zstd, xz; or avc, hevc."), + pos("input", Ty::Path, true, "The file to encode."), + flag("out", "-o", Ty::Path, false, "The envelope (out.wai)."), + flag("quality", "-q", Ty::Integer, false, "Quality, 1 to 100 (75)."), + ], + receipt: true, + needs: Needs::Any(&["floor", "codecs", "platform_encode"]), + ..OP + }, + Op { + id: "media.decode", + summary: "Decode an envelope through SPEC §4's dispatch to raw samples, with a .meta file beside them", + cli: Some("decode"), + usage: "decode -o a build with `floor` decodes png, flac and zstd; `codecs` the rest", + effect: Effect::WritesProject, + input: &[pos("path", Ty::Path, true, "A .wai envelope."), flag("out", "-o", Ty::Path, true, "The decoded samples.")], + needs: Needs::Any(&["floor", "codecs"]), + ..OP + }, + Op { + id: "media.generate", + summary: "Make a wai.generate.spectral picture natively and print its SHA-256, or write its instruction object", + cli: Some("generate"), + usage: "generate --model (--prompt --seed --steps | --instr ) --size WxH -o + make a wai.generate.spectral picture (RGB8) and print its SHA-256; --sizes WxH,... --write-instr + writes the instruction object of form v0 (SPEC §5)", + effect: Effect::WritesProject, + input: &[ + flag("model", "--model", Ty::Path, true, "The model file."), + flag("prompt", "--prompt", Ty::String, false, "The prompt."), + flag("seed", "--seed", Ty::Integer, false, "The seed."), + flag("steps", "--steps", Ty::Integer, false, "The steps."), + flag("instr", "--instr", Ty::Path, false, "An instruction object of form v0."), + flag("size", "--size", Ty::String, false, "WxH."), + flag("out", "-o", Ty::Path, false, "The picture, RGB8."), + ], + output: Output::Json(S_GENERATE), + needs: Needs::All(&["generate"]), + ..OP + }, + Op { + id: "score.build", + summary: "A WSCR score from integer note fields", + cli: Some("score build"), + usage: "score build -o a WSCR from integer note fields", + effect: Effect::WritesProject, + input: &[pos("notes", Ty::Path, true, "The note fields, JSON."), flag("out", "-o", Ty::Path, true, "The score.")], + output: Output::Json(S_SCORE), + needs: Needs::All(&["score"]), + ..OP + }, + Op { + id: "score.decode", + summary: "A score's reference mixdown and its digests (pcm_sha256, mixdown_blake3, score_hash)", + cli: Some("score render"), + usage: "score render [--pcm ] the reference mixdown and its digests", + effect: Effect::WritesProject, + input: &[pos("score", Ty::Path, true, "A WSCR score."), flag("pcm", "--pcm", Ty::Path, false, "Where the mixdown is written.")], + output: Output::Json(S_SCORE), + needs: Needs::All(&["score"]), + ..OP + }, + Op { + id: "encoder.list", + summary: "What each encoder interface offers on this device, for AVC and HEVC", + cli: Some("encoders"), + usage: "encoders [--library ] what each encoder interface offers here", + stability: Stability::Preview, + input: &[flag("library", "--library", Ty::Path, false, "A software encoder library.")], + needs: Needs::All(&["platform_encode"]), + ..OP + }, + Op { + id: "encoder.verify", + summary: "A signed encode record checked, against the stream and its source where given", + cli: Some("encode-record verify"), + usage: "encode-record verify [--stream ] [--source ]", + stability: Stability::Preview, + input: &[ + pos("record", Ty::Path, true, "The signed record."), + flag("stream", "--stream", Ty::Path, false, "The elementary stream it names."), + flag("source", "--source", Ty::Path, false, "The source it names."), + ], + needs: Needs::All(&["platform_encode"]), + ..OP + }, + // ─── the registry and parameter sets ─── + Op { + id: "registry.list", + summary: "Every registered capability with its fidelity, licence class, replicate class and role, and whether this build decodes it", + cli: Some("capabilities"), + usage: "capabilities the registered capabilities, their tier, licence, replicate class and role", + mcp: true, + ..OP + }, + Op { + id: "registry.export", + summary: "Write, or with --check compare, the registry's machine-readable export and the generated table of REGISTRY.md", + cli: Some("registry export"), + usage: "registry export [--root ] [--check] write (or compare) registry/capabilities.json, its + schema, vocab/v2.* and the generated table of REGISTRY.md", + effect: Effect::WritesProject, + toolset: Toolset::Maintainer, + input: &[flag("root", "--root", Ty::Path, false, "The wai/ directory."), switch("check", "--check", "Compare, write nothing.")], + ..OP + }, + Op { + id: "prior.list", + summary: "The listing of a parameter set (wai.prior.set) on stdout, and its pin on stderr", + cli: Some("prior-set"), + usage: "prior-set print the listing of a parameter set (wai.prior.set) and its pin", + input: &[pos("root", Ty::Path, true, "The parameter set's directory.")], + ..OP + }, + // ─── staged delivery ─── + Op { + id: "stage.encode", + summary: "A refinement layer over a base and the layers below it, toward a target", + cli: Some("stage refine"), + usage: "stage refine --base [--on ]... --target --q N [--predictor identity|up2|rate:NUM/DEN] + [--span F:C] [--out-dims D0,D1,D2] [--max-symbols N] [--table-per-unit] -o L.wai", + effect: Effect::WritesProject, + stability: Stability::Preview, + input: &[ + flag("base", "--base", Ty::Path, true, "The base envelope."), + flag("target", "--target", Ty::Path, true, "The target samples."), + flag("q", "--q", Ty::Integer, true, "The quantiser."), + flag("out", "-o", Ty::Path, true, "The layer."), + ], + needs: STAGE, + ..OP + }, + Op { + id: "stage.decode", + summary: "Envelopes taken in order: each layer offered, each base decoded and refined, with each report and the units", + cli: Some("stage apply"), + usage: "stage apply ... each layer offered, a base decoded (SPEC §4) and refined; the reports and units, or a JSON trace", + stability: Stability::Preview, + input: &[pos("envelopes", Ty::Strings, true, "The base, then its layers.")], + output: Output::Json(S_STAGE_APPLY), + needs: STAGE, + ..OP + }, + Op { + id: "stage.build", + summary: "A wai.video.int_motion base of N frames of RGB8 (WIV1, keyframe 0, zero motion)", + cli: Some("stage base"), + usage: "stage base --frames --dims N,H,W --q Q -o base.wai", + effect: Effect::WritesProject, + stability: Stability::Preview, + input: &[ + flag("frames", "--frames", Ty::Path, true, "The frames, packed RGB8."), + flag("dims", "--dims", Ty::String, true, "N,H,W."), + flag("q", "--q", Ty::Integer, true, "The quantiser."), + flag("out", "-o", Ty::Path, true, "The base envelope."), + ], + needs: STAGE, + ..OP + }, + Op { + id: "stage.inspect", + summary: "A refinement layer's refines, its WIR1 header and its declared cost", + cli: Some("stage inspect"), + usage: "stage inspect ", + stability: Stability::Preview, + input: &[pos("layer", Ty::Path, true, "A layer envelope.")], + needs: STAGE, + ..OP + }, + Op { + id: "stage.wrap", + summary: "WAI1 envelopes packed into one WAI2, their manifests verbatim", + cli: Some("stage pack"), + usage: "stage pack ... -o x.wai", + effect: Effect::WritesProject, + stability: Stability::Preview, + input: &[pos("envelopes", Ty::Strings, true, "The base, then its layers."), flag("out", "-o", Ty::Path, true, "The WAI2 envelope.")], + needs: STAGE, + ..OP + }, + Op { + id: "stage.unwrap", + summary: "One component of a WAI2 envelope as its WAI1 envelope", + cli: Some("stage explode"), + usage: "stage explode [-o out.wai]", + effect: Effect::WritesProject, + stability: Stability::Preview, + input: &[ + pos("path", Ty::Path, true, "A WAI2 envelope."), + pos("index", Ty::Integer, true, "The component's index."), + flag("out", "-o", Ty::Path, false, "The WAI1 envelope."), + ], + needs: STAGE, + ..OP + }, + // ─── HTTP delivery ─── + Op { + id: "http.export", + summary: "Envelopes named by their content hash, each with its head proof and entity tag, ready for an origin", + cli: Some("http publish"), + usage: "http publish ... --out ", + effect: Effect::WritesProject, + stability: Stability::Preview, + input: &[pos("envelopes", Ty::Strings, true, "The envelopes."), flag("out", "--out", Ty::Path, true, "The directory.")], + needs: HTTP, + ..OP + }, + Op { + id: "http.inspect", + summary: "The codecs parameter a sequence of envelopes advertises: its union and its floor", + cli: Some("http codecs"), + usage: "http codecs ...", + stability: Stability::Preview, + input: &[pos("envelopes", Ty::Strings, true, "The envelopes.")], + needs: HTTP, + ..OP + }, + Op { + id: "http.start", + summary: "An origin on loopback serving a published directory with http-delivery's headers, ranges and If-Range", + cli: Some("http origin"), + usage: "http origin --root [--port P] [--delay-range FIRST-LAST:MS]", + effect: Effect::Runs, + stability: Stability::Preview, + input: &[ + flag("root", "--root", Ty::Path, true, "The published directory."), + flag("port", "--port", Ty::Integer, false, "The port on 127.0.0.1."), + ], + needs: HTTP, + ..OP + }, + Op { + id: "hls.build", + summary: "An HLS rendition of published objects", + cli: Some("http hls"), + usage: "http hls --objects --out [--master NAME]", + effect: Effect::WritesProject, + stability: Stability::Preview, + input: &[flag("objects", "--objects", Ty::Path, true, "The objects, JSON."), flag("out", "--out", Ty::Path, true, "The directory.")], + needs: HTTP, + ..OP + }, + Op { + id: "dash.build", + summary: "A DASH manifest of published objects", + cli: Some("http mpd"), + usage: "http mpd --objects --out [--period-id ID] [--set-id N]", + effect: Effect::WritesProject, + stability: Stability::Preview, + input: &[flag("objects", "--objects", Ty::Path, true, "The objects, JSON."), flag("out", "--out", Ty::Path, true, "The manifest.")], + needs: HTTP, + ..OP + }, + Op { + id: "playlist.lock", + summary: "A media playlist sealed with a signed manifest receipt", + cli: Some("http seal-playlist"), + usage: "http seal-playlist --key [--signer-id ID] [--content-id ID] [--parent ] [--out ]", + effect: Effect::WritesProject, + stability: Stability::Preview, + input: &[pos("playlist", Ty::Path, true, "The playlist."), flag("key", "--key", Ty::Path, true, "The signing seed.")], + receipt: true, + needs: Needs::All(&["http", "video_manifest"]), + ..OP + }, + Op { + id: "playlist.verify", + summary: "A media playlist checked against its manifest receipt", + cli: Some("http check-playlist"), + usage: "http check-playlist --receipt [--parent ] [--classical ]", + stability: Stability::Preview, + input: &[pos("playlist", Ty::Path, true, "The playlist."), flag("receipt", "--receipt", Ty::Path, true, "Its manifest receipt.")], + needs: Needs::All(&["http", "video_manifest"]), + ..OP + }, + Op { + id: "corpus.generate", + summary: "The HTTP conformance corpus: generated, verified, or its differential and fuzz cases written", + cli: Some("http vectors"), + usage: "http vectors gen|verify +http vectors diff|fuzz --seed S --count N --out F", + effect: Effect::WritesProject, + toolset: Toolset::Maintainer, + stability: Stability::Preview, + input: &[pos("mode", Ty::String, true, "gen, verify, diff or fuzz."), pos("dir", Ty::Path, true, "The corpus directory.")], + needs: HTTP, + ..OP + }, + Op { + id: "http.load", + summary: "An object fetched by the byte-range procedure, repeatedly, and the record of each run", + cli: Some("http bench"), + usage: "http bench --url URL --runs N --advertise CAP [--advertise CAP]... --out F", + placement: Placement::Client, + effect: Effect::WritesProject, + stability: Stability::Preview, + input: &[ + flag("url", "--url", Ty::String, true, "The object's URL."), + flag("runs", "--runs", Ty::Integer, true, "How many runs."), + flag("advertise", "--advertise", Ty::Strings, true, "A capability this sink advertises."), + flag("out", "--out", Ty::Path, true, "The record."), + ], + needs: HTTP, + ..OP + }, + Op { + id: "receipt.fetch", + summary: "An object's receipt index fetched over HTTP and checked, its signers confirmed under the origin's key log where named", + cli: Some("http receipts"), + usage: "http receipts --url URL [--advertise CAP]... [--held fetch|none] [--join] [--max-walk N] [--fetch-budget N] + [--deadline-ms MS] [--key-origin https://HOST[:PORT] [--pin-key-log FILE | --test-plaintext-connect HOST:PORT]]", + placement: Placement::Client, + stability: Stability::Preview, + input: &[flag("url", "--url", Ty::String, true, "The object's URL.")], + needs: Needs::All(&["http", "receipt_claims"]), + ..OP + }, + // ─── compatibility and conformance ─── + Op { + id: "compat.check", + summary: "What the working tree breaks against a git ref, by category (wire, record, api); a break passes once the CHANGELOG names it", + cli: Some("breaking"), + usage: "breaking --against [--category wire|record|api] [--only ,...] [--skip ,...] + [--root ] [--target-dir ] [--all] exit 1 on a break the CHANGELOG does not acknowledge", + input: &[ + flag("against", "--against", Ty::String, true, "The git ref to compare with."), + flag("category", "--category", Ty::String, false, "wire, record or api: the categories checked, cumulative (api)."), + flag("only", "--only", Ty::String, false, "Only these surfaces, comma-separated."), + flag("skip", "--skip", Ty::String, false, "Not these surfaces, comma-separated."), + flag("root", "--root", Ty::Path, false, "The repository's root (.)."), + flag("target_dir", "--target-dir", Ty::Path, false, "Where the Rust API's description is built."), + switch("all", "--all", "List every finding, compatible ones too."), + ], + output: Output::Json(S_BREAKING), + ..OP + }, + Op { + id: "conformance.run", + summary: "WAI's conformance corpus run against another implementation, through a command or an HTTP adapter, its result a signed record wai verify checks", + cli: Some("conform"), + usage: "conform --suite --impl [--key ] [--out ] [--root ] + [--timeout ] [--name ] [--version ] [--all] [--yes] `wai conformance list` names + the kinds and suites; a record that exists is replaced only with --yes (--confirm, deprecated, is the same)", + effect: Effect::WritesProject, + input: &[ + pos("kind", Ty::String, true, "The kind of implementation (wai conformance list): a decoder, a verifier, an origin, a packager."), + flag("suite", "--suite", Ty::String, false, "The suite (wai conformance list)."), + flag("impl", "--impl", Ty::String, true, "The implementation: a command with {in}/{out}/{corpus} placeholders, or a URL."), + flag("key", "--key", Ty::Path, false, "The seed the record is signed with."), + flag("out", "--out", Ty::Path, false, "The record (.wai-conform.json)."), + flag("root", "--root", Ty::Path, false, "The wai/ directory whose corpora are run."), + flag("timeout", "--timeout", Ty::Integer, false, "Seconds a case may take."), + flag("name", "--name", Ty::String, false, "The implementation's name, for the record."), + flag("version", "--version", Ty::String, false, "The implementation's version, for the record."), + switch("all", "--all", "List every case, passed ones too."), + ], + output: Output::Json(S_CONFORM), + receipt: true, + needs: Needs::All(&["quickstart"]), + mcp: true, + confirm: Some(Confirm::AnyFile), + ..OP + }, + Op { + id: "conformance.list", + summary: "The kinds and suites wai conform runs, each suite's corpus and its cases", + cli: Some("conformance list"), + usage: "conformance list [--root ] (also wai conform --list)", + input: &[flag("root", "--root", Ty::Path, false, "The wai/ directory whose corpora are listed.")], + output: Output::Json(S_CONFORM_LIST), + needs: Needs::All(&["quickstart"]), + mcp: true, + ..OP + }, + // ─── a fleet (extensions/fleet.md §9), against a fleet service ─── + Op { + id: "enrolment.create", + summary: "A node's enrolment signed with the organisation key, written to a file and, with --target, given to a fleet service", + cli: Some("enrolment create"), + usage: "enrolment create --role --key [--label L] [--from µs] [--until µs] [--out F] + [--target URL] [--yes] a node's enrolment (WFE1), signed with the organisation key, written to F + (.wfe without --target) and, with --target, given to the fleet service there; changing the service + or replacing a file waits for --yes (--confirm, deprecated, is the same)", + placement: Placement::Client, + effect: Effect::WritesTarget, + stability: Stability::Preview, + input: &[ + pos("node", Ty::String, true, "The node's Ed25519 public key, 64 hexadecimal digits."), + flag("role", "--role", Ty::String, true, "relay, compositor, ingest, room, ledger or audit."), + flag("key", "--key", Ty::Path, true, "The organisation key's file: 32 bytes, or 64 hexadecimal digits."), + flag("label", "--label", Ty::String, false, "The node's label, at most 64 bytes."), + flag("from", "--from", Ty::Integer, false, "The start of the span it holds, microseconds since 1970 (0)."), + flag("until", "--until", Ty::Integer, false, "The end of that span, microseconds since 1970 (none)."), + flag("out", "--out", Ty::Path, false, "The enrolment's file (.wfe when no --target is given)."), + flag("target", "--target", Ty::String, false, "A fleet service's http:// address, given the enrolment."), + ], + output: Output::Json(S_ENROLMENT), + receipt: true, + needs: Needs::All(&["fleet"]), + confirm: Some(Confirm::TargetOrFile), + ..OP + }, + Op { + id: "enrolment.list", + summary: "The nodes a fleet service holds enrolled, each with its role, label and how many records it gave", + cli: Some("enrolment list"), + usage: "enrolment list --target URL the nodes the fleet service at URL holds enrolled", + placement: Placement::Client, + stability: Stability::Preview, + input: &[flag("target", "--target", Ty::String, true, "The fleet service's http:// address.")], + output: Output::Json(S_ENROLMENTS), + ..OP + }, + Op { + id: "report.create", + summary: "The fleet report of a period, signed by a fleet service over the enrolments and records it holds", + cli: Some("report create"), + usage: "report create --target URL --start µs --end µs [--yes] the fleet report of [start, end), signed by the + fleet service at URL; it changes the service, so it waits for --yes (--confirm, deprecated, is the same)", + placement: Placement::Client, + effect: Effect::WritesTarget, + stability: Stability::Preview, + input: &[ + flag("target", "--target", Ty::String, true, "The fleet service's http:// address."), + flag("start", "--start", Ty::Integer, true, "The period's start, microseconds since 1970."), + flag("end", "--end", Ty::Integer, true, "The period's end, after its start."), + ], + output: Output::Json(S_REPORT), + receipt: true, + ..OP + }, + Op { + id: "report.export", + summary: "A fleet report's bundle fetched from a fleet service, its report recomputed here, then written to a file", + cli: Some("report export"), + usage: "report export --target URL [--out F] [--yes] the report's bundle from the fleet service at URL, + recomputed by this build and written to F (.wai-fleet.json) for `wai verify`; replacing a file + waits for --yes (--confirm, deprecated, is the same)", + placement: Placement::Client, + effect: Effect::WritesProject, + stability: Stability::Preview, + input: &[ + pos("digest", Ty::String, true, "The report's digest, 64 hexadecimal digits."), + flag("target", "--target", Ty::String, true, "The fleet service's http:// address."), + flag("out", "--out", Ty::Path, false, "The bundle's file (.wai-fleet.json)."), + ], + output: Output::Json(S_REPORT_EXPORT), + receipt: true, + needs: Needs::All(&["fleet"]), + confirm: Some(Confirm::AnyFile), + ..OP + }, + // ─── the agent door ─── + Op { + id: "mcp.start", + summary: "WAI's operations as tools for a coding agent: a Model Context Protocol server on standard input and output, in toolsets", + cli: Some("mcp"), + usage: "mcp [--toolset core,dev,app] [--read-only] [--project DIR] [--spec-root DIR] [--room-bin BIN] + serves until standard input closes; --dry-run names the tools it would serve and serves nothing", + effect: Effect::Runs, + stability: Stability::Preview, + input: &[ + flag("toolset", "--toolset", Ty::String, false, "The toolsets to offer, comma-separated (core when none is named)."), + switch("read_only", "--read-only", "Offer only the tools that read or run a verifier."), + flag("project", "--project", Ty::Path, false, "The directory the server may write in (the current one)."), + flag("spec_root", "--spec-root", Ty::Path, false, "The wai/ directory whose text and corpora the tools read."), + flag("room_bin", "--room-bin", Ty::Path, false, "The room service the development room runs."), + ], + stream: Some(S_JSONRPC), + needs: Needs::All(&["mcp"]), + ..OP + }, + Op { + id: "agent.setup", + summary: "A project made ready for coding agents: AGENTS.md, an import of it in a CLAUDE.md already there, and wai mcp registered in its MCP configuration", + cli: Some("setup-agent"), + usage: "setup-agent [--dir D] [--all] [--only FILE,...] [--toolset core,dev,app] [--command PROGRAM] + a file that exists changes only once confirmed (--yes, or yes at a terminal); --dry-run shows each diff", + effect: Effect::WritesProject, + stability: Stability::Preview, + input: &[ + flag("dir", "--dir", Ty::Path, false, "The project directory (the current one)."), + switch("all", "--all", "Write every agent's configuration file, not only those whose directory is there."), + flag("only", "--only", Ty::String, false, "Only these files, comma-separated."), + flag("toolset", "--toolset", Ty::String, false, "The toolsets the registered server offers."), + flag("command", "--command", Ty::String, false, "The program the registration runs (wai)."), + ], + output: Output::Json(S_SETUP), + confirm: Some(Confirm::AnyFile), + ..OP + }, + // ─── Create (a build with `app`) ─── + Op { + id: "app.guide", + summary: "The line language and every WAI widget, as a model writing a WAI app is told them", + cli: Some("app guide"), + usage: "app guide what a model writing an app made with Create is told", + toolset: Toolset::App, + stability: Stability::Preview, + mcp: true, + needs: Needs::All(&["app"]), + ..OP + }, + Op { + id: "app.check", + summary: "An app program checked against the WAI registry and WAI's rules, every error with its line and reason code; in a project, its room and runtime too", + cli: Some("app check"), + usage: "app check [ | ] exit 1 when something does not hold, each failure named", + toolset: Toolset::App, + stability: Stability::Preview, + input: &[pos("program", Ty::Path, false, "A project directory or a program (.).")], + output: Output::Json(S_APP_CHECK), + mcp: true, + needs: Needs::All(&["app"]), + ..OP + }, + Op { + id: "app.export", + summary: "An app made with Create written as a project: the program, wai.json naming its room, the page and its runtime, a README, AGENTS.md and the licence", + cli: Some("app export"), + usage: "app export [--runtime DIR] the same as wai create --from ", + effect: Effect::WritesProject, + toolset: Toolset::App, + stability: Stability::Preview, + input: &[ + pos("program", Ty::Path, true, "The program made with Create."), + pos("dir", Ty::Path, true, "A directory that does not exist or is empty."), + flag("runtime", "--runtime", Ty::Path, false, "A Create runtime ($WAI_APP_RUNTIME, or share/wai/app beside wai)."), + ], + mcp: true, + needs: Needs::All(&["app"]), + ..OP + }, + Op { + id: "app.stream", + summary: "A program streamed into the person's open Create page through the bridge wai mcp runs on loopback", + usage: "(wai mcp --toolset app: app_stream)", + effect: Effect::Runs, + toolset: Toolset::App, + stability: Stability::Preview, + input: &[ + member("program", Ty::String, true, "The program's text."), + member("chunk", Ty::Integer, false, "Characters a chunk (24)."), + member("pace_ms", Ty::Integer, false, "Milliseconds between chunks (30)."), + ], + output: Output::Json(S_ANY), + mcp: true, + needs: Needs::All(&["app"]), + ..OP + }, + // ─── served by wai mcp alone ─── + Op { + id: "registry.show", + summary: "One capability's registry entry, whether this build decodes it, its role, and the specification lines that name it", + usage: "(wai mcp: registry_show)", + mcp: true, + input: &[member("capability", Ty::String, true, "A capability string, e.g. wai.image.avif.")], + output: Output::Json(S_ANY), + ..OP + }, + Op { + id: "docs.search", + summary: "Lines of the specification and its extensions holding every word of a query, each with its file, line and section", + usage: "(wai mcp: docs_search)", + mcp: true, + input: &[ + member("query", Ty::String, true, "Words that must all appear on a line."), + member("limit", Ty::Integer, false, "At most this many hits (20)."), + ], + output: Output::Json(S_ANY), + ..OP + }, + Op { + id: "dev.stop", + summary: "The development relay and room service a wai mcp server started, stopped", + usage: "(wai mcp: dev_stop)", + effect: Effect::Runs, + toolset: Toolset::Dev, + mcp: true, + output: Output::Json(S_ANY), + ..OP + }, + Op { + id: "room.join", + summary: "A test principal enters the development room with its grant, publishes its tracks, and is seen joined by the room's clock", + usage: "(wai mcp: room_join)", + placement: Placement::Client, + effect: Effect::WritesTarget, + toolset: Toolset::Dev, + mcp: true, + input: &[ + member("target", Ty::String, false, "local: the development room on this machine."), + member("principal", Ty::String, true, "A test principal granted the publisher role."), + member("objects", Ty::Integer, false, "Objects on each track (1)."), + ], + output: Output::Json(S_ANY), + ..OP + }, + Op { + id: "room.leave", + summary: "A test principal's session in the development room closed, and its leave seen by the room's clock", + usage: "(wai mcp: room_leave)", + placement: Placement::Client, + effect: Effect::WritesTarget, + toolset: Toolset::Dev, + mcp: true, + input: &[member("target", Ty::String, false, "local: the development room on this machine."), member("principal", Ty::String, true, "The test principal's name.")], + output: Output::Json(S_ANY), + ..OP + }, + Op { + id: "session.show", + summary: "The development room's session as a verifier is given it: descriptor, grants, key-log head and the clock's event chain", + usage: "(wai mcp: session_show)", + placement: Placement::Client, + toolset: Toolset::Dev, + mcp: true, + input: &[member("target", Ty::String, false, "local: the development room on this machine.")], + output: Output::Json(S_ANY), + ..OP + }, + // ─── the room service (wai-room), described by its OpenAPI document ─── + Op { + id: "service.show", + summary: "The service's health", + usage: "GET / (the room service)", + placement: Placement::Service, + effect: Effect::Reads, + stability: Stability::Preview, + route: Some(room("GET", "/")), + ..OP + }, + Op { + id: "service.check", + summary: "The service's health", + usage: "GET /health (the room service)", + placement: Placement::Service, + effect: Effect::Reads, + stability: Stability::Preview, + route: Some(room("GET", "/health")), + ..OP + }, + Op { + id: "room.show", + summary: "The current room", + usage: "GET /room (the room service)", + placement: Placement::Service, + effect: Effect::Reads, + stability: Stability::Preview, + route: Some(room("GET", "/room")), + ..OP + }, + Op { + id: "room.grant", + summary: "A grant for a key", + usage: "POST /grant (the room service)", + placement: Placement::Service, + effect: Effect::WritesTarget, + stability: Stability::Preview, + route: Some(room("POST", "/grant")), + toolset: Toolset::Dev, + mcp: true, + ..OP + }, + Op { + id: "admission.show", + summary: "The room's admission receipt so far", + usage: "GET /age/receipt (the room service)", + placement: Placement::Service, + effect: Effect::Reads, + stability: Stability::Preview, + route: Some(room("GET", "/age/receipt")), + ..OP + }, + Op { + id: "mls.tail", + summary: "The room's log of MLS messages", + usage: "GET /mls (the room service)", + placement: Placement::Service, + effect: Effect::Reads, + stability: Stability::Preview, + route: Some(room("GET", "/mls")), + ..OP + }, + Op { + id: "mls.import", + summary: "A KeyPackage for a granted principal's Add", + usage: "POST /mls/key-package (the room service)", + placement: Placement::Service, + effect: Effect::WritesTarget, + stability: Stability::Preview, + route: Some(room("POST", "/mls/key-package")), + ..OP + }, + Op { + id: "mls.update", + summary: "A GroupInfo, or a commit and its Welcome", + usage: "POST /mls/post (the room service)", + placement: Placement::Service, + effect: Effect::WritesTarget, + stability: Stability::Preview, + route: Some(room("POST", "/mls/post")), + ..OP + }, + Op { + id: "performance.list", + summary: "The clock's log of objects", + usage: "GET /co/objects (the room service)", + placement: Placement::Service, + effect: Effect::Reads, + stability: Stability::Preview, + route: Some(room("GET", "/co/objects")), + ..OP + }, + Op { + id: "performance.show", + summary: "The room's co-performance as the clock holds it", + usage: "GET /co/state (the room service)", + placement: Placement::Service, + effect: Effect::Reads, + stability: Stability::Preview, + route: Some(room("GET", "/co/state")), + ..OP + }, + Op { + id: "schedule.create", + summary: "A turn schedule the clock signs", + usage: "POST /co/schedule (the room service)", + placement: Placement::Service, + effect: Effect::WritesTarget, + stability: Stability::Preview, + route: Some(room("POST", "/co/schedule")), + ..OP + }, + Op { + id: "handover.create", + summary: "A handover the clock signs", + usage: "POST /co/handover (the room service)", + placement: Placement::Service, + effect: Effect::WritesTarget, + stability: Stability::Preview, + route: Some(room("POST", "/co/handover")), + ..OP + }, + Op { + id: "window.create", + summary: "A selection window the clock signs", + usage: "POST /co/window (the room service)", + placement: Placement::Service, + effect: Effect::WritesTarget, + stability: Stability::Preview, + route: Some(room("POST", "/co/window")), + ..OP + }, + Op { + id: "fidelity.create", + summary: "A fidelity statement now", + usage: "POST /co/fidelity (the room service)", + placement: Placement::Service, + effect: Effect::WritesTarget, + stability: Stability::Preview, + route: Some(room("POST", "/co/fidelity")), + ..OP + }, + Op { + id: "time.show", + summary: "The service's clock", + usage: "GET /watch/time (the room service)", + placement: Placement::Service, + effect: Effect::Reads, + stability: Stability::Preview, + route: Some(room("GET", "/watch/time")), + ..OP + }, + Op { + id: "source.fetch", + summary: "The watch room's source, whole", + usage: "GET /watch/source (the room service)", + placement: Placement::Service, + effect: Effect::Reads, + stability: Stability::Preview, + route: Some(room("GET", "/watch/source")), + ..OP + }, + Op { + id: "watch.setup", + summary: "A challenge for a join's proof", + usage: "GET /watch/challenge (the room service)", + placement: Placement::Service, + effect: Effect::WritesTarget, + stability: Stability::Preview, + route: Some(room("GET", "/watch/challenge")), + ..OP + }, + Op { + id: "watch.tail", + summary: "The watch room's log", + usage: "GET /watch (the room service)", + placement: Placement::Service, + effect: Effect::Reads, + stability: Stability::Preview, + route: Some(room("GET", "/watch")), + ..OP + }, + Op { + id: "watch.show", + summary: "The room as a co-watch verifier is given it", + usage: "GET /watch/room (the room service)", + placement: Placement::Service, + effect: Effect::Reads, + stability: Stability::Preview, + route: Some(room("GET", "/watch/room")), + ..OP + }, + Op { + id: "watch.join", + summary: "A viewer enters by a proof of its key", + usage: "POST /watch/join (the room service)", + placement: Placement::Service, + effect: Effect::WritesTarget, + stability: Stability::Preview, + route: Some(room("POST", "/watch/join")), + ..OP + }, + Op { + id: "watch.update", + summary: "A viewer's events, checked and carried in order", + usage: "POST /watch/post (the room service)", + placement: Placement::Service, + effect: Effect::WritesTarget, + stability: Stability::Preview, + route: Some(room("POST", "/watch/post")), + ..OP + }, + Op { + id: "control.grant", + summary: "The host hands control over", + usage: "POST /remote/control (the room service)", + placement: Placement::Service, + effect: Effect::WritesTarget, + stability: Stability::Preview, + route: Some(room("POST", "/remote/control")), + ..OP + }, + Op { + id: "remote.show", + summary: "The session as the clock holds it", + usage: "GET /remote/room (the room service)", + placement: Placement::Service, + effect: Effect::Reads, + stability: Stability::Preview, + route: Some(room("GET", "/remote/room")), + ..OP + }, + Op { + id: "remote.verify", + summary: "That session verified", + usage: "GET /remote/state (the room service)", + placement: Placement::Service, + effect: Effect::Reads, + stability: Stability::Preview, + route: Some(room("GET", "/remote/state")), + ..OP + }, + Op { + id: "conversation.show", + summary: "The conversation", + usage: "GET /msg (the room service)", + placement: Placement::Service, + effect: Effect::Reads, + stability: Stability::Preview, + route: Some(room("GET", "/msg")), + ..OP + }, + Op { + id: "conversation.setup", + summary: "A challenge for a proof", + usage: "GET /msg/challenge (the room service)", + placement: Placement::Service, + effect: Effect::WritesTarget, + stability: Stability::Preview, + route: Some(room("GET", "/msg/challenge")), + ..OP + }, + Op { + id: "conversation.join", + summary: "A key that proves itself is admitted", + usage: "POST /msg/join (the room service)", + placement: Placement::Service, + effect: Effect::WritesTarget, + stability: Stability::Preview, + route: Some(room("POST", "/msg/join")), + ..OP + }, + Op { + id: "conversation.tail", + summary: "The log as it is owed to the caller", + usage: "GET /msg/log (the room service)", + placement: Placement::Service, + effect: Effect::Reads, + stability: Stability::Preview, + route: Some(room("GET", "/msg/log")), + ..OP + }, + Op { + id: "conversation.import", + summary: "A member's KeyPackage", + usage: "POST /msg/key-package (the room service)", + placement: Placement::Service, + effect: Effect::WritesTarget, + stability: Stability::Preview, + route: Some(room("POST", "/msg/key-package")), + ..OP + }, + Op { + id: "conversation.update", + summary: "A GroupInfo, or a commit and its Welcome", + usage: "POST /msg/mls (the room service)", + placement: Placement::Service, + effect: Effect::WritesTarget, + stability: Stability::Preview, + route: Some(room("POST", "/msg/mls")), + ..OP + }, + Op { + id: "message.create", + summary: "A message", + usage: "POST /msg/post (the room service)", + placement: Placement::Service, + effect: Effect::WritesTarget, + stability: Stability::Preview, + route: Some(room("POST", "/msg/post")), + ..OP + }, + Op { + id: "acknowledgement.create", + summary: "An acknowledgement", + usage: "POST /msg/ack (the room service)", + placement: Placement::Service, + effect: Effect::WritesTarget, + stability: Stability::Preview, + route: Some(room("POST", "/msg/ack")), + ..OP + }, + Op { + id: "conversation.leave", + summary: "The caller leaves", + usage: "POST /msg/leave (the room service)", + placement: Placement::Service, + effect: Effect::WritesTarget, + stability: Stability::Preview, + route: Some(room("POST", "/msg/leave")), + ..OP + }, + Op { + id: "member.delete", + summary: "The owner removes a member", + usage: "POST /msg/remove (the room service)", + placement: Placement::Service, + effect: Effect::WritesTarget, + stability: Stability::Preview, + route: Some(room("POST", "/msg/remove")), + ..OP + }, + Op { + id: "call.setup", + summary: "A challenge for a proof", + usage: "GET /call/challenge (the room service)", + placement: Placement::Service, + effect: Effect::WritesTarget, + stability: Stability::Preview, + route: Some(room("GET", "/call/challenge")), + ..OP + }, + Op { + id: "call.create", + summary: "Place a call", + usage: "POST /call/place (the room service)", + placement: Placement::Service, + effect: Effect::WritesTarget, + stability: Stability::Preview, + route: Some(room("POST", "/call/place")), + ..OP + }, + Op { + id: "call.invite", + summary: "Ring invitees", + usage: "POST /call/ring (the room service)", + placement: Placement::Service, + effect: Effect::WritesTarget, + stability: Stability::Preview, + route: Some(room("POST", "/call/ring")), + ..OP + }, + Op { + id: "inbox.show", + summary: "The caller's inbox", + usage: "GET /inbox (the room service)", + placement: Placement::Service, + effect: Effect::Reads, + stability: Stability::Preview, + route: Some(room("GET", "/inbox")), + ..OP + }, + Op { + id: "answer.create", + summary: "Accept, decline or cancel", + usage: "POST /call/answer (the room service)", + placement: Placement::Service, + effect: Effect::WritesTarget, + stability: Stability::Preview, + route: Some(room("POST", "/call/answer")), + ..OP + }, + Op { + id: "call.show", + summary: "The current call", + usage: "GET /call (the room service)", + placement: Placement::Service, + effect: Effect::Reads, + stability: Stability::Preview, + route: Some(room("GET", "/call")), + ..OP + }, + // ─── the catalogue itself ─── + Op { + id: "ops.list", + summary: "Every operation of this catalogue: its id, command line, effect and whether this build holds it", + cli: Some("ops list"), + usage: "ops list every operation, its id and command line", + output: Output::Json(S_OPS_LIST), + ..OP + }, + Op { + id: "ops.search", + summary: "The operations that match a task, ranked, offline: up to five", + cli: Some("ops search"), + usage: "ops search \"\" up to five operations that match, ranked, offline", + input: &[pos("task", Ty::String, true, "What you want to do, in words.")], + output: Output::Json(S_OPS_SEARCH), + ..OP + }, + Op { + id: "ops.show", + summary: "One operation's entry: its input and output schemas, effect, placement and command line", + cli: Some("ops show"), + aliases: &["ops schema"], + usage: "ops show one operation's entry, as JSON (also wai ops schema)", + input: &[pos("id", Ty::String, true, "An operation id, noun.verb.")], + output: Output::Json(S_OPS_SCHEMA), + ..OP + }, + Op { + id: "ops.export", + summary: "The catalogue as JSON (wai.ops/1), what other tools and CI read; --check compares a file with it", + cli: Some("ops export"), + usage: "ops export [--out FILE | --check FILE] the catalogue as JSON, to stdout, to FILE, or compared with FILE", + effect: Effect::WritesProject, + input: &[ + flag("out", "--out", Ty::Path, false, "Write the export here."), + flag("check", "--check", Ty::Path, false, "Compare this file with the export; exit 1 when they differ."), + ], + output: Output::Json(S_OPS_EXPORT), + ..OP + }, +]; + +/// The operation `id`. +pub fn op(id: &str) -> Option<&'static Op> { + OPS.iter().find(|o| o.id == id) +} + +/// Whether this build of the crate has `feature`. `None` for a name this function does not +/// know, which the tests refuse in any [`Op::needs`]. +pub fn feature_on(feature: &str) -> Option { + Some(match feature { + "quickstart" => cfg!(feature = "quickstart"), + "floor" => cfg!(feature = "floor"), + "codecs" => cfg!(feature = "codecs"), + "http" => cfg!(feature = "http"), + "receipt_claims" => cfg!(feature = "receipt_claims"), + "video_manifest" => cfg!(feature = "video_manifest"), + "key_discovery" => cfg!(feature = "key_discovery"), + "generate" => cfg!(feature = "generate"), + "score" => cfg!(feature = "score"), + "staged" => cfg!(feature = "staged"), + "video_int" => cfg!(feature = "video_int"), + "audio_int" => cfg!(feature = "audio_int"), + "splat_int" => cfg!(feature = "splat_int"), + "platform_encode" => cfg!(feature = "platform_encode"), + "mcp" => cfg!(feature = "mcp"), + "app" => cfg!(feature = "app"), + "fleet" => cfg!(feature = "fleet"), + _ => return None, + }) +} + +impl Placement { + pub fn label(self) -> &'static str { + match self { + Placement::Local => "local", + Placement::Client => "client", + Placement::Service => "service", + Placement::Both => "both", + } + } +} + +impl Effect { + pub fn label(self) -> &'static str { + match self { + Effect::Reads => "reads", + Effect::Runs => "runs", + Effect::WritesProject => "writes-project", + Effect::WritesTarget => "writes-target", + Effect::Destructive => "destructive", + } + } +} + +impl Confirm { + pub fn label(self) -> &'static str { + match self { + Confirm::Never => "never", + Confirm::UnnamedFile => "when-replacing-an-unnamed-file", + Confirm::AnyFile => "when-replacing-a-file", + Confirm::TargetOrFile => "when-changing-the-target-or-replacing-a-file", + Confirm::Always => "always", + } + } + + /// The sentence an operation's help gives it; none for `Never`. + pub fn sentence(self) -> Option<&'static str> { + Some(match self { + Confirm::Never => return None, + Confirm::UnnamedFile => "Replacing a file the command line did not name waits for confirmation", + Confirm::AnyFile => "Replacing a file that exists waits for confirmation", + Confirm::TargetOrFile => "Changing the service it names, or replacing a file that exists, waits for confirmation", + Confirm::Always => "It waits for confirmation", + }) + } +} + +impl Toolset { + pub fn label(self) -> &'static str { + match self { + Toolset::Core => "core", + Toolset::Dev => "dev", + Toolset::App => "app", + Toolset::Quantum => "quantum", + Toolset::Maintainer => "maintainer", + } + } +} + +impl Stability { + pub fn label(self) -> &'static str { + match self { + Stability::Stable => "stable", + Stability::Preview => "preview", + Stability::Internal => "internal", + } + } +} + +impl Ty { + fn schema(self) -> Value { + match self { + Ty::String => json!({"type": "string"}), + Ty::Path => json!({"type": "string", "format": "path"}), + Ty::Integer => json!({"type": "integer"}), + Ty::Boolean => json!({"type": "boolean"}), + Ty::Strings => json!({"type": "array", "items": {"type": "string"}}), + Ty::Json => json!({}), + } + } +} + +/// An operation's name in each door, by the mechanical rule. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Names { + /// `wai `. + pub cli: String, + /// `noun_verb`. + pub mcp: String, + /// `wai_noun_verb`. + pub c: String, + /// `wai::noun::verb`. + pub rust: String, + /// `wai.noun.verb`, for JavaScript, Swift and Kotlin. + pub sdk: String, +} + +impl Op { + /// The noun and the verb. + pub fn parts(&self) -> (&'static str, &'static str) { + self.id.split_once('.').unwrap_or((self.id, "")) + } + + /// Its name in every door. + pub fn names(&self) -> Names { + let (n, v) = self.parts(); + Names { + cli: format!("wai {n} {v}"), + mcp: format!("{n}_{v}"), + c: format!("wai_{n}_{v}"), + rust: format!("wai::{n}::{v}"), + sdk: format!("wai.{n}.{v}"), + } + } + + /// Every command line that runs it, each as its words after `wai`: its CLI spelling, its + /// aliases, then its mechanical form. None for an operation the command line does not + /// carry (one served only over HTTP or MCP). + pub fn cli_forms(&self) -> Vec> { + let (n, v) = self.parts(); + let mut out: Vec> = Vec::new(); + if self.cli.is_none() { + return out; + } + for f in self.cli.iter().chain(self.aliases.iter()) { + out.push(f.split_whitespace().collect()); + } + let mech = vec![n, v]; + if !out.contains(&mech) { + out.push(mech); + } + out + } + + /// The command line help names it by: its CLI spelling, or, for an operation the command + /// line does not carry, its mechanical form (which runs nothing). + pub fn command(&self) -> String { + match self.cli { + Some(c) => format!("wai {c}"), + None => self.names().cli, + } + } + + /// Whether this build holds it. + pub fn available(&self) -> bool { + let on = |f: &&str| feature_on(f).unwrap_or(false); + match self.needs { + Needs::Nothing => true, + Needs::All(fs) => fs.iter().all(on), + Needs::Any(fs) => fs.iter().any(on), + } + } + + /// Whether it takes `--dry-run`: every operation of the command line whose effect is not + /// `reads`. + pub fn dry_run(&self) -> bool { + self.cli.is_some() && self.effect != Effect::Reads + } + + /// When it waits for confirmation: as its entry says, or else by its effect. A destructive + /// operation, or one that changes a target that is not this machine, waits every time; one + /// that reads waits never; one that writes on this machine waits when it would replace a + /// file the command line did not name. + pub fn confirms(&self) -> Confirm { + if let Some(c) = self.confirm { + return c; + } + match self.effect { + Effect::Destructive | Effect::WritesTarget => Confirm::Always, + Effect::Reads => Confirm::Never, + Effect::Runs | Effect::WritesProject => Confirm::UnnamedFile, + } + } + + /// Its input as a JSON Schema object. An operation of a service's HTTP API with no other + /// input points at its operation in the service's OpenAPI document. + pub fn input_schema(&self) -> Value { + if let (Some(r), true) = (self.route, self.input.is_empty()) + && let Some((_, doc)) = API_DOCUMENTS.iter().find(|(s, _)| *s == r.service) + { + let pointer = r.path.replace('~', "~0").replace('/', "~1"); + return json!({"$ref": format!("{doc}#/paths/{pointer}/{}", r.method.to_ascii_lowercase())}); + } + let mut props = Map::new(); + let mut required = Vec::new(); + for a in self.input { + let mut s = a.ty.schema(); + s["description"] = Value::from(a.doc); + match a.cli { + Cli::Positional => s["x-cli"] = json!({"positional": true}), + Cli::Flag(f) | Cli::Switch(f) => s["x-cli"] = json!({"flag": f}), + Cli::Member => {} + } + props.insert(a.name.to_string(), s); + if a.required { + required.push(a.name); + } + } + json!({"type": "object", "properties": props, "required": required, "additionalProperties": false}) + } + + /// Its output, the `data` of its `--json` envelope, as a JSON Schema object. + pub fn output_schema(&self) -> Value { + match self.output { + Output::Text => json!({"type": "object", "properties": {"text": {"type": "string"}}, "required": ["text"]}), + Output::Json(s) => serde_json::from_str(s).expect("an output schema is JSON"), + } + } + + /// The entry as the export writes it. + pub fn to_json(&self) -> Value { + let n = self.names(); + let needs = match self.needs { + Needs::Nothing => Value::Null, + Needs::All(f) => json!({"all": f}), + Needs::Any(f) => json!({"any": f}), + }; + json!({ + "id": self.id, + "summary": self.summary, + "cli": self.cli.map(|c| format!("wai {c}")), + "aliases": self.aliases.iter().map(|a| format!("wai {a}")).collect::>(), + "usage": self.usage.lines().map(|l| if l.starts_with(' ') { l.to_string() } else { format!("wai {l}") }).collect::>(), + "placement": self.placement.label(), + "effect": self.effect.label(), + "dry_run": self.dry_run(), + "confirm": self.cli.map(|_| self.confirms().label()), + "route": self.route.map(|r| json!({"service": r.service, "method": r.method, "path": r.path})), + "doors": {"cli": self.cli.is_some(), "mcp": self.mcp, "http": self.route.is_some()}, + "toolset": self.toolset.label(), + "stability": self.stability.label(), + "input": self.input_schema(), + "output": self.output_schema(), + "stream": self.stream.map(|s| serde_json::from_str::(s).expect("a stream schema is JSON")), + "since": self.since, + "deprecated": self.deprecated.map(|d| json!({"since": d.since, "use_instead": d.use_instead})), + "receipt": self.receipt, + "needs": needs, + "names": {"cli": self.cli.map(|_| n.cli), "mcp": self.mcp.then_some(n.mcp), "c": n.c, "rust": n.rust, "sdk": n.sdk}, + }) + } +} + +/// The catalogue as `wai ops export` writes it: the format, the toolchain's version, the +/// envelope's version and every operation, in [`OPS`]' order. The same text for the same +/// table on every platform. +pub fn export() -> Value { + json!({ + "format": FORMAT, + "toolchain": env!("CARGO_PKG_VERSION"), + "envelope": ENVELOPE, + "exit_codes": EXIT_CODES.iter().map(|(c, m)| json!({"exit": c, "meaning": m})).collect::>(), + "agent_flags": AGENT_FLAGS + .iter() + .map(|f| json!({"flag": f.flag, "summary": f.summary, "aliases": f.deprecated_alias.map(|(a, since)| json!({"flag": a, "deprecated": {"since": since, "use_instead": f.flag}})).into_iter().collect::>()})) + .collect::>(), + "operations": OPS.iter().map(Op::to_json).collect::>(), + }) +} + +/// [`export`] as text: pretty JSON with a final newline. +pub fn export_text() -> String { + serde_json::to_string_pretty(&export()).expect("the export is JSON") + "\n" +} + +/// The exit codes every catalogue command uses. +pub const EXIT_CODES: &[(u8, &str)] = &[ + (0, "done; every check held"), + (1, "ran, and a check failed"), + (2, "usage error or invalid input"), + (3, "authentication or permission"), + (4, "confirmation required; nothing changed"), + (5, "not found (file, target, room, operation)"), + (6, "target unavailable; safe to retry"), + (7, "internal error"), +]; + +/// A flag every operation of the command line takes, wherever it is on the line. +#[derive(Clone, Copy, Debug)] +pub struct AgentFlag { + pub flag: &'static str, + pub summary: &'static str, + /// Another spelling that still works, deprecated: the spelling, and the version since. + pub deprecated_alias: Option<(&'static str, &'static str)>, +} + +/// The agent rules' flags (WAI toolchain v1, rules A1 to A6): what `wai --help` lists for +/// every operation and the export carries. One confirmation flag, `--yes`; `--confirm`, which +/// the first commands to confirm took, is its deprecated spelling and does the same. +pub const AGENT_FLAGS: &[AgentFlag] = &[ + AgentFlag { flag: "--json", summary: "one JSON envelope on stdout: {\"wai\": \"envelope/1\", \"op\", \"ok\", \"data\" | \"error\"}", deprecated_alias: None }, + AgentFlag { flag: "--dry-run", summary: "write nothing and send nothing; say what would be done (an operation that only reads ignores it)", deprecated_alias: None }, + AgentFlag { flag: "--yes", summary: "confirm a change that waits for confirmation; off a terminal, without it, exit 4", deprecated_alias: Some(("--confirm", V0)) }, + AgentFlag { flag: "--no-input", summary: "never ask: a change that waits for confirmation exits 4 unless --yes is given", deprecated_alias: None }, +]; + +/// Whether `word` is one of [`AGENT_FLAGS`] or a deprecated spelling of one. +pub fn is_agent_flag(word: &str) -> bool { + AGENT_FLAGS.iter().any(|f| f.flag == word || f.deprecated_alias.is_some_and(|(a, _)| a == word)) +} + +/// One match of [`search`]. +#[derive(Clone, Copy, Debug)] +pub struct Match { + pub op: &'static Op, + pub score: u32, +} + +/// Words that say nothing about which operation is meant. +const STOP: &[&str] = &[ + "a", "an", "the", "of", "to", "in", "on", "for", "with", "and", "or", "my", "me", "i", "it", "its", "this", "that", + "how", "do", "can", "what", "is", "are", "from", "into", "as", "by", "at", "be", "want", "please", "some", "one", +]; + +/// Words that mean another word, for the search alone. +const SYNONYMS: &[(&str, &str)] = &[ + ("make", "create"), ("new", "create"), ("scaffold", "create"), ("init", "create"), ("start", "start"), + ("run", "start"), ("serve", "start"), ("launch", "start"), ("local", "dev"), ("development", "dev"), + ("check", "verify"), ("validate", "verify"), ("prove", "verify"), ("signature", "receipt"), + ("show", "inspect"), ("read", "inspect"), ("describe", "inspect"), ("look", "inspect"), ("open", "inspect"), + ("extract", "unwrap"), ("pack", "wrap"), ("package", "wrap"), ("compress", "encode"), ("convert", "encode"), + ("capability", "registry"), ("codec", "registry"), ("list", "list"), ("example", "template"), + ("template", "template"), ("project", "project"), ("room", "room"), ("relay", "relay"), + ("picture", "generate"), ("image", "media"), ("audio", "media"), ("video", "media"), ("playlist", "playlist"), + ("operation", "ops"), ("command", "ops"), ("catalogue", "ops"), ("catalog", "ops"), +]; + +fn stem(w: &str) -> String { + let w = w.to_ascii_lowercase(); + if let Some(s) = w.strip_suffix("ies") + && s.len() >= 3 + { + return format!("{s}y"); + } + for suf in ["ing", "ed"] { + if w.len() > suf.len() + 3 + && let Some(s) = w.strip_suffix(suf) + { + return s.to_string(); + } + } + if let Some(s) = w.strip_suffix("es") + && s.len() >= 3 + && (s.ends_with(['s', 'x', 'z']) || s.ends_with("ch") || s.ends_with("sh")) + { + return s.to_string(); + } + if let Some(s) = w.strip_suffix('s') + && s.len() >= 3 + && !s.ends_with('s') + { + return s.to_string(); + } + w +} + +fn words(text: &str) -> Vec { + text.split(|c: char| !c.is_ascii_alphanumeric()).filter(|w| !w.is_empty()).map(|w| w.to_ascii_lowercase()).collect() +} + +/// The operations that match `task`, best first, at most `limit`, offline. A word of the +/// task scores against an operation's noun and verb (most), its command line and aliases, +/// then its summary; a word is compared by its stem, and a few common words stand for the +/// operation word they mean (`make` for `create`, `check` for `verify`). Ties go to the +/// order of [`OPS`]. Operations this build does not hold are ranked as well. +pub fn search(task: &str, limit: usize) -> Vec { + let mut query: Vec = Vec::new(); + for w in words(task) { + if STOP.contains(&w.as_str()) { + continue; + } + let s = stem(&w); + if !query.contains(&s) { + query.push(s.clone()); + } + if let Some((_, to)) = SYNONYMS.iter().find(|(from, _)| stem(from) == s || *from == w) + && !query.contains(&to.to_string()) + { + query.push(to.to_string()); + } + } + let mut out: Vec = Vec::new(); + for o in OPS { + let (n, v) = o.parts(); + let id_words = [stem(n), stem(v)]; + let cli_words: Vec = o.cli.iter().chain(o.aliases.iter()).flat_map(|c| words(c)).map(|w| stem(&w)).collect(); + let summary: Vec = words(o.summary).iter().map(|w| stem(w)).collect(); + let mut score = 0u32; + for q in &query { + if id_words.contains(q) { + score += 6; + } + if cli_words.contains(q) { + score += 4; + } + if summary.contains(q) { + score += 2; + } else if q.len() >= 4 && summary.iter().any(|w| w.starts_with(q.as_str())) { + score += 1; + } + } + if score > 0 { + out.push(Match { op: o, score }); + } + } + out.sort_by_key(|m| std::cmp::Reverse(m.score)); + out.truncate(limit); + out +} + +/// What is wrong with the table: one line a finding, none when every rule holds. +pub fn findings() -> Vec { + findings_of(OPS) +} + +/// [`findings`] for any table. +pub fn findings_of(ops: &[Op]) -> Vec { + let mut f = Vec::new(); + let mut seen_forms: Vec<(Vec<&str>, &str)> = Vec::new(); + for (i, o) in ops.iter().enumerate() { + let id = o.id; + let ok_word = |w: &str| !w.is_empty() && w.bytes().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit()) && w.as_bytes()[0].is_ascii_lowercase(); + match id.split_once('.') { + Some((n, v)) if ok_word(n) && ok_word(v) && !v.contains('.') => { + if let Some((_, use_instead)) = NEAR_SYNONYMS.iter().find(|(s, _)| *s == v) { + f.push(format!("{id}: the verb {v} is a near-synonym; use {use_instead}")); + } else if !VERBS.contains(&v) { + f.push(format!("{id}: {v} is not one of the verbs an operation ends with (N3: {})", VERBS.join(", "))); + } + // `ops` and `docs` are the study's own nouns for the catalogue and the documentation. + if n.len() > 3 && n.ends_with('s') && !n.ends_with("ss") && !n.ends_with("us") && !["ops", "docs"].contains(&n) { + f.push(format!("{id}: the noun {n} is plural; nouns are singular")); + } + } + _ => f.push(format!("{id:?}: an id is noun.verb, lowercase ASCII letters and digits")), + } + if ops[..i].iter().any(|p| p.id == id) { + f.push(format!("{id}: listed twice")); + } + if o.summary.is_empty() || o.summary.contains('\n') || o.summary.ends_with('.') { + f.push(format!("{id}: the summary is one line, without a final full stop")); + } + if o.usage.is_empty() { + f.push(format!("{id}: no usage")); + } + if o.since.is_empty() { + f.push(format!("{id}: no since")); + } + if o.cli.is_none() && !o.mcp && o.route.is_none() { + f.push(format!("{id}: no door carries it (no command line, no MCP tool, no route): the catalogue lists what works")); + } + if let Some(r) = o.route + && !API_DOCUMENTS.iter().any(|(s, _)| *s == r.service) + { + f.push(format!("{id}: its route's service {} has no API document in API_DOCUMENTS", r.service)); + } + if o.mcp && !matches!(o.toolset, Toolset::Core | Toolset::Dev | Toolset::App) { + f.push(format!("{id}: served by wai mcp, in a toolset the server does not offer ({})", o.toolset.label())); + } + if o.cli.is_none() && o.input.iter().any(|a| !matches!(a.cli, Cli::Member)) { + f.push(format!("{id}: an input with a command-line form, and no command line")); + } + // A change to what is not this machine, or one that cannot be undone, waits for --yes; + // one that only reads never does. + let c = o.confirms(); + if matches!(o.effect, Effect::WritesTarget | Effect::Destructive) && !matches!(c, Confirm::Always | Confirm::TargetOrFile) { + f.push(format!("{id}: effect {} and confirmation {}: a change to a target waits for --yes", o.effect.label(), c.label())); + } + if o.effect == Effect::Reads && c != Confirm::Never { + f.push(format!("{id}: it only reads, and its confirmation is {}", c.label())); + } + let serves = matches!(o.placement, Placement::Service | Placement::Both); + if serves != o.route.is_some() { + f.push(format!("{id}: a route exactly when it runs on a service (placement {}, route {:?})", o.placement.label(), o.route)); + } + match o.needs { + Needs::All(fs) | Needs::Any(fs) => { + for x in fs { + if feature_on(x).is_none() { + f.push(format!("{id}: needs the feature {x}, which catalogue::feature_on does not know")); + } + } + } + Needs::Nothing => {} + } + for a in o.input { + if o.input.iter().filter(|b| b.name == a.name).count() > 1 { + f.push(format!("{id}: the input {} twice", a.name)); + } + } + if let Output::Json(s) = o.output + && serde_json::from_str::(s).is_err() + { + f.push(format!("{id}: the output schema is not JSON")); + } + if let Some(s) = o.stream + && serde_json::from_str::(s).is_err() + { + f.push(format!("{id}: the stream schema is not JSON")); + } + for form in o.cli_forms() { + if let Some((_, other)) = seen_forms.iter().find(|(w, other)| *w == form && *other != id) { + f.push(format!("{id}: `wai {}` already runs {other}", form.join(" "))); + } + seen_forms.push((form, id)); + } + } + // A command line that is a strict prefix of another operation's would take that + // operation's words as its arguments. + for (a, ida) in &seen_forms { + for (b, idb) in &seen_forms { + if ida != idb && a.len() < b.len() && b[..a.len()] == a[..] { + f.push(format!("{ida}: `wai {}` is the start of {idb}'s `wai {}`", a.join(" "), b.join(" "))); + } + } + } + f +} + +/// The operation a command line names, and the words after it: the longest command line of +/// any operation that `args` starts with. +pub fn resolve(args: &[String]) -> Option<(&'static Op, usize)> { + let mut best: Option<(&'static Op, usize)> = None; + for o in OPS { + for form in o.cli_forms() { + if form.len() <= args.len() && form.iter().zip(args).all(|(w, a)| *w == a) && best.is_none_or(|(_, n)| form.len() > n) { + best = Some((o, form.len())); + } + } + } + best +} + +#[cfg(test)] +mod tests { + use super::*; + + fn v(s: &[&str]) -> Vec { + s.iter().map(|x| x.to_string()).collect() + } + + #[test] + fn every_entry_keeps_the_rules() { + let f = findings(); + assert!(f.is_empty(), "{}", f.join("\n")); + } + + #[test] + fn the_rules_refuse_what_they_name() { + // Each rule, broken in a copy of the table, is a finding that names it. + let broken = |f: &dyn Fn(&mut Vec)| -> String { + let mut t = OPS.to_vec(); + f(&mut t); + findings_of(&t).join("\n") + }; + let at = |t: &mut Vec, id: &str| t.iter().position(|o| o.id == id).unwrap(); + assert!(broken(&|t| { let i = at(t, "registry.list"); t[i].id = "registry.get"; }).contains("near-synonym")); + assert!(broken(&|t| { let i = at(t, "registry.list"); t[i].id = "registry.session"; }).contains("not one of the verbs")); + assert!(broken(&|t| { let i = at(t, "registry.list"); t[i].id = "Registry.list"; }).contains("noun.verb")); + assert!(broken(&|t| { let i = at(t, "registry.list"); t[i].id = "registries.list"; }).contains("plural")); + assert!(broken(&|t| { let i = at(t, "registry.list"); t[i].id = "envelope.inspect"; }).contains("listed twice")); + assert!(broken(&|t| { let i = at(t, "registry.list"); t[i].placement = Placement::Service; }).contains("route")); + assert!(broken(&|t| { let i = at(t, "registry.list"); t[i].cli = Some("inspect"); }).contains("already runs")); + assert!(broken(&|t| { let i = at(t, "registry.list"); t[i].cli = Some("registry"); }).contains("is the start of")); + assert!(broken(&|t| { let i = at(t, "registry.list"); t[i].needs = Needs::All(&["no_such_feature"]); }).contains("does not know")); + assert!(broken(&|t| { let i = at(t, "registry.list"); t[i].summary = "Two\nlines"; }).contains("one line")); + assert!(broken(&|t| { let i = at(t, "report.create"); t[i].confirm = Some(Confirm::UnnamedFile); }).contains("waits for --yes")); + assert!(broken(&|t| { let i = at(t, "registry.list"); t[i].confirm = Some(Confirm::AnyFile); }).contains("only reads")); + assert!(broken(&|_| {}).is_empty()); + } + + #[test] + fn a_command_line_resolves_to_its_operation_and_its_mechanical_form_too() { + let r = |s: &[&str]| resolve(&v(s)).map(|(o, n)| (o.id, n)); + assert_eq!(r(&["inspect", "x.wai"]), Some(("envelope.inspect", 1))); + assert_eq!(r(&["envelope", "inspect", "x.wai"]), Some(("envelope.inspect", 2))); + assert_eq!(r(&["registry", "export", "--check"]), Some(("registry.export", 2))); + assert_eq!(r(&["registry", "list"]), Some(("registry.list", 2))); + assert_eq!(r(&["capabilities"]), Some(("registry.list", 1))); + assert_eq!(r(&["dev", "--check"]), Some(("dev.start", 1))); + assert_eq!(r(&["dev", "start", "--check"]), Some(("dev.start", 2))); + assert_eq!(r(&["http", "seal-playlist", "p.m3u8"]), Some(("playlist.lock", 2))); + assert_eq!(r(&["playlist", "lock", "p.m3u8"]), Some(("playlist.lock", 2))); + assert_eq!(r(&["encode-record", "verify", "r.json"]), Some(("encoder.verify", 2))); + assert_eq!(r(&["hello"]), Some(("toolchain.test", 1))); + assert_eq!(r(&["ops", "schema", "x"]), Some(("ops.show", 2))); + assert_eq!(r(&["frobnicate"]), None); + assert_eq!(r(&["registry"]), None); + } + + #[test] + fn the_names_in_every_door_are_mechanical() { + let n = op("envelope.inspect").unwrap().names(); + assert_eq!((n.cli.as_str(), n.mcp.as_str(), n.c.as_str(), n.rust.as_str(), n.sdk.as_str()), + ("wai envelope inspect", "envelope_inspect", "wai_envelope_inspect", "wai::envelope::inspect", "wai.envelope.inspect")); + } + + #[test] + fn the_search_ranks_the_operation_a_task_means_first() { + let first = |t: &str| search(t, 5).first().map(|m| m.op.id); + assert_eq!(first("verify receipts"), Some("receipt.verify")); + assert_eq!(first("check the receipts under a directory"), Some("receipt.verify")); + assert_eq!(first("make a new project from a template"), Some("project.create")); + assert_eq!(first("start a local room for development"), Some("dev.start")); + assert_eq!(first("what is inside an envelope"), Some("envelope.inspect")); + assert_eq!(first("list capabilities"), Some("registry.list")); + assert_eq!(first("search operations"), Some("ops.search")); + assert!(search("zzzz qqqq", 5).is_empty()); + assert!(search("wai", 5).len() <= 5); + } + + #[test] + fn the_export_is_json_with_every_operation_in_order() { + let e = export(); + assert_eq!(e["format"], FORMAT); + let ids: Vec<&str> = e["operations"].as_array().unwrap().iter().map(|o| o["id"].as_str().unwrap()).collect(); + assert_eq!(ids, OPS.iter().map(|o| o.id).collect::>()); + for o in e["operations"].as_array().unwrap() { + // An object schema, or for an operation of a service a pointer into its API document. + let r = o["input"]["$ref"].as_str(); + assert!(o["input"]["type"] == "object" || r.is_some_and(|r| r.contains("openapi.json#/paths/")), "{}", o["id"]); + assert!(o["dry_run"].as_bool().unwrap() == (!o["cli"].is_null() && o["effect"] != "reads"), "{}", o["id"]); + } + assert_eq!(export_text(), export_text()); + } +} diff --git a/wai/wai-rs/src/lib.rs b/wai/wai-rs/src/lib.rs index a8ed1cd69..6b5f622dc 100644 --- a/wai/wai-rs/src/lib.rs +++ b/wai/wai-rs/src/lib.rs @@ -20,6 +20,10 @@ //! dispatch table knows them; the actual neural model runs in whatever //! ML runtime the sink has installed (PyTorch / Core ML / ONNX / etc.). +// The operation catalogue: every operation the toolchain offers, `noun.verb`, as data every +// door (the CLI, the C ABI, the SDKs, MCP, the HTTP API) is named from and checked against. +// Pure data; no feature. +pub mod catalogue; pub mod codecs; pub mod container; // Platform decode capabilities (extensions/platform-decode.md): the query a sink makes of @@ -43,9 +47,6 @@ pub mod pin; // The reader limits the extension formats share with SPEC §2: how many samples, counting // every channel, and how many frames a reader must be willing to render. Always on. pub mod limits; -// The rule the toolchain's operation names keep (noun.verb, a closed list of verbs) and the -// forms the doors derive from a name: an MCP tool's, an OpenAPI operationId. Always on. -pub mod ops; #[cfg(feature = "ffi")] pub mod ffi; #[cfg(feature = "codecs")] diff --git a/wai/wai-rs/src/ops.rs b/wai/wai-rs/src/ops.rs deleted file mode 100644 index 270f3be64..000000000 --- a/wai/wai-rs/src/ops.rs +++ /dev/null @@ -1,66 +0,0 @@ -//! The rule the toolchain's operation names keep (WAI toolchain v1, naming rule N3), and the -//! mechanical forms the doors derive from a name. -//! -//! An operation is named `noun.verb`: both parts lowercase ASCII letters, the noun singular -//! (a compound written as one word, as `keylog` is), the verb from a closed list ([`VERBS`]). -//! A near-synonym is not a second verb: `get`, `describe` and `info` are `show`; `keygen`, -//! `init` and `new` are `create`. The catalogue names a few operations whose last word is not on -//! the list ([`NAMED_AS_IS`]); they keep the names it gives them. -//! -//! Each door takes its form from the name, never a name of its own: an OpenAPI operation's -//! `operationId` is the name itself, and an MCP tool's name is the name with `_` for the dot -//! ([`mcp_tool_name`]). `wai mcp`'s tools and the room service's API description are held to -//! this rule by their tests. - -/// The verbs an operation's name may end with. -pub const VERBS: &[&str] = &[ - "create", "list", "show", "update", "delete", "check", "verify", "run", "build", "export", "import", "start", "stop", "join", - "leave", "grant", "invite", "revoke", "rotate", "tail", "search", "test", "load", "encode", "decode", "inspect", "wrap", "unwrap", - "fetch", "generate", "promote", "rollback", "lock", "upgrade", "setup", "guide", "stream", -]; - -/// Names the catalogue gives as they are, though their last word is not on the list: the -/// catalogue's table is the authority on its own names. -pub const NAMED_AS_IS: &[&str] = &[ - "room.session", "agent.mcp", "toolchain.hello", "toolchain.doctor", "ops.schema", "edge.derive", "meter.calibrate", "audit.report", -]; - -/// `Ok` when `id` is an operation's name by the rule (or one the catalogue gives as it is), or -/// why it is not. -pub fn check_id(id: &str) -> Result<(), String> { - if NAMED_AS_IS.contains(&id) { - return Ok(()); - } - let Some((noun, verb)) = id.split_once('.') else { - return Err(format!("{id:?}: an operation is named noun.verb")); - }; - let letters = |s: &str| !s.is_empty() && s.bytes().all(|b| b.is_ascii_lowercase()); - if !letters(noun) || !letters(verb) { - return Err(format!("{id:?}: the noun and the verb are lowercase ASCII letters, one dot between them")); - } - if !VERBS.contains(&verb) { - return Err(format!("{id:?}: {verb} is not one of the verbs an operation ends with ({})", VERBS.join(", "))); - } - Ok(()) -} - -/// The MCP tool an operation is served as: its name with `_` for the dot. -pub fn mcp_tool_name(id: &str) -> String { - id.replace('.', "_") -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn names_keep_the_rule() { - for ok in ["room.grant", "envelope.inspect", "keylog.verify", "dev.start", "room.session"] { - assert_eq!(check_id(ok), Ok(()), "{ok}"); - } - for no in ["room", "room.get", "Room.grant", "room.grant.now", "room_grant", "room.", ".grant", "key-log.verify", "room.describe", "call.session"] { - assert!(check_id(no).is_err(), "{no}"); - } - assert_eq!(mcp_tool_name("receipt.verify"), "receipt_verify"); - } -} diff --git a/wai/wai-rs/tests/cli_app.rs b/wai/wai-rs/tests/cli_app.rs index 00a882b20..19b6d0b9f 100644 --- a/wai/wai-rs/tests/cli_app.rs +++ b/wai/wai-rs/tests/cli_app.rs @@ -116,8 +116,8 @@ fn an_export_is_an_ordinary_project_and_its_check_holds() { assert!(std::fs::read_to_string(p.join("app.oui")).unwrap().starts_with("root = App(")); let (code, v) = app_check(&p); assert_eq!(code, 0, "{v}"); - assert_eq!(v["result"]["room"]["kind"], "show"); - assert!(v["result"]["failures"].as_array().unwrap().is_empty(), "{v}"); + assert_eq!(v["data"]["room"]["kind"], "show"); + assert!(v["data"]["failures"].as_array().unwrap().is_empty(), "{v}"); let _ = std::fs::remove_dir_all(&d); } @@ -142,7 +142,7 @@ fn the_check_refuses_a_project_that_is_not_whole() { assert!(o.status.success()); mutate(&b); let (code, v) = app_check(&b); - let failures: String = v["result"]["failures"].as_array().unwrap().iter().map(|f| f.as_str().unwrap()).collect::>().join("\n"); + let failures: String = v["data"]["failures"].as_array().unwrap().iter().map(|f| f.as_str().unwrap()).collect::>().join("\n"); assert!(code == 1 && failures.contains(expect), "a project {what}: {code} {v}"); } let _ = std::fs::remove_dir_all(&d); @@ -167,7 +167,7 @@ fn a_watch_room_names_its_file_and_its_check_wants_it() { assert!(std::fs::read_to_string(p.join("wai.json")).unwrap().contains("\"dev\": { \"site\": \".\", \"mode\": \"watch\", \"watch\": \"media/film.wav\" }")); assert!(p.join("media/README.md").is_file()); let (code, v) = app_check(&p); - assert!(code == 1 && v["result"]["failures"].to_string().contains("media/film.wav is not there"), "{v}"); + assert!(code == 1 && v["data"]["failures"].to_string().contains("media/film.wav is not there"), "{v}"); std::fs::write(p.join("media/film.wav"), b"RIFF").unwrap(); let (code, v) = app_check(&p); assert_eq!(code, 0, "{v}"); diff --git a/wai/wai-rs/tests/cli_breaking.rs b/wai/wai-rs/tests/cli_breaking.rs index bf65a978b..cbc3170d8 100644 --- a/wai/wai-rs/tests/cli_breaking.rs +++ b/wai/wai-rs/tests/cli_breaking.rs @@ -50,7 +50,7 @@ fn breaking(dir: &Path, base: &str, extra: &[&str]) -> (i32, Value) { } fn finding<'a>(v: &'a Value, id: &str) -> Option<&'a Value> { - v["findings"].as_array().unwrap().iter().find(|f| f["id"] == id) + v["data"]["findings"].as_array().unwrap().iter().find(|f| f["id"] == id) } const REGISTRY: &str = r#"{"schema": "x", "capabilities": [ @@ -135,8 +135,8 @@ fn nothing_changed_nothing_breaks() { let (code, v) = breaking(&d, &base, &["--skip", "rust"]); assert_eq!(code, 0, "{v:#}"); assert_eq!(v["op"], "compat.check"); - assert_eq!(v["breaking"], 0); - assert!(v["findings"].as_array().unwrap().is_empty(), "{v:#}"); + assert_eq!(v["data"]["breaking"], 0); + assert!(v["data"]["findings"].as_array().unwrap().is_empty(), "{v:#}"); } #[test] @@ -266,8 +266,8 @@ fn a_listed_measurement_is_not_compared_and_a_vector_beside_it_is() { write(&d, "wai/format-probe-conformance/provenance.json", r#"{"decodes": [{"host": "x86-64", "identical": false}]}"#); let (code, v) = breaking(&d, &base, &["--only", "corpora"]); assert_eq!(code, 0, "{v:#}"); - assert!(v["findings"].as_array().unwrap().is_empty(), "{v:#}"); - assert!(v["notes"].as_array().unwrap().iter().any(|n| n.as_str().unwrap().contains("provenance.json changed, a measurement")), "{v:#}"); + assert!(v["data"]["findings"].as_array().unwrap().is_empty(), "{v:#}"); + assert!(v["data"]["notes"].as_array().unwrap().iter().any(|n| n.as_str().unwrap().contains("provenance.json changed, a measurement")), "{v:#}"); edit(&d, "wai/format-probe-conformance/vectors.json", "\"expect\": \"probe\"", "\"expect\": \"refuse\""); let (code, v) = breaking(&d, &base, &["--only", "corpora"]); assert_eq!(code, 1, "{v:#}"); diff --git a/wai/wai-rs/tests/cli_conform.rs b/wai/wai-rs/tests/cli_conform.rs index d669e9195..95290d393 100644 --- a/wai/wai-rs/tests/cli_conform.rs +++ b/wai/wai-rs/tests/cli_conform.rs @@ -9,7 +9,7 @@ //! - the reference origin (`wai http origin`) serving the HTTP corpus's objects conforms, //! and one serving an object with one byte changed fails that object; //! - a record changed after signing, in a case's outcome or in its summary, is refused by -//! `wai verify`; a dry run writes nothing; a record is not replaced without `--confirm`; +//! `wai verify`; a dry run writes nothing; a record is not replaced without `--yes`; //! each failure to run has its exit status. #![cfg(all(feature = "quickstart", feature = "score", feature = "key_discovery"))] @@ -40,7 +40,7 @@ fn text(o: &Output) -> String { /// `wai conform --suite --impl --json` writing its record to `out`. fn conform(dir: &Path, kind: &str, suite: &str, imp: &str, out: &Path) -> (i32, Value) { let root = wai_dir(); - let o = wai(dir, &["conform", kind, "--suite", suite, "--impl", imp, "--root", root.to_str().unwrap(), "--out", out.to_str().unwrap(), "--confirm", "--json"]); + let o = wai(dir, &["conform", kind, "--suite", suite, "--impl", imp, "--root", root.to_str().unwrap(), "--out", out.to_str().unwrap(), "--yes", "--json"]); let v: Value = serde_json::from_slice(&o.stdout).unwrap_or_else(|_| panic!("JSON: {}", text(&o))); (o.status.code().unwrap_or(-1), v) } @@ -56,8 +56,8 @@ fn the_reference_verifiers_conform_and_one_changed_verdict_does_not() { let out = d.join(format!("{suite}.wai-conform.json")); let (code, v) = conform(&d, "verifier", suite, &format!("{} verdicts {{corpus}}", q(bin)), &out); assert_eq!(code, 0, "{suite}: {v:#}"); - assert_eq!(v["conformant"], true); - assert!(v["cases"].as_u64().unwrap() > 100, "{v}"); + assert_eq!(v["data"]["conformant"], true); + assert!(v["data"]["cases"].as_u64().unwrap() > 100, "{v}"); let rec: Value = serde_json::from_slice(&std::fs::read(&out).unwrap()).unwrap(); assert_eq!(rec["wai"], "conform-result/1"); assert!(rec["corpus"]["digest"].as_str().unwrap().starts_with("sha256:")); @@ -72,8 +72,8 @@ fn the_reference_verifiers_conform_and_one_changed_verdict_does_not() { let bad = format!("{} verdicts {{corpus}} | awk 'NR == 1 {{ sub(/\\t/, \"\\t~\") }} 1'", q(env!("CARGO_BIN_EXE_wai_signer_vectors"))); let (code, v) = conform(&d, "verifier", "signer", &bad, &d.join("bad.wai-conform.json")); assert_eq!(code, 1, "{v:#}"); - assert_eq!(v["conformant"], false); - assert_eq!(v["failed"], 1, "{v:#}"); + assert_eq!(v["data"]["conformant"], false); + assert_eq!(v["data"]["failed"], 1, "{v:#}"); // A record that says "not conformant" is still a genuine record. let o = wai(&d, &["verify", d.join("bad.wai-conform.json").to_str().unwrap()]); assert!(o.status.success() && text(&o).contains("not conformant"), "{}", text(&o)); @@ -128,13 +128,13 @@ fn the_score_renderer_conforms_and_one_flipped_output_byte_fails() { let w = q(env!("CARGO_BIN_EXE_wai")); let (code, v) = conform(&d, "decoder", "score", &format!("{w} score render {{in}} --pcm {{out}}"), &d.join("score.wai-conform.json")); assert_eq!(code, 0, "{v:#}"); - assert_eq!(v["passed"], 3); + assert_eq!(v["data"]["passed"], 3); // One byte of the output changed (byte 100 of the PCM, plus one). let flip = format!("{w} score render {{in}} --pcm {{out}} >/dev/null && dd if={{out}} bs=1 skip=100 count=1 2>/dev/null | LC_ALL=C tr '\\000-\\377' '\\001-\\377\\000' | dd of={{out}} bs=1 seek=100 count=1 conv=notrunc 2>/dev/null"); let (code, v) = conform(&d, "decoder", "score", &flip, &d.join("flip.wai-conform.json")); assert_eq!(code, 1, "{v:#}"); - assert_eq!(v["failed"], 3, "{v:#}"); - assert_eq!(v["passed"], 0); + assert_eq!(v["data"]["failed"], 3, "{v:#}"); + assert_eq!(v["data"]["passed"], 0); } /// `wai http origin --root ` on a port the system picks: the child and its URL, from @@ -176,8 +176,8 @@ fn the_reference_origin_conforms_and_one_changed_object_fails() { let (code, v) = conform(&d, "origin", "http", &url, &d.join("bad.wai-conform.json")); let _ = c.kill(); assert_eq!(code, 1, "{v:#}"); - assert_eq!(v["failed"], 1, "{v:#}"); - assert!(v["failures"][0]["got"].as_str().unwrap().contains("the body is not the object"), "{v:#}"); + assert_eq!(v["data"]["failed"], 1, "{v:#}"); + assert!(v["data"]["failures"][0]["got"].as_str().unwrap().contains("the body is not the object"), "{v:#}"); } /// A verifier behind HTTP: the reference's verdicts, served one case a request. @@ -234,10 +234,14 @@ fn a_dry_run_writes_nothing_and_each_failure_has_its_exit_status() { assert_eq!(v["dry_run"], true); assert!(std::fs::read_dir(&d).unwrap().next().is_none(), "a dry run writes nothing"); - // The record exists: not replaced without --confirm (4). + // The record exists: not replaced without --yes (4), the proposal and the confirming command + // in the envelope. std::fs::write(d.join("score.wai-conform.json"), "{}").unwrap(); let o = wai(&d, &["conform", "decoder", "--suite", "score", "--impl", &imp, "--root", r, "--json"]); assert_eq!(o.status.code(), Some(4), "{}", text(&o)); + let v: Value = serde_json::from_slice(&o.stdout).unwrap(); + assert_eq!((v["error"]["code"].as_str(), v["proposed"][0]["replaces"].as_bool()), (Some("confirmation-required"), Some(true)), "{v:#}"); + assert!(v["confirm"].as_str().unwrap().ends_with(" --yes"), "{v:#}"); assert_eq!(std::fs::read(d.join("score.wai-conform.json")).unwrap(), b"{}"); let code = |args: &[&str]| wai(&d, args).status.code(); @@ -249,5 +253,5 @@ fn a_dry_run_writes_nothing_and_each_failure_has_its_exit_status() { assert_eq!(code(&["conform", "origin", "--impl", &format!("http://127.0.0.1:{free}"), "--root", r, "--out", d.join("o.wai-conform.json").to_str().unwrap()]), Some(6), "nothing listening"); let o = wai(&d, &["conform", "--list", "--root", r, "--json"]); let v: Value = serde_json::from_slice(&o.stdout).unwrap(); - assert!(v["suites"].as_array().unwrap().iter().all(|s| s["cases"].as_u64().unwrap() > 0), "{v:#}"); + assert!(v["data"]["suites"].as_array().unwrap().iter().all(|s| s["cases"].as_u64().unwrap() > 0), "{v:#}"); } diff --git a/wai/wai-rs/tests/cli_dx.rs b/wai/wai-rs/tests/cli_dx.rs index 9ead82f19..92602f8e8 100644 --- a/wai/wai-rs/tests/cli_dx.rs +++ b/wai/wai-rs/tests/cli_dx.rs @@ -105,8 +105,10 @@ fn create_writes_every_example_as_it_is_in_the_repository() { let examples = Path::new(env!("CARGO_MANIFEST_DIR")).join("../examples"); let o = wai(&d, &["create", "--list", "--json"]); assert!(o.status.success(), "{}", text(&o)); - let list: serde_json::Value = serde_json::from_slice(&o.stdout).unwrap(); - let list = list.as_array().unwrap(); + // The JSON envelope (envelope/1): the list is its data. + let env: serde_json::Value = serde_json::from_slice(&o.stdout).unwrap(); + assert_eq!((env["ok"].as_bool(), env["op"].as_str()), (Some(true), Some("project.create")), "{env}"); + let list = env["data"].as_array().unwrap(); let names: Vec<&str> = list.iter().map(|t| t["template"].as_str().unwrap()).collect(); // Every example directory is a template, and every template an example. let mut dirs: Vec = std::fs::read_dir(&examples).unwrap().flatten().filter(|e| e.path().is_dir()).map(|e| e.file_name().to_string_lossy().into_owned()).collect(); diff --git a/wai/wai-rs/tests/cli_fleet.rs b/wai/wai-rs/tests/cli_fleet.rs index ab2b78456..8e13cd009 100644 --- a/wai/wai-rs/tests/cli_fleet.rs +++ b/wai/wai-rs/tests/cli_fleet.rs @@ -1,10 +1,10 @@ //! The fleet's operations through the `wai` binary (extensions/fleet.md §9), as an agent runs -//! them, on the toolchain's rules: the JSON envelope when standard output is not a terminal, the -//! exit statuses, `--dry-run` that writes and sends nothing, and `--confirm` before a fleet -//! service is changed or a file replaced; a refusal of the service named by the fleet's reason; -//! a report's bundle verified here before it is written; and `wai verify` checking a bundle -//! offline and refusing it altered. The fleet service is played by a server in the test that -//! answers as `wai-fleet` does. +//! them, on the toolchain's agent rules: the JSON envelope with `--json`, the exit statuses, +//! `--dry-run` that writes and sends nothing and names what it would, and `--yes` before a fleet +//! service is changed or a file replaced (`--confirm`, its deprecated spelling, does the same); +//! a refusal of the service named by the fleet's reason; a report's bundle verified here before +//! it is written; and `wai verify` checking a bundle offline and refusing it altered. The fleet +//! service is played by a server in the test that answers as `wai-fleet` does. #![cfg(feature = "quickstart")] use std::io::{Read, Write}; @@ -21,11 +21,17 @@ fn tmp(name: &str) -> PathBuf { d } +/// `wai` run in `dir` as an agent runs it: no terminal, and `--json`. fn wai(dir: &Path, args: &[&str]) -> Output { - Command::new(env!("CARGO_BIN_EXE_wai")).current_dir(dir).args(args).output().expect("wai runs") + Command::new(env!("CARGO_BIN_EXE_wai")).current_dir(dir).args(args).arg("--json").stdin(std::process::Stdio::null()).output().expect("wai runs") } -/// The envelope a run printed (its standard output is not a terminal here). +/// `wai` run in `dir` as a person scripts it: no terminal, text. +fn wai_text(dir: &Path, args: &[&str]) -> Output { + Command::new(env!("CARGO_BIN_EXE_wai")).current_dir(dir).args(args).stdin(std::process::Stdio::null()).output().expect("wai runs") +} + +/// The envelope a `--json` run printed. fn envelope(o: &Output) -> Value { serde_json::from_slice(&o.stdout).unwrap_or_else(|e| panic!("{e}: {}{}", String::from_utf8_lossy(&o.stdout), String::from_utf8_lossy(&o.stderr))) } @@ -82,11 +88,12 @@ const NODE: &str = "111111111111111111111111111111111111111111111111111111111111 fn enrolment_create_signs_writes_and_waits_for_confirmation() { let d = tmp("enrol"); std::fs::write(d.join("org.key"), "07".repeat(32)).unwrap(); - // A dry run writes nothing. + // A dry run writes nothing, and its plan names the file. let o = wai(&d, &["enrolment", "create", NODE, "--role", "relay", "--key", "org.key", "--label", "home", "--out", "n.wfe", "--dry-run"]); assert_eq!(o.status.code(), Some(0)); let e = envelope(&o); - assert_eq!((e["op"].as_str(), e["ok"].as_bool(), e["data"]["dry_run"].as_bool()), (Some("enrolment.create"), Some(true), Some(true))); + assert_eq!((e["op"].as_str(), e["ok"].as_bool(), e["dry_run"].as_bool()), (Some("enrolment.create"), Some(true), Some(true))); + assert_eq!((e["plan"][0]["write"].as_str(), e["plan"][0]["replaces"].as_bool()), (Some("n.wfe"), Some(false)), "{e:#}"); assert!(!d.join("n.wfe").exists(), "a dry run writes nothing"); // The enrolment, written: it reads and its signature verifies. let o = wai(&d, &["enrolment", "create", NODE, "--role", "relay", "--key", "org.key", "--label", "home", "--out", "n.wfe"]); @@ -96,13 +103,26 @@ fn enrolment_create_signs_writes_and_waits_for_confirmation() { let parsed = wai::fleet::Enrolment::parse(&b).unwrap(); assert_eq!((wai::fleet::hex(&parsed.node), parsed.role, parsed.label_text()), (NODE.to_string(), 1, "home".to_string())); assert_eq!(e["data"]["digest"], json!(wai::fleet::hex(&parsed.digest()))); - // Replacing it waits for --confirm: exit 4, with the command that confirms. + // Replacing it waits for --yes: exit 4, with the proposal and the command that confirms. let o = wai(&d, &["enrolment", "create", NODE, "--role", "ingest", "--key", "org.key", "--out", "n.wfe"]); assert_eq!(o.status.code(), Some(4)); let e = envelope(&o); assert_eq!(e["error"]["code"], "confirmation-required"); - assert!(e["confirm"].as_str().unwrap().ends_with("--confirm")); + assert_eq!((e["proposed"][0]["write"].as_str(), e["proposed"][0]["replaces"].as_bool()), (Some("n.wfe"), Some(true)), "{e:#}"); + assert_eq!(e["confirm"], format!("wai enrolment create {NODE} --role ingest --key org.key --out n.wfe --yes"), "{e:#}"); assert_eq!(std::fs::read(d.join("n.wfe")).unwrap(), b, "nothing changed"); + // In text, the same: exit 4, nothing changed, and the confirming command on standard error. + let o = wai_text(&d, &["enrolment", "create", NODE, "--role", "ingest", "--key", "org.key", "--out", "n.wfe"]); + assert_eq!(o.status.code(), Some(4)); + assert!(String::from_utf8_lossy(&o.stderr).contains("to go ahead: wai enrolment create"), "{}", String::from_utf8_lossy(&o.stderr)); + assert_eq!(std::fs::read(d.join("n.wfe")).unwrap(), b, "nothing changed"); + // --yes replaces it; --confirm, the deprecated spelling, does the same. + let o = wai(&d, &["enrolment", "create", NODE, "--role", "ingest", "--key", "org.key", "--out", "n.wfe", "--yes"]); + assert_eq!(o.status.code(), Some(0), "{}", String::from_utf8_lossy(&o.stderr)); + assert_eq!(wai::fleet::Enrolment::parse(&std::fs::read(d.join("n.wfe")).unwrap()).unwrap().role, 3); + let o = wai(&d, &["enrolment", "create", NODE, "--role", "relay", "--key", "org.key", "--label", "home", "--out", "n.wfe", "--confirm"]); + assert_eq!(o.status.code(), Some(0), "{}", String::from_utf8_lossy(&o.stderr)); + assert_eq!(std::fs::read(d.join("n.wfe")).unwrap(), b, "--confirm went ahead as --yes does"); // Its own rules refuse it before anything is written: a node that is the organisation. let org = wai::fleet::hex(&ed25519_dalek::SigningKey::from_bytes(&[7; 32]).verifying_key().to_bytes()); let o = wai(&d, &["enrolment", "create", &org, "--role", "relay", "--key", "org.key", "--out", "self.wfe"]); @@ -117,10 +137,13 @@ fn enrolment_create_against_a_service() { let d = tmp("enrol-target"); std::fs::write(d.join("org.key"), [7u8; 32]).unwrap(); let (url, rx) = service(vec![(201, json!({"node": NODE, "role": "relay", "digest": "00"})), (422, json!({"outcome": "refuse", "reason": "enrolment_duplicate"}))]); - // A service is not changed without --confirm. + // A service is not changed without --yes; a dry run names the request and sends nothing. let o = wai(&d, &["enrolment", "create", NODE, "--role", "relay", "--key", "org.key", "--target", &url]); assert_eq!(o.status.code(), Some(4)); - let o = wai(&d, &["enrolment", "create", NODE, "--role", "relay", "--key", "org.key", "--target", &url, "--confirm"]); + assert_eq!(envelope(&o)["proposed"][0]["request"], "POST /v1/fleet/enrolments"); + let o = wai(&d, &["enrolment", "create", NODE, "--role", "relay", "--key", "org.key", "--target", &url, "--dry-run"]); + assert_eq!((o.status.code(), envelope(&o)["plan"][0]["request"].as_str()), (Some(0), Some("POST /v1/fleet/enrolments"))); + let o = wai(&d, &["enrolment", "create", NODE, "--role", "relay", "--key", "org.key", "--target", &url, "--yes"]); assert_eq!(o.status.code(), Some(0), "{}", String::from_utf8_lossy(&o.stdout)); let (line, body) = rx.recv().unwrap(); assert_eq!(line, "POST /v1/fleet/enrolments HTTP/1.0"); @@ -128,7 +151,7 @@ fn enrolment_create_against_a_service() { assert!(wai::fleet::Enrolment::parse(&sent).is_ok(), "the service is given a signed enrolment"); assert!(!d.join(format!("{}.wfe", &NODE[..16])).exists(), "with --target, a file only with --out"); // The service's refusal is named by the fleet's reason. - let o = wai(&d, &["enrolment", "create", NODE, "--role", "relay", "--key", "org.key", "--target", &url, "--confirm"]); + let o = wai(&d, &["enrolment", "create", NODE, "--role", "relay", "--key", "org.key", "--target", &url, "--yes"]); assert_eq!((o.status.code(), envelope(&o)["error"]["code"].as_str()), (Some(1), Some("enrolment_duplicate"))); // No service there: unavailable, and worth a retry. let o = wai(&d, &["enrolment", "list", "--target", "http://127.0.0.1:9"]); @@ -154,9 +177,10 @@ fn report_create_and_export_and_verify() { ]); assert_eq!(wai(&d, &["report", "create", "--target", &url, "--start", &start]).status.code(), Some(2), "no end"); let o = wai(&d, &["report", "create", "--target", &url, "--start", &start, "--end", &end, "--dry-run"]); - assert_eq!((o.status.code(), envelope(&o)["data"]["dry_run"].as_bool()), (Some(0), Some(true))); + assert_eq!((o.status.code(), envelope(&o)["dry_run"].as_bool()), (Some(0), Some(true))); + assert_eq!(envelope(&o)["plan"][0]["request"], "POST /v1/fleet/reports"); assert_eq!(wai(&d, &["report", "create", "--target", &url, "--start", &start, "--end", &end]).status.code(), Some(4)); - let o = wai(&d, &["report", "create", "--target", &url, "--start", &start, "--end", &end, "--confirm"]); + let o = wai(&d, &["report", "create", "--target", &url, "--start", &start, "--end", &end, "--yes"]); assert_eq!(o.status.code(), Some(0)); assert_eq!(envelope(&o)["data"]["outcome"]["totals"]["energy"]["hops"]["coverage"], "partial"); assert_eq!(rx.recv().unwrap().0, "POST /v1/fleet/reports HTTP/1.0"); @@ -170,13 +194,17 @@ fn report_create_and_export_and_verify() { let o = wai(&d, &["report", "export", &digest, "--target", &url, "--out", "altered.wai-fleet.json"]); assert_eq!((o.status.code(), envelope(&o)["error"]["code"].as_str()), (Some(1), Some("bad_signature"))); assert!(!d.join("altered.wai-fleet.json").exists()); + // Exported again over the file: that waits for --yes, and fetches before it asks. + let o = wai(&d, &["report", "export", &digest, "--target", &url, "--out", "week.wai-fleet.json", "--dry-run"]); + let e = envelope(&o); + assert_eq!((o.status.code(), e["plan"][1]["write"].as_str(), e["plan"][1]["replaces"].as_bool()), (Some(0), Some("week.wai-fleet.json"), Some(true)), "{e:#}"); // `wai verify` checks the bundle offline, and refuses it altered. - let o = wai(&d, &["verify", "week.wai-fleet.json"]); + let o = wai_text(&d, &["verify", "week.wai-fleet.json"]); let t = String::from_utf8_lossy(&o.stdout); assert!(o.status.success(), "{t}"); assert!(t.contains("every line recomputed") && t.contains("silent") && t.contains("hops partial"), "{t}"); std::fs::write(d.join("bad.wai-fleet.json"), altered.to_string()).unwrap(); - let o = wai(&d, &["verify", "bad.wai-fleet.json"]); + let o = wai_text(&d, &["verify", "bad.wai-fleet.json"]); assert_eq!(o.status.code(), Some(1)); assert!(String::from_utf8_lossy(&o.stdout).contains("FAIL fleet report: bad_signature")); } diff --git a/wai/wai-rs/tests/cli_generate_score.rs b/wai/wai-rs/tests/cli_generate_score.rs index 97c4909d4..3a5d44a1f 100644 --- a/wai/wai-rs/tests/cli_generate_score.rs +++ b/wai/wai-rs/tests/cli_generate_score.rs @@ -57,9 +57,16 @@ fn refused(run: &Output, out: &Path, what: &str) -> String { stderr } -/// The `--json` line `wai generate` prints, checked against the file it wrote. +/// The `data` of the JSON envelope (envelope/1) that `wai ... --json` prints. +fn data(stdout: &str) -> Value { + let env: Value = serde_json::from_str(stdout.trim()).expect("one JSON envelope"); + assert_eq!((env["wai"].as_str(), env["ok"].as_bool()), (Some("envelope/1"), Some(true)), "{env}"); + env["data"].clone() +} + +/// The `--json` data `wai generate` prints, checked against the file it wrote. fn made(stdout: &str, out: &Path, w: u64, h: u64) -> String { - let v: Value = serde_json::from_str(stdout.trim()).unwrap(); + let v: Value = data(stdout); let rgb = std::fs::read(out).unwrap(); assert_eq!((v["w"].as_u64(), v["h"].as_u64()), (Some(w), Some(h))); assert_eq!(v["bytes"].as_u64(), Some(w * h * 3)); @@ -366,13 +373,13 @@ fn score_build_and_render_reproduce_the_corpus() { let notes = score_dir().join("notes").join(format!("{name}.json")); let stdout = ok(&wai(&["score", "build", p(¬es), "-o", p(&built), "--json"])); assert_eq!(std::fs::read(&built).unwrap(), committed, "{name}: built byte for byte"); - let b: Value = serde_json::from_str(stdout.trim()).unwrap(); + let b: Value = data(&stdout); assert_eq!(b["bytes"].as_u64(), Some(committed.len() as u64)); assert_eq!(b["score_hash"], spec["score_hash"], "{name}"); let pcm = scratch(&format!("{name}.s16")); let stdout = ok(&wai(&["score", "render", p(&built), "--pcm", p(&pcm), "--json"])); - let r: Value = serde_json::from_str(stdout.trim()).unwrap(); + let r: Value = data(&stdout); let duration = spec["duration"].as_u64().unwrap(); assert_eq!(r["samples"].as_u64(), Some(duration), "{name}: the contract's duration, whole"); assert_eq!(r["sample_rate"].as_u64(), Some(22050)); @@ -477,7 +484,7 @@ fn score_render_refuses_a_note_count_its_bytes_cannot_hold() { empty[n - 4..].copy_from_slice(&0u32.to_le_bytes()); let input = scratch("empty.wscr"); std::fs::write(&input, empty).unwrap(); - let r: Value = serde_json::from_str(ok(&wai(&["score", "render", p(&input), "--json"])).trim()).unwrap(); + let r: Value = data(&ok(&wai(&["score", "render", p(&input), "--json"]))); assert_eq!((r["samples"].as_u64(), r["notes"].as_u64()), (Some(100), Some(0)), "{r}"); let input = scratch("huge.wscr"); std::fs::write(&input, b).unwrap(); diff --git a/wai/wai-rs/tests/cli_mcp.rs b/wai/wai-rs/tests/cli_mcp.rs index 056c2d750..50797065a 100644 --- a/wai/wai-rs/tests/cli_mcp.rs +++ b/wai/wai-rs/tests/cli_mcp.rs @@ -5,14 +5,14 @@ //! //! - Every tool is an operation of the toolchain's catalogue, named by it: the tool list is //! exactly the operations below, each tool's name its operation's with `_` for the dot, each -//! operation's name keeping the rule (`wai::ops::check_id`). `core` is offered by default, +//! operation of the catalogue (`wai::catalogue`). `core` is offered by default, //! `dev` when it is asked for, and a toolset that does not exist is refused. //! - `receipt_verify` agrees with the committed corpora vector by vector: every group and object //! vector of jwp-conformance (kind delivery), every claim vector whose context is one the tool //! takes (none, the previous claim, the object, an OER receipt, a gap's object; kind claim), //! every room of live-conformance/rooms (kind room_session) and every session of //! live-conformance/audit (kind audit_bundle); a receipt changed by one character is refused. -//! - `package_build` writes an envelope inside the project directory only, never over a file +//! - `envelope_wrap` writes an envelope inside the project directory only, never over a file //! without `overwrite`; `envelope_inspect` reads it back, and the directory as a package. //! - `conformance_run` runs a corpus's independent verifier and reports it passed. //! - The `dev` tools refuse a service that is not on a loopback address, before any @@ -123,8 +123,8 @@ fn corpus(rel: &str) -> Value { /// The operations of each toolset, in the order the server lists them. A tool is its /// operation's name with `_` for the dot. -const CORE: &[&str] = &["envelope.inspect", "registry.list", "registry.show", "receipt.verify", "package.build", "conformance.list", "conformance.run", "docs.search"]; -const DEV: &[&str] = &["dev.start", "dev.stop", "room.grant", "room.join", "room.leave", "room.session"]; +const CORE: &[&str] = &["envelope.inspect", "registry.list", "registry.show", "receipt.verify", "envelope.wrap", "conformance.list", "conformance.run", "docs.search"]; +const DEV: &[&str] = &["dev.start", "dev.stop", "room.grant", "room.join", "room.leave", "session.show"]; fn tool_names(c: &mut Client) -> Vec { let list = c.request("tools/list", json!({})); @@ -132,7 +132,7 @@ fn tool_names(c: &mut Client) -> Vec { } fn names_of(ops: &[&[&str]]) -> Vec { - ops.iter().flat_map(|s| s.iter()).map(|op| wai::ops::mcp_tool_name(op)).collect() + ops.iter().flat_map(|s| s.iter()).map(|op| op.replace('.', "_")).collect() } #[test] @@ -149,7 +149,7 @@ fn the_handshake_and_the_tool_list() { assert_eq!(names, names_of(&[CORE, DEV])); for t in tools { let op = t["_meta"]["wai/operation"].as_str().unwrap(); - assert_eq!(wai::ops::check_id(op), Ok(()), "{t}"); + assert!(wai::catalogue::op(op).is_some_and(|o| o.mcp), "{t}: an operation of the catalogue that wai mcp serves"); assert_eq!(t["name"].as_str().unwrap(), op.replace('.', "_"), "{t}: the tool is its operation, named by it"); let set = t["_meta"]["wai/toolset"].as_str().unwrap(); assert_eq!(set, if DEV.contains(&op) { "dev" } else { "core" }, "{t}"); @@ -184,11 +184,11 @@ fn read_only_offers_no_tool_that_writes_serves_or_mints() { let d = scratch("readonly"); let mut c = Client::start(&d, &["--toolset", "core,dev", "--read-only"]); let names = tool_names(&mut c); - for gone in ["package_build", "dev_start", "dev_stop", "room_grant", "room_join", "room_leave", "room_session"] { + for gone in ["envelope_wrap", "dev_start", "dev_stop", "room_grant", "room_join", "room_leave", "session_show"] { assert!(!names.iter().any(|n| n == gone), "{gone} is offered read-only"); } assert!(names.iter().any(|n| n == "receipt_verify")); - let r = c.request("tools/call", json!({"name": "package_build", "arguments": {"input": "x", "capability": "wai.text.zstd"}})); + let r = c.request("tools/call", json!({"name": "envelope_wrap", "arguments": {"input": "x", "capability": "wai.text.zstd"}})); assert_eq!(r["error"]["code"], -32602, "{r}"); } @@ -228,21 +228,21 @@ fn packaging_and_inspecting_stay_inside_the_project() { let mut c = Client::start(&d, &[]); let input = d.join("hello.txt"); std::fs::write(&input, b"hello, world").unwrap(); - let p = c.ok("package_build", json!({"input": input, "capability": "wai.text.zstd", "intent": "improve"})); + let p = c.ok("envelope_wrap", json!({"input": input, "capability": "wai.text.zstd", "intent": "improve"})); assert_eq!((p["format"].as_str(), p["components"][0]["capability"].as_str()), (Some("WAI1"), Some("wai.text.zstd")), "{p}"); let out = d.join("hello.txt.wai"); assert!(out.is_file()); - assert!(c.call("package_build", json!({"input": input, "capability": "wai.text.zstd", "intent": "improve"})).unwrap_err().contains("exists")); - c.ok("package_build", json!({"input": input, "capability": "wai.text.zstd", "intent": "improve", "overwrite": true})); + assert!(c.call("envelope_wrap", json!({"input": input, "capability": "wai.text.zstd", "intent": "improve"})).unwrap_err().contains("exists")); + c.ok("envelope_wrap", json!({"input": input, "capability": "wai.text.zstd", "intent": "improve", "overwrite": true})); let outside = std::env::temp_dir().join(format!("wai-cli-mcp-outside-{}.wai", std::process::id())); - let e = c.call("package_build", json!({"input": input, "capability": "wai.text.zstd", "output": outside})).unwrap_err(); + let e = c.call("envelope_wrap", json!({"input": input, "capability": "wai.text.zstd", "output": outside})).unwrap_err(); assert!(e.contains("outside the project directory") && !outside.exists(), "{e}"); - assert!(c.call("package_build", json!({"input": input, "capability": "wai.text.zstd", "output": "../escape.wai"})).is_err()); - assert!(c.call("package_build", json!({"input": input, "capability": "not.a.capability"})).is_err()); + assert!(c.call("envelope_wrap", json!({"input": input, "capability": "wai.text.zstd", "output": "../escape.wai"})).is_err()); + assert!(c.call("envelope_wrap", json!({"input": input, "capability": "not.a.capability"})).is_err()); #[cfg(unix)] { std::os::unix::fs::symlink(&outside, d.join("link.wai")).unwrap(); - assert!(c.call("package_build", json!({"input": input, "capability": "wai.text.zstd", "output": "link.wai", "overwrite": true})).unwrap_err().contains("a link")); + assert!(c.call("envelope_wrap", json!({"input": input, "capability": "wai.text.zstd", "output": "link.wai", "overwrite": true})).unwrap_err().contains("a link")); assert!(!outside.exists(), "nothing written through a link"); std::fs::remove_file(d.join("link.wai")).unwrap(); } @@ -400,7 +400,7 @@ fn development_tools_refuse_any_service_off_this_machine() { assert!(e.starts_with("DEVELOPMENT ONLY"), "{remote}: {e}"); } // A target other than local is refused, whatever the service. - for t in ["room_grant", "room_join", "room_leave", "room_session"] { + for t in ["room_grant", "room_join", "room_leave", "session_show"] { let e = c.call(t, json!({"target": "cloud", "principal": "alice", "role": "publisher"})).unwrap_err(); assert!(e.contains("target local alone"), "{t}: {e}"); } @@ -412,7 +412,7 @@ fn development_tools_refuse_any_service_off_this_machine() { for t in ["room_join", "room_leave"] { assert!(c.call(t, json!({"principal": "alice"})).is_err()); } - assert!(c.call("room_session", json!({})).unwrap_err().contains("dev_start")); + assert!(c.call("session_show", json!({})).unwrap_err().contains("dev_start")); assert!(c.call("dev_stop", json!({})).is_err()); assert!(c.call("room_grant", json!({"principal": "alice", "role": "publisher"})).unwrap_err().contains("dev_start")); let e = c.call("dev_start", json!({"room_bin": d.join("no-such-program")})).unwrap_err(); diff --git a/wai/wai-rs/tests/cli_ops.rs b/wai/wai-rs/tests/cli_ops.rs new file mode 100644 index 000000000..343be7e8e --- /dev/null +++ b/wai/wai-rs/tests/cli_ops.rs @@ -0,0 +1,494 @@ +//! The operation catalogue through the `wai` binary, as an agent and a person meet it +//! (`--features quickstart --test cli_ops`): +//! +//! - `wai ops list`, `search`, `schema` and `export` read the catalogue, and the export is +//! the committed `wai/catalogue/ops.json` (a drifted copy exits 1); +//! - every operation answers `--help` from its catalogue entry, and its mechanical form +//! `wai ` runs it as its CLI spelling does; +//! - the exit codes: 1 a check that failed, 2 usage, 3 permission, 4 confirmation required, +//! 5 a file, an operation or a build feature not found, 6 a target that did not come up; +//! - `--json`: one envelope (`envelope/1`) on stdout, success and failure, with a command's +//! own JSON or its text in `data`, and with a confirmation's proposal and command; +//! - `--dry-run`: every operation of this build whose effect is not `reads` writes nothing +//! with it, and (for those that write) writes without it, so the dry run is of a command +//! that would write; +//! - `--yes`: replacing a file the command line did not name waits for it; `--confirm` is its +//! deprecated spelling, does the same, and help and the export say so. +#![cfg(feature = "quickstart")] + +use serde_json::Value; +use std::io::{BufRead, BufReader}; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output, Stdio}; +use wai::catalogue::{self, Confirm, Effect, OPS}; + +fn wai_in(dir: &Path, args: &[&str]) -> Output { + Command::new(env!("CARGO_BIN_EXE_wai")).current_dir(dir).args(args).stdin(Stdio::null()).output().expect("wai runs") +} + +fn text(o: &Output) -> String { + format!("{}{}", String::from_utf8_lossy(&o.stdout), String::from_utf8_lossy(&o.stderr)) +} + +fn scratch(name: &str) -> PathBuf { + let d = Path::new(env!("CARGO_TARGET_TMPDIR")).join("cli_ops").join(name); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + d +} + +fn wai_dir() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("..") +} + +/// The one JSON envelope a `--json` run prints. +fn envelope(o: &Output) -> Value { + let v: Value = serde_json::from_slice(&o.stdout).unwrap_or_else(|e| panic!("not one JSON envelope ({e}): {}", text(o))); + assert_eq!(v["wai"], catalogue::ENVELOPE, "{v}"); + v +} + +#[test] +fn ops_list_names_every_operation_and_export_is_the_committed_file() { + let d = scratch("list"); + let o = wai_in(&d, &["ops", "list", "--json"]); + assert!(o.status.success(), "{}", text(&o)); + let v = envelope(&o); + assert_eq!((v["ok"].as_bool(), v["op"].as_str()), (Some(true), Some("ops.list"))); + let ids: Vec<&str> = v["data"].as_array().unwrap().iter().map(|r| r["id"].as_str().unwrap()).collect(); + assert_eq!(ids, OPS.iter().map(|o| o.id).collect::>()); + let plain = wai_in(&d, &["ops", "list"]); + for op in OPS { + assert!(String::from_utf8_lossy(&plain.stdout).contains(op.id), "{}", op.id); + } + + let committed = wai_dir().join("catalogue/ops.json"); + let o = wai_in(&d, &["ops", "export", "--check", committed.to_str().unwrap()]); + assert!(o.status.success(), "wai/catalogue/ops.json is the catalogue's export: {}", text(&o)); + let o = wai_in(&d, &["ops", "export"]); + assert_eq!(o.stdout, std::fs::read(&committed).unwrap(), "`wai ops export` prints the committed export"); + // A drifted copy is a check that failed. + let drifted = d.join("ops.json"); + std::fs::write(&drifted, String::from_utf8_lossy(&o.stdout).replacen("envelope.inspect", "envelope.inspekt", 1)).unwrap(); + let o = wai_in(&d, &["ops", "export", "--check", drifted.to_str().unwrap()]); + assert_eq!(o.status.code(), Some(1), "{}", text(&o)); + assert!(text(&o).contains("regenerate"), "{}", text(&o)); +} + +#[test] +fn ops_search_and_schema_answer_offline() { + let d = scratch("search"); + let o = wai_in(&d, &["ops", "search", "verify", "the", "receipts", "--json"]); + let v = envelope(&o); + let hits = v["data"].as_array().unwrap(); + assert!(!hits.is_empty() && hits.len() <= 5, "{v}"); + assert_eq!(hits[0]["id"], "receipt.verify", "{v}"); + let o = wai_in(&d, &["ops", "search", "make a new project"]); + assert!(String::from_utf8_lossy(&o.stdout).lines().next().unwrap_or("").starts_with("project.create"), "{}", text(&o)); + + let o = wai_in(&d, &["ops", "schema", "envelope.wrap"]); + assert!(o.status.success(), "{}", text(&o)); + let s: Value = serde_json::from_slice(&o.stdout).unwrap(); + assert_eq!((s["id"].as_str(), s["effect"].as_str(), s["dry_run"].as_bool()), (Some("envelope.wrap"), Some("writes-project"), Some(true))); + assert!(s["input"]["required"].as_array().unwrap().iter().any(|r| r == "capability"), "{s}"); + let o = wai_in(&d, &["ops", "schema", "envelope.frob", "--json"]); + assert_eq!(o.status.code(), Some(5), "{}", text(&o)); + assert_eq!(envelope(&o)["error"]["code"], "operation-not-found"); +} + +#[test] +fn every_operation_answers_help_from_its_entry_and_its_mechanical_form() { + let d = scratch("help"); + for op in OPS.iter().filter(|o| o.cli.is_some()) { + let cmd = op.command(); + let mut words: Vec<&str> = cmd.split_whitespace().skip(1).collect(); + words.push("--help"); + let o = wai_in(&d, &words); + assert!(o.status.success(), "{cmd} --help: {}", text(&o)); + let t = String::from_utf8_lossy(&o.stdout); + assert!(t.contains(op.id) && t.contains(op.summary), "{cmd} --help: {t}"); + let (n, v) = op.parts(); + let o = wai_in(&d, &[n, v, "--help"]); + assert!(o.status.success() && String::from_utf8_lossy(&o.stdout).contains(op.id), "wai {n} {v} --help: {}", text(&o)); + } + let o = wai_in(&d, &["--help"]); + assert!(o.status.success()); + assert!(String::from_utf8_lossy(&o.stdout).contains("sends nothing off your machine unless you name a remote service")); + // The mechanical form runs the operation. + let o = wai_in(&d, &["registry", "list"]); + let p = wai_in(&d, &["capabilities"]); + assert!(o.status.success() && o.stdout == p.stdout, "{}", text(&o)); +} + +#[test] +fn the_exit_codes_say_what_happened() { + let d = scratch("exits"); + // 2: usage. + assert_eq!(wai_in(&d, &[]).status.code(), Some(2)); + assert_eq!(wai_in(&d, &["inspect"]).status.code(), Some(2)); + assert_eq!(wai_in(&d, &["create", "x", "--frob"]).status.code(), Some(2)); + // 5: a file, an operation, a build that does not hold the operation. + assert_eq!(wai_in(&d, &["inspect", "no-such.wai"]).status.code(), Some(5)); + let o = wai_in(&d, &["frobnicate", "--json"]); + assert_eq!(o.status.code(), Some(5), "{}", text(&o)); + let v = envelope(&o); + assert_eq!((v["ok"].as_bool(), v["error"]["code"].as_str()), (Some(false), Some("operation-not-found")), "{v}"); + #[cfg(not(feature = "generate"))] + { + let o = wai_in(&d, &["generate", "--model", "m.json"]); + assert_eq!(o.status.code(), Some(5), "a build without `generate` does not hold media.generate: {}", text(&o)); + assert!(text(&o).contains("needs a build with the feature generate"), "{}", text(&o)); + } + // 1: a check that failed (a receipt whose object changed), with its report in data. + assert!(wai_in(&d, &["hello", "h"]).status.success()); + let obj = std::fs::read_dir(d.join("h")).unwrap().flatten().map(|e| e.path()).find(|p| p.extension().is_some_and(|x| x == "wai")).unwrap(); + let mut b = std::fs::read(&obj).unwrap(); + let n = b.len(); + b[n - 1] ^= 1; + std::fs::write(&obj, &b).unwrap(); + let o = wai_in(&d, &["verify", "h", "--json"]); + assert_eq!(o.status.code(), Some(1), "{}", text(&o)); + let v = envelope(&o); + assert_eq!((v["ok"].as_bool(), v["error"]["code"].as_str()), (Some(false), Some("check-failed")), "{v}"); + assert!(v["data"]["text"].as_str().unwrap().contains("FAIL"), "{v}"); + // 3: a file this user may not read (where the user is not root, who reads anything). + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let locked = d.join("locked.wai"); + std::fs::write(&locked, b"WAI1").unwrap(); + std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o000)).unwrap(); + if std::fs::read(&locked).is_err() { + assert_eq!(wai_in(&d, &["inspect", "locked.wai"]).status.code(), Some(3)); + } + std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o644)).unwrap(); + } +} + +#[cfg(unix)] +#[test] +fn a_local_stack_that_does_not_come_up_is_unavailable_exit_6() { + use std::os::unix::fs::PermissionsExt; + let d = scratch("unavailable"); + let fail = d.join("fails.sh"); + std::fs::write(&fail, "#!/bin/sh\nexit 1\n").unwrap(); + std::fs::set_permissions(&fail, std::fs::Permissions::from_mode(0o755)).unwrap(); + let web = d.join("web"); + std::fs::create_dir_all(web.join("room")).unwrap(); + std::fs::write(web.join("room/index.html"), "").unwrap(); + let f = fail.to_str().unwrap(); + let o = wai_in(&d, &["dev", "--port", "0", "--relay", f, "--room", f, "--web", web.to_str().unwrap(), "--check", "--json"]); + assert_eq!(o.status.code(), Some(6), "{}", text(&o)); + let v = envelope(&o); + assert_eq!((v["error"]["code"].as_str(), v["error"]["retryable"].as_bool()), (Some("target-unavailable"), Some(true)), "{v}"); +} + +#[test] +fn replacing_a_file_the_command_line_did_not_name_waits_for_yes() { + let d = scratch("confirm"); + std::fs::write(d.join("p.bin"), b"hello").unwrap(); + let o = wai_in(&d, &["wrap", "wai.text.zstd", "p.bin"]); + assert!(o.status.success(), "{}", text(&o)); + let first = std::fs::read(d.join("out.wai")).unwrap(); + std::fs::write(d.join("p.bin"), b"hello, again").unwrap(); + // Off a terminal, without --yes: exit 4, nothing changed, the proposal and the command. + let o = wai_in(&d, &["wrap", "wai.text.zstd", "p.bin", "--json"]); + assert_eq!(o.status.code(), Some(4), "{}", text(&o)); + let v = envelope(&o); + assert_eq!(v["error"]["code"], "confirmation-required", "{v}"); + assert_eq!(v["proposed"][0]["replace"], "out.wai", "{v}"); + assert_eq!(v["confirm"], "wai wrap wai.text.zstd p.bin --yes", "{v}"); + assert_eq!(std::fs::read(d.join("out.wai")).unwrap(), first, "nothing changed"); + let o = wai_in(&d, &["wrap", "wai.text.zstd", "p.bin"]); + assert_eq!(o.status.code(), Some(4), "{}", text(&o)); + assert!(text(&o).contains("to go ahead: wai wrap wai.text.zstd p.bin --yes"), "{}", text(&o)); + // With --yes it goes ahead; a named output never waits. + assert!(wai_in(&d, &["wrap", "wai.text.zstd", "p.bin", "--yes"]).status.success()); + assert_ne!(std::fs::read(d.join("out.wai")).unwrap(), first); + assert!(wai_in(&d, &["wrap", "wai.text.zstd", "p.bin", "-o", "out.wai"]).status.success()); + // `wai hello` over an earlier run's files waits too. + assert!(wai_in(&d, &["hello", "h"]).status.success()); + assert_eq!(wai_in(&d, &["hello", "h"]).status.code(), Some(4)); + assert!(wai_in(&d, &["hello", "h", "--yes"]).status.success()); +} + +#[test] +fn confirm_is_the_deprecated_spelling_of_yes() { + let d = scratch("confirm-alias"); + std::fs::write(d.join("p.bin"), b"hello").unwrap(); + assert!(wai_in(&d, &["wrap", "wai.text.zstd", "p.bin"]).status.success()); + let first = std::fs::read(d.join("out.wai")).unwrap(); + std::fs::write(d.join("p.bin"), b"hello, again").unwrap(); + // Without either: exit 4, and the confirming command names --yes. + let o = wai_in(&d, &["wrap", "wai.text.zstd", "p.bin", "--json"]); + assert_eq!(o.status.code(), Some(4), "{}", text(&o)); + assert_eq!(envelope(&o)["confirm"], "wai wrap wai.text.zstd p.bin --yes"); + assert_eq!(std::fs::read(d.join("out.wai")).unwrap(), first, "nothing changed"); + // --confirm alone goes ahead as --yes does, wherever it is on the line. + let o = wai_in(&d, &["wrap", "--confirm", "wai.text.zstd", "p.bin"]); + assert!(o.status.success(), "--confirm is --yes: {}", text(&o)); + let replaced = std::fs::read(d.join("out.wai")).unwrap(); + assert_ne!(replaced, first); + assert!(wai_in(&d, &["wrap", "wai.text.zstd", "p.bin", "--yes"]).status.success()); + assert_eq!(std::fs::read(d.join("out.wai")).unwrap(), replaced, "the same change"); + // The help names it deprecated, on its own line and in every operation that confirms. + let h = String::from_utf8_lossy(&wai_in(&d, &["--help"]).stdout).into_owned(); + assert!(h.lines().any(|l| l.trim_start().starts_with("--confirm") && l.contains("deprecated") && l.contains("--yes")), "{h}"); + for op in OPS.iter().filter(|o| o.cli.is_some() && o.confirms() != Confirm::Never) { + let cmd = op.command(); + let words: Vec<&str> = cmd.split_whitespace().skip(1).chain(["--help"]).collect(); + let t = String::from_utf8_lossy(&wai_in(&d, &words).stdout).into_owned(); + assert!(t.contains("--yes") && t.contains("--confirm, deprecated"), "{} --help: {t}", op.id); + } + // The export says so, for agents. + let e: Value = serde_json::from_slice(&wai_in(&d, &["ops", "export"]).stdout).unwrap(); + let yes = e["agent_flags"].as_array().unwrap().iter().find(|f| f["flag"] == "--yes").unwrap().clone(); + assert_eq!((yes["aliases"][0]["flag"].as_str(), yes["aliases"][0]["deprecated"]["use_instead"].as_str()), (Some("--confirm"), Some("--yes")), "{yes}"); +} + +#[test] +fn a_text_command_carries_its_output_in_the_envelope() { + let d = scratch("envelope"); + let o = wai_in(&d, &["capabilities", "--json"]); + assert!(o.status.success(), "{}", text(&o)); + let v = envelope(&o); + assert_eq!((v["op"].as_str(), v["ok"].as_bool()), (Some("registry.list"), Some(true))); + assert!(v["data"]["text"].as_str().unwrap().contains("wai.image.png"), "{v}"); + let o = wai_in(&d, &["inspect", "no-such.wai", "--json"]); + let v = envelope(&o); + assert_eq!((v["ok"].as_bool(), v["error"]["code"].as_str()), (Some(false), Some("not-found")), "{v}"); + assert!(v.get("data").is_none(), "{v}"); +} + +// ─────────────────────────────── --dry-run ─────────────────────────────── + +/// Every file under `d`, with its length and modification time. +fn snapshot(d: &Path) -> Vec<(String, u64, std::time::SystemTime)> { + fn walk(root: &Path, p: &Path, out: &mut Vec<(String, u64, std::time::SystemTime)>) { + for e in std::fs::read_dir(p).unwrap().flatten() { + let q = e.path(); + let m = e.metadata().unwrap(); + out.push((q.strip_prefix(root).unwrap().display().to_string(), m.len(), m.modified().unwrap())); + if m.is_dir() { + walk(root, &q, out); + } + } + } + let mut v = Vec::new(); + walk(d, d, &mut v); + v.sort(); + v +} + +fn corpus_object(id: &str) -> PathBuf { + let c = wai_dir().join("http-conformance"); + let listing: Value = serde_json::from_slice(&std::fs::read(c.join("objects.json")).unwrap()).unwrap(); + c.join(listing[id]["path"].as_str().unwrap()) +} + +/// A running `wai http origin` over `root`, stopped when dropped. +struct Origin(std::process::Child, u16); + +impl Origin { + fn start(root: &Path) -> Origin { + let mut child = Command::new(env!("CARGO_BIN_EXE_wai")) + .args(["http", "origin", "--root", root.to_str().unwrap(), "--port", "0"]) + .stdout(Stdio::piped()) + .spawn() + .unwrap(); + let mut first = String::new(); + BufReader::new(child.stdout.take().unwrap()).read_line(&mut first).unwrap(); + let port = first.trim().rsplit(':').next().unwrap().trim_end_matches('/').parse().unwrap(); + Origin(child, port) + } +} + +impl Drop for Origin { + fn drop(&mut self) { + let _ = self.0.kill(); + let _ = self.0.wait(); + } +} + +/// A fleet service that answers one request with `body` (status 200), as `wai-fleet` would; +/// its `http://` address. +fn fleet_service(body: Value) -> String { + use std::io::{Read, Write}; + let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = format!("http://{}", l.local_addr().unwrap()); + std::thread::spawn(move || { + if let Ok((mut s, _)) = l.accept() { + let mut buf = [0u8; 4096]; + let _ = s.read(&mut buf); + let b = body.to_string(); + let _ = s.write_all(format!("HTTP/1.1 200 X\r\ncontent-type: application/json\r\ncontent-length: {}\r\n\r\n{b}", b.len()).as_bytes()); + } + }); + addr +} + +/// A command line that writes, for each operation of this build whose effect is not +/// `reads`, with what it needs made in `d`: `(id, args, whether it writes without +/// --dry-run)`. An operation that runs something, or changes only a service, writes nothing +/// of the project's. +fn samples(d: &Path, origin_port: u16) -> Vec<(&'static str, Vec, bool)> { + let s = |v: &[&str]| v.iter().map(|x| x.to_string()).collect::>(); + std::fs::write(d.join("in.txt"), b"a payload to carry").unwrap(); + assert!(wai_in(d, &["wrap", "wai.text.zstd", "in.txt", "-o", "w.wai"]).status.success()); + assert!(wai_in(d, &["encode", "zstd", "in.txt", "-o", "z.wai"]).status.success()); + let reg = d.join("regroot"); + std::fs::create_dir_all(®).unwrap(); + std::fs::copy(wai_dir().join("REGISTRY.md"), reg.join("REGISTRY.md")).unwrap(); + let objs: Vec = ["O9a", "O9b", "O9c"].iter().map(|id| corpus_object(id).to_str().unwrap().to_string()).collect(); + std::fs::write(d.join("classical.m3u8"), "#EXTM3U\n#EXT-X-VERSION:7\n#EXT-X-TARGETDURATION:1\n#EXT-X-MEDIA-SEQUENCE:0\n#EXTINF:0.640,\nc0.m4s\n#EXTINF:0.640,\nc1.m4s\n#EXTINF:0.640,\nc2.m4s\n#EXT-X-ENDLIST\n").unwrap(); + let list = serde_json::json!({ + "uri_prefix": "objects/", + "variants": [{ "uri": "wai-180.m3u8", "classical": "classical.m3u8", "resolution": [320, 180], "frame_rate_milli": 25000, + "segments": objs.iter().map(|o| serde_json::json!({ "object": o })).collect::>() }], + }); + std::fs::write(d.join("list.json"), serde_json::to_vec_pretty(&list).unwrap()).unwrap(); + let od = corpus_object("OD"); + let cid = wai::http_binding::hex(&wai::http_binding::content_hash(&std::fs::read(&od).unwrap())); + let web = d.join("web"); + std::fs::create_dir_all(web.join("room")).unwrap(); + std::fs::write(web.join("room/index.html"), "").unwrap(); + let me = env!("CARGO_BIN_EXE_wai"); + std::fs::create_dir_all(d.join("agentproj")).unwrap(); + #[allow(unused_mut)] + let mut more: Vec<(&'static str, Vec, bool)> = Vec::new(); + #[cfg(feature = "video_manifest")] + { + let sd = d.join("sealdir"); + std::fs::create_dir_all(&sd).unwrap(); + std::fs::copy(d.join("classical.m3u8"), sd.join("classical.m3u8")).unwrap(); + std::fs::copy(d.join("list.json"), sd.join("list.json")).unwrap(); + let o = wai_in(&sd, &["http", "hls", "--objects", "list.json", "--out", "."]); + assert!(o.status.success(), "{}", text(&o)); + more.push(("playlist.lock", s(&["http", "seal-playlist", "sealdir/wai-180.m3u8", "--key", &"07".repeat(32), "--out", "sealed"]), true)); + } + #[cfg(feature = "app")] + { + // A Create runtime as wai-app's runtime.mjs lays one out, and a program made with Create. + use sha2::{Digest, Sha256}; + let rt = d.join("runtime"); + let mut listed = Vec::new(); + for (p, text) in [("create/wai-app-app.js", "export {};\n"), ("create/pkg/wai_app_bg.wasm", "\0asm"), ("room/wai-app-room-client.js", "// the room client\n")] { + let f = rt.join(p); + std::fs::create_dir_all(f.parent().unwrap()).unwrap(); + std::fs::write(&f, text).unwrap(); + let h: String = Sha256::digest(text.as_bytes()).iter().map(|x| format!("{x:02x}")).collect(); + listed.push(serde_json::json!({"path": p, "sha256": h})); + } + std::fs::write(rt.join("create/wai-app-runtime.json"), serde_json::json!({"files": listed}).to_string()).unwrap(); + std::fs::write(d.join("app.oui"), "root = App([show], \"Friday night\")\nshow = Show([player, chat], \"Live\", 2)\nplayer = Player()\nchat = Chat(\"Talk\")\n").unwrap(); + more.push(("app.export", s(&["app", "export", "app.oui", "appproj", "--runtime", "runtime"]), true)); + } + #[cfg(feature = "fleet")] + { + // A node's enrolment written here; a report's bundle exported from a fleet service (the + // corpus's week, which this build verifies before it writes); and a report the service + // would sign, which a dry run does not ask for. + std::fs::write(d.join("org.key"), "07".repeat(32)).unwrap(); + let corpus: Value = serde_json::from_slice(&std::fs::read(wai_dir().join("live-conformance/fleet/vectors.json")).unwrap()).unwrap(); + let week = corpus["vectors"].as_array().unwrap().iter().find(|v| v["name"] == "a_week").unwrap().clone(); + let digest = week["outcome"]["digest"].as_str().unwrap().to_string(); + let url = fleet_service(serde_json::json!({"report": digest, "outcome": week["outcome"], "bundle": week["bundle"]})); + more.push(("enrolment.create", s(&["enrolment", "create", &"11".repeat(32), "--role", "relay", "--key", "org.key", "--out", "node.wfe"]), true)); + more.push(("report.export", s(&["report", "export", &digest, "--target", &url, "--out", "week.wai-fleet.json"]), true)); + more.push(("report.create", s(&["report", "create", "--target", "http://127.0.0.1:9", "--start", "0", "--end", "1"]), false)); + } + #[cfg(feature = "generate")] + { + let model = wai_dir().join("wai-rs/tests/vectors/generate/generate_spectral_model.json"); + more.push(("media.generate", s(&["generate", "--model", model.to_str().unwrap(), "--prompt", "a calm spectral horizon", "--seed", "7", "--steps", "1", "--size", "16x16", "-o", "g.rgb"]), true)); + } + #[cfg(feature = "score")] + { + let notes = wai_dir().join("score-conformance/notes/scale.json"); + let notes = notes.to_str().unwrap(); + assert!(wai_in(d, &["score", "build", notes, "-o", "s0.wscr"]).status.success()); + more.push(("score.build", s(&["score", "build", notes, "-o", "s.wscr"]), true)); + more.push(("score.decode", s(&["score", "render", "s0.wscr", "--pcm", "s.s16"]), true)); + } + #[cfg(all(feature = "staged", feature = "video_int", feature = "audio_int", feature = "splat_int"))] + { + let frame: Vec = (0..6 * 10 * 3).map(|i| ((i * 13) % 256) as u8).collect(); + std::fs::write(d.join("f.rgb"), &frame).unwrap(); + for args in [ + &["stage", "base", "--frames", "f.rgb", "--dims", "1,6,10", "--q", "40", "-o", "b0.wai"][..], + &["stage", "refine", "--base", "b0.wai", "--span", "0:1", "--target", "f.rgb", "--q", "1", "-o", "l0.wai"], + &["stage", "pack", "b0.wai", "l0.wai", "-o", "x0.wai"], + ] { + let o = wai_in(d, args); + assert!(o.status.success(), "{args:?}: {}", text(&o)); + } + more.push(("stage.build", s(&["stage", "base", "--frames", "f.rgb", "--dims", "1,6,10", "--q", "40", "-o", "b.wai"]), true)); + more.push(("stage.encode", s(&["stage", "refine", "--base", "b0.wai", "--span", "0:1", "--target", "f.rgb", "--q", "1", "-o", "l.wai"]), true)); + more.push(("stage.wrap", s(&["stage", "pack", "b0.wai", "l0.wai", "-o", "x.wai"]), true)); + more.push(("stage.unwrap", s(&["stage", "explode", "x0.wai", "0", "-o", "e.wai"]), true)); + } + let mut v = vec![ + ("toolchain.test", s(&["hello", "hello-out"]), true), + ("project.create", s(&["create", "proj", "--template", "video-room"]), true), + ("envelope.wrap", s(&["wrap", "wai.text.zstd", "in.txt", "-o", "w2.wai"]), true), + ("envelope.unwrap", s(&["unwrap", "w.wai", "-o", "payload.out"]), true), + ("media.encode", s(&["encode", "zstd", "in.txt", "-o", "e.wai"]), true), + ("media.decode", s(&["decode", "z.wai", "-o", "decoded.txt"]), true), + ("registry.export", s(&["registry", "export", "--root", reg.to_str().unwrap()]), true), + ("ops.export", s(&["ops", "export", "--out", "ops-out.json"]), true), + ("http.export", s(&["http", "publish", corpus_object("O3").to_str().unwrap(), "--out", "pub"]), true), + ("hls.build", s(&["http", "hls", "--objects", "list.json", "--out", "."]), true), + ("dash.build", s(&["http", "mpd", "--objects", "list.json", "--out", "wai.mpd"]), true), + ("corpus.generate", s(&["http", "vectors", "diff", ".", "--seed", "1", "--count", "1", "--out", "diff.bin"]), true), + ("http.load", s(&["http", "bench", "--url", &format!("http://127.0.0.1:{origin_port}/{cid}.wai#h=1009&n=53794"), "--runs", "1", "--advertise", "wai.video.av1", "--out", "bench.json"]), true), + ("dev.start", s(&["dev", "--port", "0", "--relay", me, "--room", me, "--web", web.to_str().unwrap()]), false), + ("http.start", s(&["http", "origin", "--root", ".", "--port", "0"]), false), + ("conformance.run", s(&["conform", "verifier", "--suite", "jwp", "--impl", &format!("'{}' verdicts {{corpus}}", env!("CARGO_BIN_EXE_wai_jwp_vectors")), "--root", wai_dir().to_str().unwrap(), "--out", "jwp.wai-conform.json"]), true), + ("agent.setup", s(&["setup-agent", "--dir", "agentproj"]), true), + ("mcp.start", s(&["mcp", "--project", "."]), false), + ]; + v.extend(more); + v +} + +#[test] +fn every_operation_that_is_not_read_only_writes_nothing_with_dry_run() { + let root = scratch("dry-run"); + let served = root.join("served"); + std::fs::create_dir_all(&served).unwrap(); + assert!(wai_in(&served, &["http", "publish", corpus_object("OD").to_str().unwrap(), "--out", "."]).status.success()); + let origin = Origin::start(&served); + let work = root.join("work"); + std::fs::create_dir_all(&work).unwrap(); + let table = samples(&work, origin.1); + // Every such operation this build holds has a sample: a new one without --dry-run + // support cannot join the catalogue unnoticed. + for op in OPS.iter().filter(|o| o.cli.is_some() && o.effect != Effect::Reads && o.available()) { + assert!(table.iter().any(|(id, _, _)| *id == op.id), "{} is not read-only and has no --dry-run sample here", op.id); + } + for (id, args, writes) in &table { + let before = snapshot(&work); + let mut dry: Vec<&str> = args.iter().map(String::as_str).collect(); + dry.push("--dry-run"); + let o = wai_in(&work, &dry); + assert!(o.status.success(), "{id} --dry-run: {}", text(&o)); + assert_eq!(snapshot(&work), before, "{id} --dry-run wrote: {}", text(&o)); + let t = text(&o); + assert!(t.contains("dry run") || t.contains("dry-run"), "{id} --dry-run says so: {t}"); + // The same with --json: the envelope says it was a dry run, and lists the plan. + dry.push("--json"); + let o = wai_in(&work, &dry); + assert_eq!(snapshot(&work), before, "{id} --dry-run --json wrote: {}", text(&o)); + if *writes { + let v = envelope(&o); + assert_eq!((v["ok"].as_bool(), v["dry_run"].as_bool()), (Some(true), Some(true)), "{id}: {v}"); + assert!(!v["plan"].as_array().unwrap().is_empty(), "{id}: the plan names what it would write: {v}"); + // Without --dry-run the same command writes: the dry run was of a write. + let refs: Vec<&str> = args.iter().map(String::as_str).collect(); + let o = wai_in(&work, &refs); + assert!(o.status.success(), "{id}: {}", text(&o)); + assert_ne!(snapshot(&work), before, "{id} without --dry-run writes"); + } + } + drop(origin); +} diff --git a/wai/wai-rs/tests/cli_setup_agent.rs b/wai/wai-rs/tests/cli_setup_agent.rs index bbc21d144..59efe748b 100644 --- a/wai/wai-rs/tests/cli_setup_agent.rs +++ b/wai/wai-rs/tests/cli_setup_agent.rs @@ -1,13 +1,13 @@ -//! `wai setup-agent` in a scratch project, run as an agent runs it (no terminal, so its output -//! is the toolchain's JSON envelope, `envelope/1`, and it asks nothing): +//! `wai setup-agent` in a scratch project, run as an agent runs it (no terminal and `--json`, so +//! its output is the toolchain's JSON envelope, `envelope/1`, and it asks nothing): //! //! - a fresh project gets AGENTS.md and `.mcp.json`, and the configuration files of the agents //! whose directories it holds, and no CLAUDE.md; a second run changes nothing; //! - a CLAUDE.md the project has gains an import of AGENTS.md; -//! - a file that exists is changed only with `--confirm`: without it nothing that exists is -//! written, the command exits 4 (confirmation required) with each proposed change and the -//! command that confirms; with it the block is added and everything else in the file is kept; -//! `--dry-run` writes nothing; +//! - a file that exists is changed only with `--yes` (or `--confirm`): without it nothing that +//! exists is written, the command exits 4 (confirmation required) with each proposed change and +//! the command that confirms; with it the block is added and everything else in the file is +//! kept; `--dry-run` writes nothing; //! - `--toolset` registers the server with those toolsets; //! - a JSON file it cannot read as JSON is left alone, with the entry to add by hand; //! - nothing outside the project directory is touched, through a link or otherwise; @@ -26,15 +26,15 @@ fn scratch(tag: &str) -> PathBuf { /// Run it in `dir`: its exit status and its JSON. fn run(dir: &Path, extra: &[&str]) -> (i32, Value) { - let out = Command::new(env!("CARGO_BIN_EXE_wai")).arg("setup-agent").arg("--dir").arg(dir).args(extra).output().expect("run wai"); + let out = Command::new(env!("CARGO_BIN_EXE_wai")).arg("setup-agent").arg("--dir").arg(dir).args(extra).arg("--json").output().expect("run wai"); let text = String::from_utf8_lossy(&out.stdout); - let v: Value = serde_json::from_str(text.trim()).unwrap_or_else(|e| panic!("JSON when not on a terminal: {e}: {text}")); + let v: Value = serde_json::from_str(text.trim()).unwrap_or_else(|e| panic!("one JSON envelope with --json: {e}: {text}")); (out.status.code().unwrap(), v) } fn files(v: &Value) -> &Vec { assert_eq!((v["wai"].as_str(), v["op"].as_str()), (Some("envelope/1"), Some("agent.setup")), "the toolchain's envelope: {v}"); - v["result"]["files"].as_array().unwrap() + v["data"]["files"].as_array().unwrap() } fn action(v: &Value, path: &str) -> String { @@ -107,9 +107,9 @@ fn an_existing_file_changes_only_when_confirmed() { let diff = proposed.iter().find(|f| f["path"] == "AGENTS.md").unwrap()["diff"].as_str().unwrap().to_string(); assert!(diff.starts_with("--- a/AGENTS.md\n+++ b/AGENTS.md\n") && diff.contains("+## WAI"), "{diff}"); let confirm = v["confirm"].as_str().unwrap(); - assert!(confirm.ends_with("--confirm") && confirm.contains(d.to_str().unwrap()), "{confirm}"); + assert!(confirm.ends_with("--yes") && confirm.contains(d.to_str().unwrap()), "{confirm}"); // With it, the block is added and the rest kept. - let (code, v) = run(&d, &["--confirm"]); + let (code, v) = run(&d, &["--yes"]); assert_eq!(code, 0, "{v}"); assert_eq!(action(&v, "AGENTS.md"), "updated"); let c = read(d.join("CLAUDE.md")); diff --git a/wai/wai-rs/tests/cli_stage.rs b/wai/wai-rs/tests/cli_stage.rs index ae14fb4ed..654d822df 100644 --- a/wai/wai-rs/tests/cli_stage.rs +++ b/wai/wai-rs/tests/cli_stage.rs @@ -34,6 +34,14 @@ fn text(o: &Output) -> String { format!("{}{}", String::from_utf8_lossy(&o.stdout), String::from_utf8_lossy(&o.stderr)) } +/// The `data` of the JSON envelope (envelope/1) that `wai ... --json` prints: `wai stage +/// apply`'s trace. +fn envelope_data(stdout: &[u8]) -> serde_json::Value { + let env: serde_json::Value = serde_json::from_slice(stdout).expect("one JSON envelope"); + assert_eq!((env["wai"].as_str(), env["op"].as_str()), (Some("envelope/1"), Some("stage.decode")), "{env}"); + env["data"].clone() +} + fn case(name: &str) -> serde_json::Value { serde_json::from_slice(&std::fs::read(corpus(&format!("cases/{name}/case.json"))).unwrap()).unwrap() } @@ -201,7 +209,7 @@ fn base_refine_apply_json_trace() { args.extend(layers.iter().rev().map(|p| p.as_os_str())); let o = wai(&args); assert!(o.status.success(), "{}", text(&o)); - let doc: serde_json::Value = serde_json::from_slice(&o.stdout).expect("one JSON document"); + let doc = envelope_data(&o.stdout); assert_eq!(doc["format"], "wai-stage-apply/1"); let steps = doc["steps"].as_array().unwrap(); assert_eq!(steps.len(), 1 + n); @@ -246,7 +254,7 @@ fn apply_holds_layers_for_a_later_base() { args.extend(files.iter().map(|p| p.as_os_str())); let o = wai(&args); assert!(o.status.success(), "{}", text(&o)); - let doc: serde_json::Value = serde_json::from_slice(&o.stdout).unwrap(); + let doc = envelope_data(&o.stdout); let steps = doc["steps"].as_array().unwrap(); let held: usize = steps.iter().filter(|s| s["reports"].as_array().is_some_and(|r| r.iter().any(|r| r["held_for_base"] == true))).count(); assert_eq!(held, 10, "GOPs 1 and 2's layers wait for their bases"); @@ -367,7 +375,7 @@ fn apply_json_replays_the_corpus() { }); if let Some(i) = routed { let o = wai(&[os("stage"), os("apply"), os("--json"), d.join("base.wai").as_os_str(), d.join(steps[i]["file"].as_str().unwrap()).as_os_str()]); - let got: serde_json::Value = serde_json::from_slice(&o.stdout).unwrap_or_default(); + let got = serde_json::from_slice::(&o.stdout).map(|e| e["data"].clone()).unwrap_or_default(); if got["steps"][1]["op"] != "base" { differ.push(format!("{name}: the tool does not route the envelope offered at step {i} as a base: {}", text(&o))); } @@ -408,7 +416,7 @@ fn apply_json_replays_the_corpus() { differ.push(format!("{name}: the tool fails: {}", text(&o))); continue; } - let doc: serde_json::Value = serde_json::from_slice(&o.stdout).unwrap(); + let doc = envelope_data(&o.stdout); // A claimed base's chain says so (--stage0); the mark is set aside to compare, // and must be on every report of that chain. let got: Vec = doc["steps"].as_array().unwrap().iter() @@ -529,7 +537,7 @@ fn apply_stage0_marks_the_base_claimed() { let o = wai(&[os("stage"), os("apply"), os("--json"), os("--stage0"), jpath.as_os_str(), os("--units"), os("2"), os("--dims"), os("8,8,3"), d.join("base.wai").as_os_str(), d.join("L1.wai").as_os_str()]); assert!(o.status.success(), "{}", text(&o)); - let doc: serde_json::Value = serde_json::from_slice(&o.stdout).unwrap(); + let doc = envelope_data(&o.stdout); let steps = doc["steps"].as_array().unwrap(); assert_eq!(steps[0]["claimed"], true); let s0 = steps[0]["units"].as_array().unwrap(); @@ -546,7 +554,7 @@ fn apply_stage0_marks_the_base_claimed() { assert!(b["units"].as_array().unwrap().iter().all(|u| u["claimed"] == true)); // Without --stage0 the decoded base claims nothing. let o = wai(&[os("stage"), os("apply"), os("--json"), d.join("base.wai").as_os_str(), d.join("L1.wai").as_os_str()]); - let doc: serde_json::Value = serde_json::from_slice(&o.stdout).unwrap(); + let doc = envelope_data(&o.stdout); assert!(!o.stdout.windows(9).any(|w| w == b"\"claimed\""), "{doc}"); } @@ -572,7 +580,7 @@ fn apply_records_a_refused_base_and_goes_on() { let t = text(&o); assert!(o.status.success(), "{t}"); if json { - let doc: serde_json::Value = serde_json::from_slice(&o.stdout).unwrap(); + let doc = envelope_data(&o.stdout); let steps = doc["steps"].as_array().unwrap(); assert_eq!((steps[0]["op"].as_str(), steps[0]["error"].as_str()), (Some("base"), Some("encrypted"))); assert_eq!(steps[1]["error"], "missing_capability"); @@ -600,7 +608,7 @@ fn apply_holds_a_layer_to_the_stated_limit() { assert!(text(&o).contains("declared cost: 67092480 output bytes"), "the vector declares what its README states: {}", text(&o)); let o = wai(&[os("stage"), os("apply"), os("--json"), b.as_os_str(), l.as_os_str()]); assert!(o.status.success(), "{}", text(&o)); - let doc: serde_json::Value = serde_json::from_slice(&o.stdout).unwrap(); + let doc = envelope_data(&o.stdout); let r = &doc["steps"][1]["reports"][0]; assert_eq!((r["envelope"].as_str(), r["detail"].as_str()), (Some("cost-over-limit"), Some("output bytes")), "{r}"); assert!(r["applied"].as_array().unwrap().is_empty()); diff --git a/wai/wai-studio/src/actions.generated.js b/wai/wai-studio/src/actions.generated.js index 80ed1bd96..c2ce1b5a4 100644 --- a/wai/wai-studio/src/actions.generated.js +++ b/wai/wai-studio/src/actions.generated.js @@ -1,12 +1,12 @@ // GENERATED by scripts/actions.mjs from the WAI operation catalogue. Do not edit: change the // catalogue (wai-rs/src/catalogue.rs, exported by `wai ops export`), then run // `node scripts/actions.mjs`. `node scripts/actions.mjs --check` fails when this file differs. -export const CATALOGUE = {"format":"wai.ops/1","toolchain":"0.3.0","sha256":"4bfdd96bbb263f59d210b8fdb4b2704d250be30cdb35f930954d2f287ac84724"}; +export const CATALOGUE = {"format":"wai.ops/1","toolchain":"0.3.0","sha256":"7a7d097d4ca43e0054b685a94dab0dc56cc4e960d7cbd193b12bb8a841d9bf8c"}; export const ACTIONS = [ {"id":"toolchain.test","noun":"toolchain","verb":"test","menu":"Toolchain","item":"Test","palette":"toolchain test","summary":"WAI's four steps (name, dispatch, rebuild, receipt) on a picture made here, its receipt checked offline","placement":"local","effect":"writes-project","stability":"stable","toolset":"core","cli":"wai hello","mcp":null,"route":null,"call":"toolchain_test"}, {"id":"project.create","noun":"project","verb":"create","menu":"Project","item":"Create","palette":"project create","summary":"A new project from one of the examples, written into a new or empty directory; --list names the templates","placement":"local","effect":"writes-project","stability":"stable","toolset":"core","cli":"wai create","mcp":null,"route":null,"call":"project_create"}, {"id":"dev.start","noun":"dev","verb":"start","menu":"Dev","item":"Start","palette":"dev start","summary":"A relay, a room service and the room page on one origin on this machine, for development; --check starts, checks every piece and stops","placement":"local","effect":"runs","stability":"stable","toolset":"dev","cli":"wai dev","mcp":"dev_start","route":null,"call":"dev_start"}, - {"id":"receipt.verify","noun":"receipt","verb":"verify","menu":"Receipt","item":"Verify","palette":"receipt verify","summary":"Every receipt index (*.wai.receipts) under each path, its group receipts and claims checked offline","placement":"local","effect":"reads","stability":"stable","toolset":"core","cli":"wai verify","mcp":"receipt_verify","route":null,"call":"receipt_verify"}, + {"id":"receipt.verify","noun":"receipt","verb":"verify","menu":"Receipt","item":"Verify","palette":"receipt verify","summary":"Every receipt index (*.wai.receipts) under each path, its group receipts and claims checked offline, and every conformance result and fleet report bundle there","placement":"local","effect":"reads","stability":"stable","toolset":"core","cli":"wai verify","mcp":"receipt_verify","route":null,"call":"receipt_verify"}, {"id":"envelope.inspect","noun":"envelope","verb":"inspect","menu":"Envelope","item":"Inspect","palette":"envelope inspect","summary":"What an envelope holds and whether this build decodes it: media, intent, pin, capability, fallback, the dispatch verdict and the conformance checks","placement":"local","effect":"reads","stability":"stable","toolset":"core","cli":"wai inspect","mcp":"envelope_inspect","route":null,"call":"envelope_inspect"}, {"id":"envelope.wrap","noun":"envelope","verb":"wrap","menu":"Envelope","item":"Wrap","palette":"envelope wrap","summary":"Carry a registered capability's bytes in an envelope, byte for byte, under SPEC's intent, role and candidate rules","placement":"local","effect":"writes-project","stability":"stable","toolset":"core","cli":"wai wrap","mcp":"envelope_wrap","route":null,"call":"envelope_wrap"}, {"id":"envelope.unwrap","noun":"envelope","verb":"unwrap","menu":"Envelope","item":"Unwrap","palette":"envelope unwrap","summary":"An envelope's capability and its checks, and with -o its payload or one component, exactly as wrapped","placement":"local","effect":"writes-project","stability":"stable","toolset":"core","cli":"wai unwrap","mcp":null,"route":null,"call":"envelope_unwrap"}, @@ -39,6 +39,10 @@ export const ACTIONS = [ {"id":"compat.check","noun":"compat","verb":"check","menu":"Compat","item":"Check","palette":"compat check","summary":"What the working tree breaks against a git ref, by category (wire, record, api); a break passes once the CHANGELOG names it","placement":"local","effect":"reads","stability":"stable","toolset":"core","cli":"wai breaking","mcp":null,"route":null,"call":"compat_check"}, {"id":"conformance.run","noun":"conformance","verb":"run","menu":"Conformance","item":"Run","palette":"conformance run","summary":"WAI's conformance corpus run against another implementation, through a command or an HTTP adapter, its result a signed record wai verify checks","placement":"local","effect":"writes-project","stability":"stable","toolset":"core","cli":"wai conform","mcp":"conformance_run","route":null,"call":"conformance_run"}, {"id":"conformance.list","noun":"conformance","verb":"list","menu":"Conformance","item":"List","palette":"conformance list","summary":"The kinds and suites wai conform runs, each suite's corpus and its cases","placement":"local","effect":"reads","stability":"stable","toolset":"core","cli":"wai conformance list","mcp":"conformance_list","route":null,"call":"conformance_list"}, + {"id":"enrolment.create","noun":"enrolment","verb":"create","menu":"Enrolment","item":"Create","palette":"enrolment create","summary":"A node's enrolment signed with the organisation key, written to a file and, with --target, given to a fleet service","placement":"client","effect":"writes-target","stability":"preview","toolset":"core","cli":"wai enrolment create","mcp":null,"route":null,"call":"enrolment_create"}, + {"id":"enrolment.list","noun":"enrolment","verb":"list","menu":"Enrolment","item":"List","palette":"enrolment list","summary":"The nodes a fleet service holds enrolled, each with its role, label and how many records it gave","placement":"client","effect":"reads","stability":"preview","toolset":"core","cli":"wai enrolment list","mcp":null,"route":null,"call":"enrolment_list"}, + {"id":"report.create","noun":"report","verb":"create","menu":"Report","item":"Create","palette":"report create","summary":"The fleet report of a period, signed by a fleet service over the enrolments and records it holds","placement":"client","effect":"writes-target","stability":"preview","toolset":"core","cli":"wai report create","mcp":null,"route":null,"call":"report_create"}, + {"id":"report.export","noun":"report","verb":"export","menu":"Report","item":"Export","palette":"report export","summary":"A fleet report's bundle fetched from a fleet service, its report recomputed here, then written to a file","placement":"client","effect":"writes-project","stability":"preview","toolset":"core","cli":"wai report export","mcp":null,"route":null,"call":"report_export"}, {"id":"mcp.start","noun":"mcp","verb":"start","menu":"Mcp","item":"Start","palette":"mcp start","summary":"WAI's operations as tools for a coding agent: a Model Context Protocol server on standard input and output, in toolsets","placement":"local","effect":"runs","stability":"preview","toolset":"core","cli":"wai mcp","mcp":null,"route":null,"call":"mcp_start"}, {"id":"agent.setup","noun":"agent","verb":"setup","menu":"Agent","item":"Setup","palette":"agent setup","summary":"A project made ready for coding agents: AGENTS.md, an import of it in a CLAUDE.md already there, and wai mcp registered in its MCP configuration","placement":"local","effect":"writes-project","stability":"preview","toolset":"core","cli":"wai setup-agent","mcp":null,"route":null,"call":"agent_setup"}, {"id":"app.guide","noun":"app","verb":"guide","menu":"App","item":"Guide","palette":"app guide","summary":"The line language and every WAI widget, as a model writing a WAI app is told them","placement":"local","effect":"reads","stability":"preview","toolset":"app","cli":"wai app guide","mcp":"app_guide","route":null,"call":"app_guide"}, diff --git a/wai/wai-studio/src/bindings.js b/wai/wai-studio/src/bindings.js index ed2d69d04..e106f481c 100644 --- a/wai/wai-studio/src/bindings.js +++ b/wai/wai-studio/src/bindings.js @@ -44,6 +44,10 @@ export const BINDINGS = { // Placed `client`, run from the command line. 'http.load': { elsewhere: 'cli' }, 'receipt.fetch': { elsewhere: 'cli' }, + 'enrolment.create': { elsewhere: 'cli' }, + 'enrolment.list': { elsewhere: 'cli' }, + 'report.create': { elsewhere: 'cli' }, + 'report.export': { elsewhere: 'cli' }, }; /** How `id` runs on `host`, or null. */