wai: one operation catalogue (noun.verb) for every door, wai ops, the agent rules on every command, and the wai-* service binaries #506

Merged
dcharlot merged 26 commits from wai/ops-catalogue into main 2026-10-28 11:17:27 -04:00
104 changed files with 7736 additions and 1016 deletions

View file

@ -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.
#

136
.github/workflows/wai-catalogue.yml vendored Normal file
View file

@ -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"

View file

@ -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,

View file

@ -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; }

View file

@ -3,7 +3,7 @@ name: wai command line release
# On a tag wai-v<version> (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') }
)

View file

@ -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/.

View file

@ -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

View file

@ -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 <file.toml>");
eprintln!("usage: wai-ingest --config <file.toml>");
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;
}
};

View file

@ -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

View file

@ -4,7 +4,7 @@
# scripts/local.sh <target-dir>
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

View file

@ -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]

View file

@ -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

View file

@ -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.

View file

@ -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 <file.toml> | --dev)");
eprintln!("usage: wai-relay (--config <file.toml> | --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;
}
};

View file

@ -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 <hex from joule-moq-room invite>] --relay wss://relay.example/moq \
[--invitation <hex from wai-room invite>] --relay wss://relay.example/moq \
[--ca ./private-ca.pem] --floor floor --give-on-request
```

View file

@ -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]

View file

@ -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 <keys.toml> new keys (mode 0600, never over a file); prints the
relay configuration lines that trust them
joule-moq-room relay-lines <keys.toml> those lines again
joule-moq-room invite <keys.toml> [<s>] an invitation to host a remote desktop room, signed by
the room's authority, good for <s> seconds (3600)
joule-moq-room --config <room.toml> serve (room.example.toml shows every field)
joule-moq-room dev [--publishers <n>] a development room: the relay and the service in one
process on loopback, driven by JSON control lines
wai-room keygen <keys.toml> new keys (mode 0600, never over a file); prints the
relay configuration lines that trust them
wai-room relay-lines <keys.toml> those lines again
wai-room invite <keys.toml> [<s>] an invitation to host a remote desktop room, signed by
the room's authority, good for <s> seconds (3600)
wai-room --config <room.toml> serve (room.example.toml shows every field)
wai-room dev [--publishers <n>] 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

View file

@ -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"
],

View file

@ -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 <path>` 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 <path>` 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 <keys.toml>`). Not also e2ee, a watch room, a room with
# authority signed (`wai-room invite <keys.toml>`). 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.

View file

@ -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 <n>]`: run a development room and answer control lines
/// `wai-room dev [--publishers <n>]`: 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 <n>]");
eprintln!("usage: wai-room dev [--publishers <n>]");
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;
}

View file

@ -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)

View file

@ -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 <keys.toml> new keys (mode 0600, never over a file), and the
//! wai-room keygen <keys.toml> new keys (mode 0600, never over a file), and the
//! relay configuration lines that trust them
//! joule-moq-room relay-lines <keys.toml> those lines again, for keys that exist
//! joule-moq-room invite <keys.toml> [<s>] an invitation to host a remote desktop room
//! wai-room relay-lines <keys.toml> those lines again, for keys that exist
//! wai-room invite <keys.toml> [<s>] an invitation to host a remote desktop room
//! (`[remote]` with no `host`), signed by the
//! room's authority, good for <s> seconds (3600)
//! joule-moq-room --config <room.toml> serve
//! joule-moq-room dev [--publishers <n>] 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 <room.toml> serve
//! wai-room dev [--publishers <n>] 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 <keys.toml> | relay-lines <keys.toml> | invite <keys.toml> [<seconds>] | --config <room.toml> | dev [--publishers <n>])");
eprintln!("usage: wai-room (keygen <keys.toml> | relay-lines <keys.toml> | invite <keys.toml> [<seconds>] | --config <room.toml> | dev [--publishers <n>])");
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;
}
};

View file

@ -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

View file

@ -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);
}

View file

@ -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);
}
}

View file

@ -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");

View file

@ -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 <joule-moq-room> --relay <joule-moq-relay> --web <web dir>
// node e2e/messaging.e2e.mjs --room <wai-room> --relay <wai-relay> --web <web dir>
// [--browser <executable>] [--only messaging,call] [--out <dir>] [--runs N]
//
// Scenarios:

View file

@ -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 <joule-moq-relay> --wai <wai_live_web> --web <web dir>
// node e2e/room.e2e.mjs --relay <wai-relay> --wai <wai_live_web> --web <web dir>
// --corpus <live-conformance/compose/vectors.json> [--browser <executable>]
// [--only compose,webtransport,websocket,goaway,dvr,service,e2ee,age,co,ageco,binding,cowatch,
// origin,termination,remote,world,privateworld]
// [--runs N]
// [--between <s>] [--out <dir>] [--room <joule-moq-room>] [--base <origin>] [--seek on|off]
// [--between <s>] [--out <dir>] [--room <wai-room>] [--base <origin>] [--seek on|off]
// [--wai-dir <wai>]
//
// Scenarios:
@ -564,7 +564,7 @@ async function scenarioService(run) {
if (BASE) {
base = `${BASE}/room/`;
} else {
if (!ROOM) throw new Error("--room <joule-moq-room> is needed for the service scenario");
if (!ROOM) throw new Error("--room <wai-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 <wai-remote-host> and --room <joule-moq-room> are needed for the remote scenario");
if (!REMOTE || !ROOM) throw new Error("--remote <wai-remote-host> and --room <wai-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 <joule-moq-room> is needed for the e2ee scenario");
if (!ROOM) throw new Error("--room <wai-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 <joule-moq-room> is needed for the age scenario");
if (!ROOM) throw new Error("--room <wai-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 <joule-moq-room> is needed for the origin scenario");
if (!ROOM) throw new Error("--room <wai-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 <joule-moq-room> is needed for the ageco scenario");
if (!ROOM) throw new Error("--room <wai-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 <joule-moq-room> is needed for the co scenario");
if (!ROOM) throw new Error("--room <wai-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 <joule-moq-room> is needed for the cowatch scenario");
if (!ROOM) throw new Error("--room <wai-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 <joule-moq-room> is needed for the ${label} scenario`);
if (!ROOM) throw new Error(`--room <wai-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.

View file

@ -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" \

View file

@ -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<ByteArray>) = list.all { it.isNotEmpty() }
}
}
K
}
fails=0
expect() { # expect <pass|fail> <what> [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 <file> <from> <to>: 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"

292
scripts/check-wai-ops-doors.py Executable file
View file

@ -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<Noun>`, method `<verb>…` wai/wai-sink-swift/Sources
Kotlin class `Wai<Noun>`, fun `<verb>…` 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:]))

View file

@ -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
"<task>"`** (offline, up to five ranked matches), **`wai ops show <id>`** 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

View file

@ -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 "<task>"` 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.

92
wai/catalogue/README.md Normal file
View file

@ -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 <noun> <verb>` |
| `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.

View file

@ -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 <path>... the receipts under each path (receipt indexes *.wai.receipts), checked offline"
"wai verify <path>... 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 <kind> --suite <suite> --impl <command or URL> [--key <file>] [--out <file>] [--root <wai-dir>]",
" [--timeout <seconds>] [--name <text>] [--version <text>] [--all] `wai conformance list` names the kinds",
" and suites; a record that exists is replaced only with --yes"
" [--timeout <seconds>] [--name <text>] [--version <text>] [--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 <node-key> --role <role> --key <file> [--label L] [--from µs] [--until µs] [--out F]",
" [--target URL] [--yes] a node's enrolment (WFE1), signed with the organisation key, written to F",
" (<node>.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 (<node>.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 <digest> --target URL [--out F] [--yes] the report's bundle from the fleet service at URL,",
" recomputed by this build and written to F (<digest>.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 (<digest>.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,

View file

@ -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.

View file

@ -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.

View file

@ -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.

View file

@ -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.

View file

@ -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.

View file

@ -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.

View file

@ -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.

View file

@ -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.

View file

@ -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.

View file

@ -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.

View file

@ -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.

View file

@ -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.

View file

@ -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.

View file

@ -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"

View file

@ -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

View file

@ -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.

View file

@ -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.

View file

@ -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.

View file

@ -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.

View file

@ -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.

View file

@ -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.

View file

@ -494,9 +494,10 @@ in order):
| `report.export` | `wai report export <digest> --target URL [--out F]` | `GET /v1/fleet/reports/{digest}` | writes a file |
| `receipt.verify` | `wai verify <bundle>` | `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

View file

@ -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 |

View file

@ -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 <out-dir>/wai-<version>-<triple>.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"]:

View file

@ -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}")

View file

@ -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

View file

@ -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 <runtime dir> --relay <joule-moq-relay> --room <joule-moq-room>
// node create.e2e.mjs --rt <runtime dir> --relay <wai-relay> --room <wai-room>
// --wai <wai, built with app> --web <wai dev's room client> --out <dir>
// [--browser <executable>] [--site <dist>]
//
@ -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.

View file

@ -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" \

View file

@ -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]]

View file

@ -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 <codec> <input> [-o out.wai] [-q quality]");
eprintln!("wai decode <in.wai> -o <output>");
eprintln!("wai inspect <in.wai>");
eprintln!("wai wrap <capability> <payload> [-o out.wai] [--fallback <cap>] [--intent <intent>]");
eprintln!(" [--companion <cap> <payload>]... WAI2: companions bound to the payload");
eprintln!("wai unwrap <in.wai> [-o payload] [--component <i>] carry/extract ANY registered codec's bytes");
eprintln!("wai capabilities list registered capabilities + tier + license + replicate class + role");
eprintln!("wai registry export [--root <wai-dir>] [--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 <root> 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 <json> (--prompt <s> --seed <u32> --steps <n> | --instr <object>)");
eprintln!(" --size WxH -o <out.rgb> [--json] make a wai.generate.spectral picture (RGB8) and");
eprintln!(" print its SHA-256; --sizes WxH,... --write-instr <object> writes the instruction");
eprintln!(" object of form v0 (SPEC \u{a7}5). A build with `generate`");
eprintln!("wai score build <notes.json> -o <out.wscr> [--json] a WSCR from integer note fields");
eprintln!("wai score render <in.wscr> [--pcm <out.s16>] [--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<u8> = 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<u8> = 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<u8>, 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<bool, String> {
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<bool, String> {
Ok(true)
}
/// Every command line goes through the catalogue's door.
fn main() -> ExitCode {
let args: Vec<String> = 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()
}

View file

@ -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 <noun> <verb>`), 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<bool, OpError>;
/// 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<Item = &'static Impl> {
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<String> = 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<String> = 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), &not_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<bool, OpError> {
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<bool, OpError>, 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::<Value>, None::<Value>, 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::<Value>(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<String> = 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 \"<task>\"` finds one")
} else {
f.with_hint(format!("the closest: {}; `wai ops search \"<task>\"` 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 <noun> <verb>` 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::<Vec<_>>().join("; ");
s += "\n\n`wai ops search \"<task>\"` finds an operation; `wai ops schema <id>` 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<String> = 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<bool, OpError> {
if let Some(x) = a.first() {
return Err(OpError::usage(format!("wai ops list: {x}: takes no argument"), ""));
}
let rows: Vec<Value> = 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<bool, OpError> {
if a.is_empty() || a.iter().any(|x| x.starts_with("--")) {
return Err(OpError::usage("usage: wai ops search \"<task>\"", ""));
}
let found = catalogue::search(&a.join(" "), 5);
if agent::ctx().json {
let rows: Vec<Value> = 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<bool, OpError> {
let [id] = a else {
return Err(OpError::usage("usage: wai ops schema <id>", ""));
};
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<bool, OpError> {
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<bool, OpError>;
/// 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<String> {
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 <capability> <payload>"), "{}", f.message);
}
}

View file

@ -18,7 +18,7 @@ pub fn main(_: &[String]) -> Result<bool, String> {
#[cfg(feature = "app")]
pub fn main(args: &[String]) -> Result<bool, String> {
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<bool, String> {
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 {

View file

@ -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 <kind> --suite <suite> --impl <command or URL> [--key <file>] [--out <file>] [--confirm]\n\
const USAGE: &str = "wai conform <kind> --suite <suite> --impl <command or URL> [--key <file>] [--out <file>] [--yes]\n\
[--root <wai-dir>] [--timeout <seconds>] [--name <text>] [--version <text>] [--dry-run] [--all] [--json|--text]\n\
wai conform --list";
@ -403,7 +403,7 @@ pub fn main(args: &[String]) -> Result<bool, String> {
}
fn run(args: &[String], json_mode: bool) -> Result<bool, OpError> {
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<bool, OpError> {
}
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<bool, OpError> {
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 <another file>: 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<bool, OpError> {
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();

View file

@ -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 <dir> --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 <dir> --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 <dir> --template <name> | wai create <dir> --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<bool, String> {
pub fn main(args: &[String]) -> Result<bool, OpError> {
if flag(args, "--list") {
if flag(args, "--json") {
let v: Vec<serde_json::Value> = 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::<Vec<_>>() }))
.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::<Vec<_>>() }))
.collect();
println!("{}", serde_json::to_string_pretty(&v).expect("json"));
} else {
@ -54,15 +56,16 @@ pub fn main(args: &[String]) -> Result<bool, String> {
}
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 <dir> --from app.oui [--runtime DIR]\n{USAGE}"));
return Err(OpError::usage(format!("wai create <dir> --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<bool, String> {
}
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<u8>)>) -> 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(())
}

View file

@ -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<u64>,
}
pub fn main(args: &[String]) -> Result<bool, String> {
if flag(args, "--help") || flag(args, "-h") {
println!("{HELP}");
return Ok(true);
}
pub fn main(args: &[String]) -> Result<bool, OpError> {
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<Opts, String> {
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<Opts, 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));
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<u16, String> {
/// The relay lines of a room service's keys, made once in `keys`.
fn keys(room: &Path, keys: &Path) -> Result<String, String> {
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<bool, String> {
// The loopback address each process listens on, in the order they start.
let mut listens: Vec<SocketAddr> = Vec::new();
let result = (|| -> Result<Front, String> {
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<bool, String> {
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<bool, String> {
}
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),

View file

@ -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<String>,
}
static CTX: OnceLock<Ctx> = 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<Value>,
/// With exit 4: the command that confirms them.
pub confirm: Option<String>,
/// 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<String>, hint: impl Into<String>) -> 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<String>) -> OpError {
OpError::new(exit, code, message, "")
}
pub fn with_hint(mut self, h: impl Into<String>) -> OpError {
self.hint = h.into();
self
}
pub fn usage(message: impl Into<String>, usage: &str) -> OpError {
OpError::new(EXIT_USAGE, "usage", message, usage)
}
pub fn unavailable(message: impl Into<String>) -> OpError {
OpError::new(EXIT_UNAVAILABLE, "target-unavailable", message, "start it again; nothing was changed")
}
pub fn internal(message: impl Into<String>) -> 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<String> 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<Value>) -> 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<String, Value>) -> 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<Vec<Value>>) -> 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>) -> 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");
}
}

View file

@ -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 <node-key> --role <role> --key <file> [--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 <digest> --target URL [--out F] [--dry-run] [--confirm] [--json]: its bundle,\n verified here and written to a file (report.export)";
type R = Result<bool, OpError>;
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<String>, hint: impl Into<String>) -> Fail {
Fail { exit, code: code.into(), message: message.into(), hint: hint.into(), retryable: exit == UNAVAILABLE }
}
fn usage(message: impl Into<String>, 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<String, Value> {
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<String> = 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<Vec<u8>> {
(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<u64, Fail> {
fn number(args: &[String], flag: &str, default: u64) -> Result<u64, OpError> {
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<u8, Fail> {
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 <node-key> --role <role> --key <file> ...`: 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<u8, Fail> {
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::<u8>().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::<u8>().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::<Vec<_>>().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<u8, Fail> {
}
}
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<u8, Fail> {
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<u8, Fail> {
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<u8, Fail> {
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<u8, Fail> {
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 <digest> --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<u8, Fail> {
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<u8, Fail> {
// 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)
}

View file

@ -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 [<dir>] WAI's four steps (name, dispatch, rebuild, receipt) on a sample, written to <dir> (wai-hello)";
use super::envelope::OpError;
#[cfg(not(feature = "quickstart"))]
pub fn main(_: &[String]) -> Result<bool, String> {
Err("`wai hello` needs a build with the feature `quickstart`".into())
pub fn main(_: &[String]) -> Result<bool, OpError> {
Err(OpError::from("`wai hello` needs a build with the feature `quickstart`"))
}
#[cfg(feature = "quickstart")]
pub fn main(args: &[String]) -> Result<bool, String> {
pub fn main(args: &[String]) -> Result<bool, OpError> {
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<bool, String> {
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<bool, String> {
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<bool, String> {
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}");
}

View file

@ -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<bool, String>),
/// 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<bool, String> {
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<bool, String> {
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<String> {
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 <kind> --suite <suite> --impl <command or URL> [--key <file>] [--out <file>] [--confirm]\n\
[--root <wai-dir>] [--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<bool, OpError> {
Ok(breaking::main(&with_agent_flags(args, &["--json"]))?)
}
#[cfg(feature = "quickstart")]
fn conform_main(args: &[String]) -> Result<bool, String> {
conform::main(args)
fn conformance_run(args: &[String]) -> Result<bool, OpError> {
Ok(conform::main(&with_agent_flags(args, &["--json", "--dry-run"]))?)
}
#[cfg(feature = "quickstart")]
fn conformance_list(args: &[String]) -> Result<bool, OpError> {
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<bool, String> {
Err("`wai conform` needs a build with the feature `quickstart`".into())
fn conformance_run(_: &[String]) -> Result<bool, OpError> {
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<Result<u8, String>> {
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<bool, OpError> {
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<String> {
words.iter().map(|w| w.to_string()).chain(args.iter().cloned()).collect()
}
fn app_guide(args: &[String]) -> Result<bool, OpError> {
Ok(app::main(&with(&["guide"], args))?)
}
fn app_check(args: &[String]) -> Result<bool, OpError> {
Ok(app::main(&with(&["check"], &with_agent_flags(args, &["--json"])))?)
}
/// `wai app export <app.oui> <dir> [--runtime DIR]`: `wai create <dir> --from <app.oui>`.
fn app_export(args: &[String]) -> Result<bool, OpError> {
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<bool, OpError> {
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<bool, OpError> {
Err(crate::door::not_built(wai::catalogue::op("mcp.start").expect("listed")))
}
/// The value after `flag` among `args`.

View file

@ -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"),

View file

@ -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 <path>... 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<bool, String> {
Err("`wai verify` needs a build with the feature `quickstart`".into())
pub fn main(_: &[String]) -> Result<bool, OpError> {
Err(OpError::from("`wai verify` needs a build with the feature `quickstart`"))
}
#[cfg(feature = "quickstart")]
pub fn main(args: &[String]) -> Result<bool, String> {
pub fn main(args: &[String]) -> Result<bool, OpError> {
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<PathBuf>, bundles: &mut Vec<PathBuf>, depth: usize) -> Result<(), String> {
if p.is_file() {
@ -64,10 +64,10 @@ pub fn main(args: &[String]) -> Result<bool, String> {
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);

View file

@ -15,7 +15,7 @@ fn read(p: &Path) -> Result<Vec<u8>, 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> {

View file

@ -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(());
}

View file

@ -133,7 +133,7 @@ fn read(p: &str) -> Result<Vec<u8>, String> {
fn publish(args: &[String]) -> Result<(), String> {
let out = PathBuf::from(opt(args, "--out").ok_or("publish needs --out <dir>")?);
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(())
}

View file

@ -62,9 +62,9 @@ fn read(p: &Path) -> Result<Vec<u8>, 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

View file

@ -67,8 +67,8 @@ pub fn main(args: &[String]) -> Result<bool, String> {
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<bool, String> {
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<bool, String> {
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)
}

View file

@ -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<PathBuf, String> {
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")
})
}

View file

@ -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<bool, String> {
/// What `wai mcp` is started with.
struct Options {
toolsets: Vec<tools::Toolset>,
spec_root: Option<PathBuf>,
room_bin: Option<PathBuf>,
project: PathBuf,
read_only: bool,
}
fn options(args: &[String]) -> Result<Options, String> {
let mut toolsets: Vec<tools::Toolset> = Vec::new();
let mut spec_root = None;
let mut room_bin = None;
@ -244,8 +252,32 @@ pub fn main(args: &[String]) -> Result<bool, String> {
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<bool, String> {
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<String> = 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<bool, String> {
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}"))?;

View file

@ -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);
}
}
}

View file

@ -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<Vec<Value>> = 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<Value> {
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<Path>) {
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<Path>, 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<Path>, 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<Path>) -> 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<Path>) -> 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<Path>) -> io::Result<Box<dyn Write>> {
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<String> {
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()
}

View file

@ -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!({

View file

@ -5,16 +5,16 @@
//! documented (`TARGETS`).
//!
//! ```text
//! wai setup-agent [--dir <project>] [--dry-run] [--confirm] [--json] [--all] [--only <file>[,<file>...]]
//! wai setup-agent [--dir <project>] [--dry-run] [--yes] [--json] [--all] [--only <file>[,<file>...]]
//! [--toolset <set>[,<set>...]] [--command <program>]
//! ```
//!
//! - 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 <project>] [--dry-run] [--confirm] [--json] [--all] [--only <file>[,<file>...]] [--toolset <set>[,<set>...]] [--command <program>]"
"usage: wai setup-agent [--dir <project>] [--dry-run] [--yes] [--json] [--all] [--only <file>[,<file>...]] [--toolset <set>[,<set>...]] [--command <program>]"
}
/// 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<String, Value> {
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<bool, OpError> {
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<Value> = 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);
}

View file

@ -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(())
}

View file

@ -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<String> = std::env::args().skip(1).collect();
match http_vectors::main(&args) {

2178
wai/wai-rs/src/catalogue.rs Normal file

File diff suppressed because it is too large Load diff

View file

@ -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")]

View file

@ -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");
}
}

View file

@ -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::<Vec<_>>().join("\n");
let failures: String = v["data"]["failures"].as_array().unwrap().iter().map(|f| f.as_str().unwrap()).collect::<Vec<_>>().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}");

View file

@ -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:#}");

View file

@ -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 <kind> --suite <suite> --impl <imp> --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 <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:#}");
}

View file

@ -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<String> = std::fs::read_dir(&examples).unwrap().flatten().filter(|e| e.path().is_dir()).map(|e| e.file_name().to_string_lossy().into_owned()).collect();

View file

@ -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"));
}

View file

@ -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(&notes), "-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();

View file

@ -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<String> {
let list = c.request("tools/list", json!({}));
@ -132,7 +132,7 @@ fn tool_names(c: &mut Client) -> Vec<String> {
}
fn names_of(ops: &[&[&str]]) -> Vec<String> {
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();

494
wai/wai-rs/tests/cli_ops.rs Normal file
View file

@ -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 <noun> <verb>` 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::<Vec<_>>());
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"), "<script src=a.js></script>").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<String>, bool)> {
let s = |v: &[&str]| v.iter().map(|x| x.to_string()).collect::<Vec<_>>();
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(&reg).unwrap();
std::fs::copy(wai_dir().join("REGISTRY.md"), reg.join("REGISTRY.md")).unwrap();
let objs: Vec<String> = ["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::<Vec<_>>() }],
});
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"), "<script src=a.js></script>").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<String>, 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<u8> = (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);
}

Some files were not shown because too many files have changed in this diff Show more