From 184086e64c97cff1fe7dc9eb7aee85b210d8b118 Mon Sep 17 00:00:00 2001 From: John Vandenberg Date: Wed, 20 May 2026 08:58:12 +0800 Subject: [PATCH 01/13] Spec and client generation --- .dprint.jsonc | 6 + .editorconfig | 25 + .mise.toml | 175 ++- CLAUDE.md | 25 + Cargo.lock | 856 ++++++++++++- Cargo.toml | 18 + README.md | 20 + config/ast-grep/rules/no-inline-mod.yml | 12 + config/ast-grep/rules/no-result-alias.yml | 15 + .../rules/no-rust-line-continuation.yml | 2 + config/ast-grep/rules/no-shadow-result.yml | 16 + generated/.gitignore | 6 + generated/README.md | 72 ++ generated/dart-ws/README.md | 33 + generated/dart-ws/lib/ws_messages.dart | 1121 +++++++++++++++++ generated/dart-ws/pubspec.yaml | 7 + .../python-rest/et_rest_client/__init__.py | 8 + .../et_rest_client/api/__init__.py | 1 + .../api/et_modules_service/__init__.py | 1 + .../api/et_modules_service/get_module_file.py | 115 ++ .../list_modules_handler.py | 119 ++ .../api/et_storage_service/__init__.py | 1 + .../api/et_storage_service/get_file.py | 115 ++ .../api/et_storage_service/put_file.py | 126 ++ .../api/et_ws_server/__init__.py | 1 + .../et_rest_client/api/et_ws_server/health.py | 120 ++ .../python-rest/et_rest_client/client.py | 268 ++++ .../python-rest/et_rest_client/errors.py | 16 + .../et_rest_client/models/__init__.py | 5 + .../et_rest_client/models/health_response.py | 70 + generated/python-rest/et_rest_client/types.py | 54 + generated/python-rest/pyproject.toml | 15 + generated/python-ws/README.md | 13 + generated/python-ws/et_ws/__init__.py | 6 + generated/python-ws/et_ws/messages.py | 143 +++ generated/python-ws/pyproject.toml | 15 + generated/rust-rest/Cargo.toml | 35 + generated/rust-rest/src/lib.rs | 416 ++++++ generated/specs/rest.yaml | 149 +++ .../wit/deps/et-ws-messages/messages.wit | 91 ++ .../wit/deps/wasi-clocks/monotonic-clock.wit | 50 + .../specs/wit/deps/wasi-clocks/timezone.wit | 55 + .../specs/wit/deps/wasi-clocks/wall-clock.wit | 46 + .../specs/wit/deps/wasi-clocks/world.wit | 11 + generated/specs/wit/deps/wasi-io/error.wit | 34 + generated/specs/wit/deps/wasi-io/poll.wit | 47 + generated/specs/wit/deps/wasi-io/streams.wit | 290 +++++ generated/specs/wit/deps/wasi-io/world.wit | 10 + .../specs/wit/deps/wasi-keyvalue/atomic.wit | 22 + .../specs/wit/deps/wasi-keyvalue/batch.wit | 63 + .../specs/wit/deps/wasi-keyvalue/store.wit | 122 ++ .../specs/wit/deps/wasi-keyvalue/watch.wit | 16 + .../specs/wit/deps/wasi-keyvalue/world.wit | 26 + .../specs/wit/deps/wasi-logging/logging.wit | 35 + .../specs/wit/deps/wasi-logging/world.wit | 5 + .../specs}/wit/deps/wasi-nn/wasi-nn.wit | 0 .../specs}/wit/deps/wasi-webgpu/webgpu.wit | 593 ++++----- generated/specs/wit/world.wit | 41 + generated/specs/ws.yaml | 304 +++++ generated/zig-rest/build.zig.zon | 11 + generated/zig-rest/et_rest_client.o | Bin 0 -> 193 bytes generated/zig-rest/src/et_rest_client.zig | 508 ++++++++ libs/edge-toolkit/Cargo.toml | 7 + libs/edge-toolkit/src/config.rs | 10 +- libs/edge-toolkit/src/ws.rs | 91 +- ruff.toml | 4 + services/modules/Cargo.toml | 1 + services/modules/src/lib.rs | 27 +- services/storage/Cargo.toml | 1 + services/storage/src/lib.rs | 49 +- .../ws-modules/dart-comm1/lib/dart_comm1.dart | 71 +- services/ws-modules/dart-comm1/pubspec.yaml | 3 + services/ws-modules/data1/Cargo.toml | 16 +- services/ws-modules/data1/src/lib.rs | 125 +- services/ws-modules/dotnet-data1/Program.cs | 36 +- .../dotnet-data1/pkg/et_ws_dotnet_data1.js | 13 +- .../java-data1/pkg/et_ws_java_data1.js | 13 +- .../src/main/java/au/edu/curtin/et/Data1.java | 80 +- .../ws-modules/pydata1/pkg/et_ws_pydata1.js | 100 +- .../ws-modules/pydata1/pydata1/__init__.py | 53 +- services/ws-modules/pydata1/pyproject.toml | 7 +- .../ws-modules/pyface1/pkg/et_ws_pyface1.js | 25 +- .../pyface1/pyface1/face_detection.py | 40 +- services/ws-modules/pyface1/pyproject.toml | 12 +- services/ws-modules/wasi-comm1/src/lib.rs | 80 +- services/ws-modules/wasi-data1/src/lib.rs | 2 +- .../wasi_graphics_info/__init__.py | 44 +- services/ws-modules/zig-data1/build.zig | 10 + .../zig-data1/pkg/et_ws_zig_data1.js | 105 +- .../zig-data1/pkg/et_ws_zig_data1_worker.js | 63 +- services/ws-modules/zig-data1/src/main.zig | 79 +- services/ws-server/Cargo.toml | 1 + services/ws-server/src/lib.rs | 22 +- services/ws-server/static/app.js | 2 +- services/ws-test-server/Cargo.toml | 7 + .../ws-test-server/tests/hub_forwarding.rs | 121 ++ services/ws-wasi-runner/Cargo.toml | 3 +- services/ws-wasi-runner/src/bindings.rs | 40 + services/ws-wasi-runner/src/host/mod.rs | 9 +- .../ws-wasi-runner/src/host/wasi_keyvalue.rs | 112 +- .../ws-wasi-runner/src/host/wasi_webgpu.rs | 34 +- .../src/host/wasi_webgpu/map_mode.rs | 4 + .../src/host/wasi_webgpu/shader_stage.rs | 5 + .../src/host/wasi_webgpu/usage.rs | 15 + services/ws-wasi-runner/src/host/ws.rs | 245 +++- services/ws-wasi-runner/src/lib.rs | 131 +- .../wit/deps/wasi-clocks/clocks.wit | 157 --- .../ws-wasi-runner/wit/deps/wasi-io/io.wit | 331 ----- .../wit/deps/wasi-keyvalue/store.wit | 65 - .../wit/deps/wasi-logging/logging.wit | 38 - .../wit/deps/wasi-webgpu/imports.wit | 5 - services/ws-wasi-runner/wit/world.wit | 61 - services/ws-wasm-agent/src/lib.rs | 3 - services/ws/Cargo.toml | 1 + services/ws/src/lib.rs | 138 +- .../src/deployment_types/docker_compose.rs | 6 +- utilities/cli/src/deployment_types/mise.rs | 6 +- utilities/cli/src/lib.rs | 20 +- utilities/cli/src/main.rs | 3 +- utilities/cli/src/module_package_json/mod.rs | 32 +- utilities/int-gen/Cargo.toml | 42 + utilities/int-gen/src/bin/int-gen.rs | 32 + utilities/int-gen/src/kdl.rs | 264 ++++ utilities/int-gen/src/lib.rs | 297 +++++ utilities/int-gen/src/rest.rs | 109 ++ utilities/int-gen/src/wit/messages.rs | 255 ++++ utilities/int-gen/src/wit/mod.rs | 14 + utilities/int-gen/src/wit/upstream.rs | 416 ++++++ utilities/int-gen/src/wit/world.rs | 136 ++ utilities/int-gen/src/zig.rs | 214 ++++ 130 files changed, 9259 insertions(+), 1905 deletions(-) create mode 100644 config/ast-grep/rules/no-inline-mod.yml create mode 100644 config/ast-grep/rules/no-result-alias.yml create mode 100644 config/ast-grep/rules/no-shadow-result.yml create mode 100644 generated/.gitignore create mode 100644 generated/README.md create mode 100644 generated/dart-ws/README.md create mode 100644 generated/dart-ws/lib/ws_messages.dart create mode 100644 generated/dart-ws/pubspec.yaml create mode 100644 generated/python-rest/et_rest_client/__init__.py create mode 100644 generated/python-rest/et_rest_client/api/__init__.py create mode 100644 generated/python-rest/et_rest_client/api/et_modules_service/__init__.py create mode 100644 generated/python-rest/et_rest_client/api/et_modules_service/get_module_file.py create mode 100644 generated/python-rest/et_rest_client/api/et_modules_service/list_modules_handler.py create mode 100644 generated/python-rest/et_rest_client/api/et_storage_service/__init__.py create mode 100644 generated/python-rest/et_rest_client/api/et_storage_service/get_file.py create mode 100644 generated/python-rest/et_rest_client/api/et_storage_service/put_file.py create mode 100644 generated/python-rest/et_rest_client/api/et_ws_server/__init__.py create mode 100644 generated/python-rest/et_rest_client/api/et_ws_server/health.py create mode 100644 generated/python-rest/et_rest_client/client.py create mode 100644 generated/python-rest/et_rest_client/errors.py create mode 100644 generated/python-rest/et_rest_client/models/__init__.py create mode 100644 generated/python-rest/et_rest_client/models/health_response.py create mode 100644 generated/python-rest/et_rest_client/types.py create mode 100644 generated/python-rest/pyproject.toml create mode 100644 generated/python-ws/README.md create mode 100644 generated/python-ws/et_ws/__init__.py create mode 100644 generated/python-ws/et_ws/messages.py create mode 100644 generated/python-ws/pyproject.toml create mode 100644 generated/rust-rest/Cargo.toml create mode 100644 generated/rust-rest/src/lib.rs create mode 100644 generated/specs/rest.yaml create mode 100644 generated/specs/wit/deps/et-ws-messages/messages.wit create mode 100644 generated/specs/wit/deps/wasi-clocks/monotonic-clock.wit create mode 100644 generated/specs/wit/deps/wasi-clocks/timezone.wit create mode 100644 generated/specs/wit/deps/wasi-clocks/wall-clock.wit create mode 100644 generated/specs/wit/deps/wasi-clocks/world.wit create mode 100644 generated/specs/wit/deps/wasi-io/error.wit create mode 100644 generated/specs/wit/deps/wasi-io/poll.wit create mode 100644 generated/specs/wit/deps/wasi-io/streams.wit create mode 100644 generated/specs/wit/deps/wasi-io/world.wit create mode 100644 generated/specs/wit/deps/wasi-keyvalue/atomic.wit create mode 100644 generated/specs/wit/deps/wasi-keyvalue/batch.wit create mode 100644 generated/specs/wit/deps/wasi-keyvalue/store.wit create mode 100644 generated/specs/wit/deps/wasi-keyvalue/watch.wit create mode 100644 generated/specs/wit/deps/wasi-keyvalue/world.wit create mode 100644 generated/specs/wit/deps/wasi-logging/logging.wit create mode 100644 generated/specs/wit/deps/wasi-logging/world.wit rename {services/ws-wasi-runner => generated/specs}/wit/deps/wasi-nn/wasi-nn.wit (100%) rename {services/ws-wasi-runner => generated/specs}/wit/deps/wasi-webgpu/webgpu.wit (61%) create mode 100644 generated/specs/wit/world.wit create mode 100644 generated/specs/ws.yaml create mode 100644 generated/zig-rest/build.zig.zon create mode 100644 generated/zig-rest/et_rest_client.o create mode 100644 generated/zig-rest/src/et_rest_client.zig create mode 100644 ruff.toml create mode 100644 services/ws-test-server/tests/hub_forwarding.rs create mode 100644 services/ws-wasi-runner/src/bindings.rs create mode 100644 services/ws-wasi-runner/src/host/wasi_webgpu/map_mode.rs create mode 100644 services/ws-wasi-runner/src/host/wasi_webgpu/shader_stage.rs create mode 100644 services/ws-wasi-runner/src/host/wasi_webgpu/usage.rs delete mode 100644 services/ws-wasi-runner/wit/deps/wasi-clocks/clocks.wit delete mode 100644 services/ws-wasi-runner/wit/deps/wasi-io/io.wit delete mode 100644 services/ws-wasi-runner/wit/deps/wasi-keyvalue/store.wit delete mode 100644 services/ws-wasi-runner/wit/deps/wasi-logging/logging.wit delete mode 100644 services/ws-wasi-runner/wit/deps/wasi-webgpu/imports.wit delete mode 100644 services/ws-wasi-runner/wit/world.wit create mode 100644 utilities/int-gen/Cargo.toml create mode 100644 utilities/int-gen/src/bin/int-gen.rs create mode 100644 utilities/int-gen/src/kdl.rs create mode 100644 utilities/int-gen/src/lib.rs create mode 100644 utilities/int-gen/src/rest.rs create mode 100644 utilities/int-gen/src/wit/messages.rs create mode 100644 utilities/int-gen/src/wit/mod.rs create mode 100644 utilities/int-gen/src/wit/upstream.rs create mode 100644 utilities/int-gen/src/wit/world.rs create mode 100644 utilities/int-gen/src/zig.rs diff --git a/.dprint.jsonc b/.dprint.jsonc index c3e8a2e..790dece 100644 --- a/.dprint.jsonc +++ b/.dprint.jsonc @@ -3,6 +3,12 @@ }, "json": { }, + // Match the repo-wide 120 line-length set in .editorconfig and ruff.toml, + // otherwise dprint's bundled ruff would reformat Python files to its + // default and fight with `mise run ruff-fmt`. + "ruff": { + "lineLength": 120, + }, "malva": { }, "markdown": { diff --git a/.editorconfig b/.editorconfig index c02692b..1e0fc1b 100644 --- a/.editorconfig +++ b/.editorconfig @@ -30,3 +30,28 @@ indent_size = unset indent_style = unset trim_trailing_whitespace = unset max_line_length = unset + +# Upstream WIT packages fetched verbatim — their formatting is whatever the +# upstream repos use and shouldn't be normalised on our side. +[generated/specs/wit/deps/wasi-*/**] +charset = unset +end_of_line = unset +insert_final_newline = unset +indent_size = unset +indent_style = unset +trim_trailing_whitespace = unset +max_line_length = unset + +# openapi-python-client emits framework-boilerplate docstrings (e.g. the +# "errors.UnexpectedStatus: If the server returns an undocumented status code +# …" line in every operation) that exceed 120 chars. ruff format doesn't +# reflow plain-text docstrings, so we can't fix this short of patching the +# generator's templates — drop the line-length check for this tree. +[generated/python-rest/**] +max_line_length = unset + +# Same story for openapi2zig: a handful of helpers (SSE parsing, query-string +# encoding) emit lines that exceed 120 chars and `zig fmt` won't reflow them. +# The generator is the source of truth; we don't edit its output by hand. +[generated/zig-rest/**] +max_line_length = unset diff --git a/.mise.toml b/.mise.toml index 6b7f24d..89781b2 100644 --- a/.mise.toml +++ b/.mise.toml @@ -1,14 +1,14 @@ [tools] action-validator = "latest" -"cargo:ast-grep" = "latest" cargo-binstall = "latest" +"cargo:ast-grep" = "latest" "cargo:aube" = "latest" +"cargo:dart-typegen" = "latest" "cargo:taplo-cli" = "latest" "cargo:wasm-pack" = "latest" "chromedriver" = "146" -claude = "latest" cmake = "latest" -codex = "latest" +"conda:lld" = "latest" dart = { version = "latest", url = "https://storage.googleapis.com/dart-archive/channels/stable/release/{{ version }}/sdk/dartsdk-{{ os() }}-{{ arch() }}-release.zip", version_expr = 'fromJSON(body).prefixes | filter({ # matches "^channels/stable/release/(\\d+\\.\\d+\\.\\d+)/$" }) | map({split(#, "/")[3]}) | sortVersions()', version_list_url = "https://storage.googleapis.com/storage/v1/b/dart-archive/o?prefix=channels/stable/release/&delimiter=/" } dotnet = "latest" dotnet-core = "latest" @@ -16,7 +16,11 @@ dotnet-core = "latest" dprint = "latest" editorconfig-checker = "latest" gemini-cli = "latest" -"github:block/goose" = "latest" +# openapi2zig v0.2.0 doesn't support YAML input yet — `gen-zig-rest` feeds it +# the JSON form via `yq`. Its `Client` ships `std.http.Client` which doesn't +# compile for `wasm32-unknown-unknown`; `et-int-gen` post-processes the +# generated source via tree-sitter-zig to swap in a WASM-friendly extern. +"github:christianhelle/openapi2zig" = "latest" "github:grok-rs/waitup" = "latest" "github:wasm-bindgen/wasm-bindgen" = "0.2.114" java = "latest" @@ -24,12 +28,21 @@ maven = "latest" mprocs = "latest" node = "22" "npm:onnxruntime-web" = "latest" -"npm:pyodide" = "0.29.3" -ollama = "latest" +# Use the GitHub release tarball, not `npm:pyodide`. The npm package is only +# the runtime (pyodide.js, .wasm, stdlib, micropip) ~5 MB — it's designed for +# JS apps that fetch the rest from JsDelivr's Pyodide custom-CDN namespace +# (https://www.jsdelivr.com/oss-cdn) at runtime. We need the ~200 MB "full" +# distribution served locally so browser modules work offline; that's only +# published as a tarball on GitHub releases. The extracted tarball contains a +# top-level `pyodide/` directory the modules service picks up — see +# `default_modules_folders` in libs/edge-toolkit/src/config.rs. +"http:pyodide" = { version = "0.29.3", url = "https://github.com/pyodide/pyodide/releases/download/{{ version }}/pyodide-{{ version }}.tar.bz2" } osv-scanner = "latest" pipx = "latest" "pipx:cmake" = "latest" "pipx:componentize-py" = "latest" +"pipx:datamodel-code-generator" = "latest" +"pipx:openapi-python-client" = "latest" "pipx:pytest" = "latest" protoc = "latest" rclone = "latest" @@ -44,13 +57,27 @@ wasm-tools = "latest" yq = "latest" zig = "latest" +# Recent Apple `ld` versions trip an assertion on long mangled symbols emitted +# by some deep-dep crates (notably dart-typegen, installed below). Routing the +# link step through LLVM's `ld64.lld` avoids the bug — `lld` is on PATH via the +# `conda:lld` mise tool. We set this here so `mise install cargo:dart-typegen` +# also picks it up (cargo install runs in a tempdir and doesn't read project +# `.cargo/config.toml`). +[env] +CARGO_TARGET_AARCH64_APPLE_DARWIN_RUSTFLAGS = "-C link-arg=-fuse-ld=lld" +CARGO_TARGET_X86_64_APPLE_DARWIN_RUSTFLAGS = "-C link-arg=-fuse-ld=lld" + [tasks] ec = "ec" editorconfig-check = "ec" -ruff-check = "ruff check services/ws-modules/" -ruff-fmt = "ruff format services/ws-modules/" -zig-check = "zig fmt --check services/ws-modules/" -zig-fmt = "zig fmt services/ws-modules/" +# `wit_world` and `componentize_py_async_support` are componentize-py outputs +# (gitignored, regenerated each build) under wasi-graphics-info — ruff would +# flag 200+ issues in code we don't own and don't ship. +ruff-check = "ruff check --exclude wit_world --exclude componentize_py_async_support services/ws-modules/ generated/python-ws/ generated/python-rest/" +ruff-fmt = "ruff format --exclude wit_world --exclude componentize_py_async_support services/ws-modules/ generated/python-ws/ generated/python-rest/" +ruff-fmt-check = "ruff format --check --exclude wit_world --exclude componentize_py_async_support services/ws-modules/ generated/python-ws/ generated/python-rest/" +zig-check = "zig fmt --check services/ws-modules/ generated/zig-rest/" +zig-fmt = "zig fmt services/ws-modules/ generated/zig-rest/" [tasks.dotnet-fmt] description = "Format dotnet C# code" @@ -70,14 +97,19 @@ DPRINT_CACHE_DIR = "/tmp/dprint-cache" run = "dprint fmt" [tasks.fmt] -depends = ["cargo-clippy-fix", "cargo-fmt", "dart-fmt", "dotnet-fmt", "dprint-fmt", "taplo-fmt", "zig-fmt"] +depends = ["cargo-clippy-fix", "cargo-fmt", "dart-fmt", "dotnet-fmt", "dprint-fmt", "ruff-fmt", "taplo-fmt", "zig-fmt"] description = "Run repository formatters" [tasks.dart-check] -run = "dart analyze services/ws-modules/dart-comm1/" +run = "dart analyze services/ws-modules/dart-comm1/ generated/dart-ws/" [tasks.dart-fmt] -run = "dart format services/ws-modules/dart-comm1/" +run = "dart format services/ws-modules/dart-comm1/ generated/dart-ws/" + +[tasks.dart-fmt-check] +# Parallel of cargo-fmt-check: fail if any tracked Dart source would be +# reformatted, so CI enforces the same formatting `dart-fmt` applies. +run = "dart format --output=none --set-exit-if-changed services/ws-modules/dart-comm1/ generated/dart-ws/" [tasks.check] depends = [ @@ -86,10 +118,14 @@ depends = [ "cargo-clippy", "cargo-fmt-check", "dart-check", + "dart-fmt-check", "dotnet-check", "dprint-check", "editorconfig-check", + "gen-specs-check", "osv-scanner", + "ruff-check", + "ruff-fmt-check", "taplo-check", "typos", "verification-check", @@ -139,6 +175,103 @@ depends = ["regenerate-verification"] description = "Fail if generated scenario output files are stale" run = "git diff --exit-code -- verification" +[tasks.fetch-wit-deps] +description = "Fetch upstream WASI WIT packages into generated/specs/wit/ at pinned versions. Run by hand when bumping a package version; gen-specs-check catches drift." +run = "cargo run -q -p et-int-gen --bin et-int-gen -- fetch-deps" + +[tasks.gen-ws-spec] +description = "Run et-int-gen: emit the WS protocol's AsyncAPI YAML, OpenAPI YAML, WIT package, KDL schema, and Rust REST client from edge_toolkit::ws::WsMessage and the service handlers" +# et-int-gen runs progenitor + prettyplease to produce +# generated/rust-rest/src/lib.rs. prettyplease's style disagrees with our +# nightly rustfmt (different import grouping + line width), so the workspace +# `mise run fmt` would reformat it and `mise run gen-specs-check` would see +# drift on the next regen. Make rustfmt the canonical formatter by running +# it here as a post-process — the committed file is then a fixed point of +# both tools. +run = """ +cargo run -q -p et-int-gen --bin et-int-gen +cargo +nightly fmt -p et-rest-client +zig fmt generated/zig-rest/src/et_rest_client.zig +""" + +[tasks.gen-dart-ws] +depends = ["gen-ws-spec"] +description = "Emit Dart sealed-class client for the WS protocol via dart-typegen (consumes target/int-gen/ws.kdl emitted by gen-ws-spec)" +run = """ +mkdir -p generated/dart-ws/lib +dart-typegen generate -i target/int-gen/ws.kdl -o generated/dart-ws/lib/ws_messages.dart +dart format generated/dart-ws/lib/ws_messages.dart +""" + +[tasks.gen-python-rest] +depends = ["gen-ws-spec"] +description = "Emit typed Python REST client for the ws-server API via openapi-python-client (consumes generated/specs/rest.yaml)" +run = """ +mkdir -p generated/python-rest/et_rest_client +openapi-python-client generate \ + --path generated/specs/rest.yaml \ + --meta none \ + --overwrite \ + --output-path generated/python-rest/et_rest_client +# openapi-python-client drops a .ruff_cache next to the generated source; we +# don't want that committed. +rm -rf generated/python-rest/et_rest_client/.ruff_cache +""" + +[tasks.gen-python-ws] +depends = ["gen-ws-spec"] +description = "Emit Pydantic models for the WS protocol via datamodel-code-generator" +run = """ +mkdir -p generated/python-ws/et_ws +# `--custom-file-header "#"` is the only way to suppress datamodel-codegen's +# "generated by datamodel-codegen / filename: …" banner (an empty string is +# treated as "no override" and the default banner returns). Strip the +# remaining lone `#` line below so the file starts cleanly. +datamodel-codegen \ + --input target/int-gen/ws.schema.json \ + --input-file-type jsonschema \ + --output generated/python-ws/et_ws/messages.py \ + --output-model-type pydantic_v2.BaseModel \ + --target-python-version 3.10 \ + --use-schema-description \ + --use-title-as-name \ + --use-double-quotes \ + --use-union-operator \ + --field-constraints \ + --disable-timestamp \ + --custom-file-header '#' +sed -i.bak -e '1{/^#$/d;}' -e '2{/^$/d;}' generated/python-ws/et_ws/messages.py +rm generated/python-ws/et_ws/messages.py.bak +# datamodel-codegen formats via black at line-length 88; our repo-wide +# ruff.toml sets 120. Re-run ruff to match — otherwise gen-specs-check +# diffs single-line vs wrapped fields on every regen. +ruff format generated/python-ws/et_ws/messages.py +""" + +[tasks.gen-specs] +depends = ["gen-dart-ws", "gen-python-rest", "gen-python-ws", "gen-ws-spec"] +description = "Regenerate every spec artifact under generated/ (AsyncAPI YAML, OpenAPI YAML, WIT, Dart, Python, Rust)" + +[tasks.gen-specs-check] +depends = ["gen-specs"] +description = "Fail if any checked-in artifact under generated/ is stale" +# All et-int-gen outputs land under generated/. The runner's +# wit/deps/et-ws-messages is a symlink into that tree, so a single diff +# against generated/ covers everything. +run = "git diff --exit-code -- generated" + +[tasks.build-et-ws-wheel] +depends = ["gen-python-ws"] +description = "Build the generated et-ws Python package as a wheel" +dir = "generated/python-ws" +run = "uv build --wheel --out-dir dist" + +[tasks.build-et-rest-client-wheel] +depends = ["gen-python-rest"] +description = "Build the generated et-rest-client Python package as a wheel" +dir = "generated/python-rest" +run = "uv build --wheel --out-dir dist" + [tasks.ws-server] description = "Run the WebSocket server" dir = "services/ws-server" @@ -253,10 +386,10 @@ cp "$PUBLISH"/*.js "$PUBLISH"/*.wasm "$PUBLISH"/*.dat pkg/ [tasks.build-ws-wasi-graphics-info-module] description = "Build the WASI graphics-info Python module as a WASI Preview 2 component" dir = "services/ws-modules/wasi-graphics-info" -# WIT lives once in services/ws-wasi-runner/wit/ - the runner's `runner` -# world is what its bindgen consumes; the `module` world (which `include`s -# `runner` and adds wasi-nn) is what guest modules target. `mnist-12.onnx` -# is fetched into `pkg/` by `mise run download-models` (see +# WIT lives once at generated/specs/wit/ — the runner's `runner` world is +# what its bindgen consumes; the `module` world (which `include`s `runner` +# and adds wasi-nn) is what guest modules target. `mnist-12.onnx` is +# fetched into `pkg/` by `mise run download-models` (see # `fetch-mnist-rclone`); componentize-py 0.23 doesn't bundle arbitrary # data files inside the component image, so the guest fetches it via the # `wasi:keyvalue/store` host import from the ws-server at runtime. @@ -264,8 +397,8 @@ dir = "services/ws-modules/wasi-graphics-info" # existing output dir, so we wipe it first. run = """ rm -rf wit_world -componentize-py -d ../../ws-wasi-runner/wit -w module bindings . -componentize-py -d ../../ws-wasi-runner/wit -w module componentize wasi_graphics_info -o pkg/et_ws_wasi_graphics_info.wasm +componentize-py -d ../../../generated/specs/wit -w module bindings . +componentize-py -d ../../../generated/specs/wit -w module componentize wasi_graphics_info -o pkg/et_ws_wasi_graphics_info.wasm cargo run -p et-cli -- module-package-json """ @@ -293,18 +426,22 @@ cargo run -p et-cli -- module-package-json --module-dir services/ws-modules/wasi """ [tasks.build-ws-pydata1-module] +depends = ["build-et-rest-client-wheel"] description = "Build the pydata1 Python workflow module" dir = "services/ws-modules/pydata1" run = """ uv build --wheel --out-dir pkg +cp ../../../generated/python-rest/dist/et_rest_client-*.whl pkg/ cargo run -p et-cli -- module-package-json """ [tasks.build-ws-pyface1-module] +depends = ["build-et-ws-wheel"] description = "Build the pyface1 Python face detection workflow module" dir = "services/ws-modules/pyface1" run = """ uv build --wheel --out-dir pkg +cp ../../../generated/python-ws/dist/et_ws-*.whl pkg/ cargo run -p et-cli -- module-package-json """ diff --git a/CLAUDE.md b/CLAUDE.md index 16376d7..fedff92 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -41,6 +41,31 @@ This is a WebSocket-based edge computing framework. The server is a hub: it maintains an agent registry, routes messages between agents, provides agents with storage, and serves node module packages as static files to browsers. +### WebSocket protocol + +The Rust source of truth is `WsMessage` in `libs/edge-toolkit/src/ws.rs`. The full +message catalogue (every wire `type`, request/response shape, and shared schema) is +regenerated by `mise run gen-ws-spec` into [`generated/specs/ws.yaml`](generated/specs/ws.yaml) +(AsyncAPI 3.0). Generated language clients sit next to it under `generated/dart-ws/`, +`generated/python-ws/`, and `generated/specs/wit/deps/et-ws-messages/`. + +The server acts as a pure hub for **unrecognised** frames: any text the server can't parse as a +known `WsMessage`, and any binary frame, is forwarded verbatim to every other connected agent +(with a single `info!` log per broadcast). This lets agents use arbitrary out-of-band payloads +without needing a server-side enum entry. Explicit `et-broadcast-message` still wraps payloads in +an `et-agent-message` envelope as before. Both paths require the sender to be a connected agent; +frames from unassigned clients are dropped. + +### REST surface + +Every HTTP endpoint exposed by ws-server (health probe, module discovery, module assets, +per-agent storage) is annotated with `#[utoipa::path]` in its handler. `mise run gen-ws-spec` +emits the aggregated [`generated/specs/rest.yaml`](generated/specs/rest.yaml) (OpenAPI 3.0) +and the typed Rust client at [`generated/rust-rest/`](generated/rust-rest/) via `progenitor` +— consumed by `et-ws-wasi-runner` (native) and the browser `data1` module (WASM). The client +crate's `tracing` feature is on by default (W3C `traceparent` injected on every request via a +progenitor pre-hook) and off in WASM consumers, which switch reqwest to its `fetch()` transport. + ### Services (`services/`) - **ws-server** — Actix-web entry point; wires together the four concerns below. Loads registry from diff --git a/Cargo.lock b/Cargo.lock index cce5108..71c4342 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -467,6 +467,43 @@ dependencies = [ "syn", ] +[[package]] +name = "asyncapi-rust" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80ca4d85b1ab04475b03e84a38385786632200879127fd4cbc7da83c8fa7fe72" +dependencies = [ + "asyncapi-rust-codegen", + "asyncapi-rust-models", + "schemars 1.2.1", + "serde", + "serde_json", +] + +[[package]] +name = "asyncapi-rust-codegen" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc20d497b19e9f06ba3fad2938f6bf696184a72d44d8e9d44208303409e3a0af" +dependencies = [ + "proc-macro2", + "quote", + "schemars 1.2.1", + "serde", + "serde_json", + "syn", +] + +[[package]] +name = "asyncapi-rust-models" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "076c77e2a619f709f613e50ea1bcb58211539cbf2f1eaa9fad140e93a15f8950" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -829,7 +866,7 @@ version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "af491d569909a7e4dee0ad7db7f5341fef5c614d5b8ec8cf765732aba3cff681" dependencies = [ - "unicode-width", + "unicode-width 0.2.2", ] [[package]] @@ -838,6 +875,16 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + [[package]] name = "const-hex" version = "1.19.0" @@ -892,6 +939,12 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "countme" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7704b5fdd17b18ae31c4c1da5a2e0305a2bf17b5249300a9ee9ed7b72114c636" + [[package]] name = "cpp_demangle" version = "0.4.5" @@ -1288,6 +1341,12 @@ dependencies = [ "unicode-xid", ] +[[package]] +name = "diff" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" + [[package]] name = "digest" version = "0.10.7" @@ -1366,14 +1425,22 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + [[package]] name = "edge-toolkit" version = "0.1.0" dependencies = [ + "asyncapi-rust", "base64", "lets_find_up", "log", "rstest", + "schemars 1.2.1", "secrecy", "serde", "serde-env", @@ -1440,6 +1507,38 @@ dependencies = [ "toml 0.8.23", ] +[[package]] +name = "et-int-gen" +version = "0.1.0" +dependencies = [ + "asyncapi-rust", + "clap", + "edge-toolkit", + "et-modules-service", + "et-storage-service", + "et-ws-server", + "heck", + "kdl 6.5.0", + "openapiv3", + "pretty_yaml", + "prettyplease", + "progenitor", + "quote", + "schemars 1.2.1", + "semver", + "serde", + "serde_json", + "serde_yaml", + "syn", + "thiserror 2.0.18", + "tree-sitter", + "tree-sitter-zig", + "ureq 2.12.1", + "utoipa", + "wit-encoder", + "wit-parser 0.249.0", +] + [[package]] name = "et-modules-service" version = "0.1.0" @@ -1453,6 +1552,7 @@ dependencies = [ "serde_default", "serde_json", "tracing", + "utoipa", ] [[package]] @@ -1480,6 +1580,22 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "et-rest-client" +version = "0.1.0" +dependencies = [ + "bytes", + "futures-core", + "opentelemetry", + "opentelemetry-http", + "progenitor-client", + "reqwest 0.13.3", + "serde", + "serde_urlencoded", + "tracing", + "tracing-opentelemetry", +] + [[package]] name = "et-storage-service" version = "0.1.0" @@ -1493,6 +1609,7 @@ dependencies = [ "serde_default", "tokio", "tracing", + "utoipa", ] [[package]] @@ -1563,7 +1680,9 @@ name = "et-ws-data1" version = "0.1.0" dependencies = [ "edge-toolkit", + "et-rest-client", "et-ws-wasm-agent", + "futures-util", "js-sys", "serde", "serde-wasm-bindgen", @@ -1712,6 +1831,7 @@ dependencies = [ "tracing", "tracing-actix-web", "tracing-subscriber", + "utoipa", "uuid", ] @@ -1721,6 +1841,7 @@ version = "0.1.0" dependencies = [ "actix-web", "actix-ws", + "bytes", "chrono", "edge-toolkit", "futures-util", @@ -1757,10 +1878,15 @@ dependencies = [ "actix", "actix-rt", "actix-web", + "edge-toolkit", "et-modules-service", "et-storage-service", "et-ws-service", + "futures-util", + "serde_json", "tempfile", + "tokio", + "tokio-tungstenite", "tracing-actix-web", ] @@ -1806,6 +1932,7 @@ dependencies = [ "bytemuck", "edge-toolkit", "et-otlp", + "et-rest-client", "et-ws-test-server", "futures-util", "opentelemetry", @@ -1813,7 +1940,7 @@ dependencies = [ "ort", "otlp-mock", "pollster", - "reqwest", + "reqwest 0.13.3", "rstest", "serde", "serde-env", @@ -2213,6 +2340,12 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + [[package]] name = "hashbrown" version = "0.15.5" @@ -2371,7 +2504,6 @@ dependencies = [ "tokio", "tokio-rustls", "tower-service", - "webpki-roots", ] [[package]] @@ -2617,6 +2749,55 @@ dependencies = [ "cc", ] +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys", + "log", + "simd_cesu8", + "thiserror 2.0.18", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn", +] + [[package]] name = "jobserver" version = "0.1.34" @@ -2639,6 +2820,29 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "kdl" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e03e2e96c5926fe761088d66c8c2aee3a4352a2573f4eaca50043ad130af9117" +dependencies = [ + "miette 5.10.0", + "nom", + "thiserror 1.0.69", +] + +[[package]] +name = "kdl" +version = "6.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81a29e7b50079ff44549f68c0becb1c73d7f6de2a4ea952da77966daf3d4761e" +dependencies = [ + "kdl 4.7.1", + "miette 7.6.0", + "num", + "winnow 0.6.24", +] + [[package]] name = "language-tags" version = "0.3.2" @@ -2832,6 +3036,39 @@ dependencies = [ "libc", ] +[[package]] +name = "miette" +version = "5.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59bb584eaeeab6bd0226ccf3509a69d7936d148cf3d036ad350abe35e8c6856e" +dependencies = [ + "miette-derive", + "once_cell", + "thiserror 1.0.69", + "unicode-width 0.1.14", +] + +[[package]] +name = "miette" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f98efec8807c63c752b5bd61f862c165c115b0a35685bdcfd9238c7aeb592b7" +dependencies = [ + "cfg-if", + "unicode-width 0.1.14", +] + +[[package]] +name = "miette-derive" +version = "5.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49e7bc1560b95a3c4a25d03de42fe76ca718ab92d1a22a55b9b4cf67b3ae635c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "mime" version = "0.3.17" @@ -2989,6 +3226,20 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + [[package]] name = "num-bigint" version = "0.4.6" @@ -2999,6 +3250,15 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + [[package]] name = "num-conv" version = "0.2.1" @@ -3014,6 +3274,28 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -3136,11 +3418,22 @@ version = "11.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" +[[package]] +name = "openapiv3" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c8d427828b22ae1fff2833a03d8486c2c881367f1c336349f307f321e7f4d05" +dependencies = [ + "indexmap", + "serde", + "serde_json", +] + [[package]] name = "openssl" -version = "0.10.79" +version = "0.10.80" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf0b434746ee2832f4f0baf10137e1cabb18cbe6912c69e2e33263c45250f542" +checksum = "a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967" dependencies = [ "bitflags", "cfg-if", @@ -3169,9 +3462,9 @@ checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" [[package]] name = "openssl-sys" -version = "0.9.115" +version = "0.9.116" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "158fe5b292746440aa6e7a7e690e55aeb72d41505e2804c23c6973ad0e9c9781" +checksum = "f28a22dc7140cda5f096e5e7724a6962ca81a7f8bfd2979f9b18c11af56318c4" dependencies = [ "cc", "libc", @@ -3215,7 +3508,7 @@ dependencies = [ "bytes", "http 1.4.0", "opentelemetry", - "reqwest", + "reqwest 0.12.28", ] [[package]] @@ -3230,7 +3523,7 @@ dependencies = [ "opentelemetry-proto", "opentelemetry_sdk", "prost", - "reqwest", + "reqwest 0.12.28", "serde_json", "thiserror 2.0.18", ] @@ -3296,7 +3589,7 @@ dependencies = [ "pkg-config", "sha2", "tar", - "ureq", + "ureq 3.3.0", ] [[package]] @@ -3472,6 +3765,27 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa" +[[package]] +name = "pretty_assertions" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d" +dependencies = [ + "diff", + "yansi", +] + +[[package]] +name = "pretty_yaml" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f5db2b0044c0dae209c173c6049f210c9449092b07304c9c8b8d555c606e93e" +dependencies = [ + "rowan", + "tiny_pretty", + "yaml_parser", +] + [[package]] name = "prettyplease" version = "0.2.37" @@ -3528,6 +3842,72 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" +[[package]] +name = "progenitor" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8ba1d77160e6d5c95bdf0792527f76bf528791093fa83015bc2908a0ba9d076" +dependencies = [ + "progenitor-client", + "progenitor-impl", + "progenitor-macro", +] + +[[package]] +name = "progenitor-client" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e8a874cf25a33cac7a01b9c1de87bcfbc8aea93f3156d09dcc3bee516a78926" +dependencies = [ + "bytes", + "futures-core", + "percent-encoding", + "reqwest 0.13.3", + "serde", + "serde_json", + "serde_urlencoded", +] + +[[package]] +name = "progenitor-impl" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6e349eed84b9a1a6a5dbe478d335e3df73d32a93c5eefe571c9b8cb298aab5d" +dependencies = [ + "heck", + "http 1.4.0", + "indexmap", + "openapiv3", + "proc-macro2", + "quote", + "regex", + "schemars 0.8.22", + "serde", + "serde_json", + "syn", + "thiserror 2.0.18", + "typify", + "unicode-ident", +] + +[[package]] +name = "progenitor-macro" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "efa969a1349979c5f64347f204e794781a86d738206d75672e6c9493f5910002" +dependencies = [ + "openapiv3", + "proc-macro2", + "progenitor-impl", + "quote", + "schemars 0.8.22", + "serde", + "serde_json", + "serde_tokenstream", + "serde_yaml", + "syn", +] + [[package]] name = "proptest" version = "1.11.0" @@ -3659,6 +4039,7 @@ version = "0.11.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" dependencies = [ + "aws-lc-rs", "bytes", "getrandom 0.3.4", "lru-slab", @@ -3873,8 +4254,28 @@ dependencies = [ ] [[package]] -name = "regalloc2" -version = "0.15.1" +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "regalloc2" +version = "0.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "de2c52737737f8609e94f975dee22854a2d5c125772d4b1cf292120f4d45c186" dependencies = [ @@ -3921,6 +4322,16 @@ version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +[[package]] +name = "regress" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "158a764437582235e3501f683b93a0a6f8d825d04a789dbe5ed30b8799b8908a" +dependencies = [ + "hashbrown 0.16.1", + "memchr", +] + [[package]] name = "relative-path" version = "1.9.3" @@ -3948,6 +4359,39 @@ dependencies = [ "http-body", "http-body-util", "hyper", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "reqwest" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62e0021ea2c22aed41653bc7e1419abb2c97e038ff2c33d0e1309e49a97deec0" +dependencies = [ + "base64", + "bytes", + "futures-core", + "futures-util", + "http 1.4.0", + "http-body", + "http-body-util", + "hyper", "hyper-rustls", "hyper-util", "js-sys", @@ -3957,6 +4401,7 @@ dependencies = [ "quinn", "rustls", "rustls-pki-types", + "rustls-platform-verifier", "serde", "serde_json", "serde_urlencoded", @@ -3972,7 +4417,6 @@ dependencies = [ "wasm-bindgen-futures", "wasm-streams", "web-sys", - "webpki-roots", ] [[package]] @@ -3989,6 +4433,18 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rowan" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "417a3a9f582e349834051b8a10c8d71ca88da4211e4093528e36b9845f6b5f21" +dependencies = [ + "countme", + "hashbrown 0.14.5", + "rustc-hash 1.1.0", + "text-size", +] + [[package]] name = "rstest" version = "0.26.1" @@ -4106,6 +4562,18 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-native-certs" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + [[package]] name = "rustls-pki-types" version = "1.14.1" @@ -4116,6 +4584,33 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-platform-verifier" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +dependencies = [ + "core-foundation", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + [[package]] name = "rustls-webpki" version = "0.103.13" @@ -4158,6 +4653,57 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "schemars" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" +dependencies = [ + "chrono", + "dyn-clone", + "schemars_derive 0.8.22", + "serde", + "serde_json", + "uuid", +] + +[[package]] +name = "schemars" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +dependencies = [ + "dyn-clone", + "ref-cast", + "schemars_derive 1.2.1", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn", +] + +[[package]] +name = "schemars_derive" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d115b50f4aaeea07e79c1912f645c7513d81715d0420f8bc77a18c6260b307f" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -4280,12 +4826,24 @@ dependencies = [ "syn", ] +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "serde_json" version = "1.0.149" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" dependencies = [ + "indexmap", "itoa", "memchr", "serde", @@ -4293,6 +4851,19 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_norway" +version = "0.9.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e408f29489b5fd500fab51ff1484fc859bb655f32c671f307dcd733b72e8168c" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml-norway", +] + [[package]] name = "serde_spanned" version = "0.6.9" @@ -4311,6 +4882,18 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_tokenstream" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c49585c52c01f13c5c2ebb333f14f6885d76daa768d8a037d28017ec538c69" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -4400,6 +4983,22 @@ version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" +[[package]] +name = "simd_cesu8" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "slab" version = "0.4.12" @@ -4473,6 +5072,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b2231b7c3057d5e4ad0156fb3dc807d900806020c5ffa3ee6ff2c8c76fb8520" + [[package]] name = "strsim" version = "0.11.1" @@ -4571,6 +5176,12 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "text-size" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f18aa187839b2bdb1ad2fa35ead8c4c2976b64e4363c386d45ac0f7ee85c9233" + [[package]] name = "thiserror" version = "1.0.69" @@ -4651,6 +5262,12 @@ dependencies = [ "time-core", ] +[[package]] +name = "tiny_pretty" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650d82e943da333637be9f1567d33d605e76810a26464edfd7ae74f7ef181e95" + [[package]] name = "tinystr" version = "0.8.3" @@ -5032,6 +5649,36 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "tree-sitter" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78f873475d258561b06f1c595d93308a7ed124d9977cb26b148c2084a4a3cc87" +dependencies = [ + "cc", + "regex", + "regex-syntax", + "serde_json", + "streaming-iterator", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-language" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "009994f150cc0cd50ff54917d5bc8bffe8cad10ca10d81c34da2ec421ae61782" + +[[package]] +name = "tree-sitter-zig" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab11fc124851b0db4dd5e55983bbd9631192e93238389dcd44521715e5d53e28" +dependencies = [ + "cc", + "tree-sitter-language", +] + [[package]] name = "try-lock" version = "0.2.5" @@ -5062,6 +5709,53 @@ version = "1.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" +[[package]] +name = "typify" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc0b89f47309feaeb23c4509c15c9a04234f7deccef6f96c3bfe95319819a304" +dependencies = [ + "typify-impl", + "typify-macro", +] + +[[package]] +name = "typify-impl" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7b026f540b148b81043c720889dbb942b08659aa8a43f624ac4f04dbfc1861" +dependencies = [ + "heck", + "log", + "proc-macro2", + "quote", + "regress", + "schemars 0.8.22", + "semver", + "serde", + "serde_json", + "syn", + "thiserror 2.0.18", + "unicode-ident", +] + +[[package]] +name = "typify-macro" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39ed96c57f06ae0839416b986921a98f18b220da63bbb243a8570a00c8492183" +dependencies = [ + "proc-macro2", + "quote", + "schemars 0.8.22", + "semver", + "serde", + "serde_json", + "serde_tokenstream", + "syn", + "typify-impl", +] + [[package]] name = "unarray" version = "0.1.4" @@ -5086,6 +5780,12 @@ version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + [[package]] name = "unicode-width" version = "0.2.2" @@ -5104,12 +5804,34 @@ version = "0.2.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" +[[package]] +name = "unsafe-libyaml-norway" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39abd59bf32521c7f2301b52d05a6a2c975b6003521cbd0c6dc1582f0a22104" + [[package]] name = "untrusted" version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" +[[package]] +name = "ureq" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d" +dependencies = [ + "base64", + "flate2", + "log", + "once_cell", + "rustls", + "rustls-pki-types", + "url", + "webpki-roots 0.26.11", +] + [[package]] name = "ureq" version = "3.3.0" @@ -5176,6 +5898,31 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "utoipa" +version = "5.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bde15df68e80b16c7d16b9616e80770ad158988daa56a27dccd1e55558b0160" +dependencies = [ + "indexmap", + "serde", + "serde_json", + "serde_norway", + "utoipa-gen", +] + +[[package]] +name = "utoipa-gen" +version = "5.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba0b99ee52df3028635d93840c797102da61f8a7bb3cf751032455895b52ef8" +dependencies = [ + "proc-macro2", + "quote", + "regex", + "syn", +] + [[package]] name = "uuid" version = "1.23.1" @@ -5432,9 +6179,9 @@ dependencies = [ [[package]] name = "wasm-streams" -version = "0.4.2" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" dependencies = [ "futures-util", "js-sys", @@ -5491,6 +6238,17 @@ dependencies = [ "semver", ] +[[package]] +name = "wasmparser" +version = "0.249.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30538cae9a794215f490b532df01c557e2e2bfac92569482554acd0992a102ea" +dependencies = [ + "bitflags", + "indexmap", + "semver", +] + [[package]] name = "wasmprinter" version = "0.246.2" @@ -5834,7 +6592,7 @@ dependencies = [ "bumpalo", "leb128fmt", "memchr", - "unicode-width", + "unicode-width 0.2.2", "wasm-encoder 0.248.0", ] @@ -5876,6 +6634,15 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.7", +] + [[package]] name = "webpki-roots" version = "1.0.7" @@ -6391,6 +7158,15 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" +[[package]] +name = "winnow" +version = "0.6.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d71a593cc5c42ad7876e2c1fda56f314f3754c084128833e64f1345ff8a03a" +dependencies = [ + "memchr", +] + [[package]] name = "winnow" version = "0.7.15" @@ -6561,6 +7337,19 @@ dependencies = [ "wit-parser 0.247.0", ] +[[package]] +name = "wit-encoder" +version = "0.249.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e9f2894b253e84a32f686e29b1625b3907bea19f0af39d9f2cadc11749ea598" +dependencies = [ + "id-arena", + "pretty_assertions", + "semver", + "serde", + "wit-parser 0.249.0", +] + [[package]] name = "wit-parser" version = "0.244.0" @@ -6617,6 +7406,25 @@ dependencies = [ "wasmparser 0.247.0", ] +[[package]] +name = "wit-parser" +version = "0.249.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50840f2e2cf170d910858089d7dfb3e97c6f9a0d6ec7bff7f7cc28f5aeacc15f" +dependencies = [ + "anyhow", + "hashbrown 0.17.1", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser 0.249.0", +] + [[package]] name = "witx" version = "0.9.1" @@ -6663,6 +7471,22 @@ dependencies = [ "rustix 1.1.4", ] +[[package]] +name = "yaml_parser" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71ebb04c72edf699612d543f6c421142527b85ac180156017fa26be49dc0762f" +dependencies = [ + "rowan", + "winnow 0.7.15", +] + +[[package]] +name = "yansi" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" + [[package]] name = "yasna" version = "0.5.2" diff --git a/Cargo.toml b/Cargo.toml index 4258e6f..c22e6d7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,8 +31,10 @@ members = [ "services/ws-wasm-agent", "services/ws-wasi-runner", "services/ws-test-server", + "utilities/int-gen", "utilities/cli", "utilities/onnx", + "generated/rust-rest", ] resolver = "2" @@ -41,17 +43,27 @@ actix = "0.13" actix-rt = "2" actix-web = { version = "4", features = ["rustls-0_23"] } anyhow = "1.0" +asyncapi-rust = "0.2" base64 = "0.22.1" +bytes = "1" chrono = { version = "0.4", features = ["serde"] } clap = { version = "4.4", features = ["derive"] } edge-toolkit = { path = "libs/edge-toolkit" } et-otlp = { path = "libs/et-otlp" } et-web = { path = "libs/web" } +heck = "0.5" +kdl = { version = "6", features = ["v1"] } lets_find_up = "0.0.4" log = "0.4" onnx-extractor = "0.3" +openapiv3 = "2" opentelemetry = "0.31" +pretty_yaml = "0.6" +prettyplease = "0.2" +progenitor = "0.14" +quote = "1" rstest = "0.26" +schemars = { version = "1.1", features = ["derive"] } secrecy = { version = "0.10.3", features = ["serde"] } serde = { version = "1.0.228", features = ["derive"] } serde-env = "0.3" @@ -59,6 +71,7 @@ serde-inline-default = "1.0" serde_default = "0.2" serde_json = "1" serde_yaml = "0.9" +syn = "2" tempfile = "3" thiserror = "2" toml = "0.8" @@ -70,4 +83,9 @@ tracing-actix-web = { version = "0.7", default-features = false, features = [ ] } tracing-opentelemetry = "0.32" tracing-subscriber = { version = "0.3", features = ["env-filter"] } +tree-sitter = "0.25" +tree-sitter-zig = "1" +ureq = "2" +utoipa = { version = "5", features = ["actix_extras", "yaml"] } uuid = { version = "1", features = ["serde", "v4", "v7"] } +wit-encoder = "0.249" diff --git a/README.md b/README.md index 959a58f..f66c923 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,26 @@ The default UX in the web-browser is also a loadable module located in A custom UX module can be used by setting the `ws-server` environment variable `MODULES_ROOT`. +## Protocol & API specs + +The WebSocket protocol and the ws-server REST surface are described by +machine-readable specs regenerated from their Rust sources of truth by +`mise run gen-specs`: + +- **WebSocket** (AsyncAPI 3.0): [`generated/specs/ws.yaml`](generated/specs/ws.yaml). + Source: `WsMessage` in `libs/edge-toolkit/src/ws.rs`. Generated clients: + [`generated/dart-ws/`](generated/dart-ws/), + [`generated/python-ws/`](generated/python-ws/), and the + `et:ws-messages` WIT under `generated/specs/wit/deps/`. +- **REST** (OpenAPI 3.0): [`generated/specs/rest.yaml`](generated/specs/rest.yaml). + Source: `#[utoipa::path]` annotations on the handlers in + `services/{ws-server,modules,storage}`. Typed Rust client at + [`generated/rust-rest/`](generated/rust-rest/) (consumed by + `et-ws-wasi-runner` and the browser `data1` module). + +See [`generated/README.md`](generated/README.md) for a full catalogue +of what's regenerated vs. hand-maintained under `generated/`. + ## Run e2e Run the end-to-end tests using Chrome: diff --git a/config/ast-grep/rules/no-inline-mod.yml b/config/ast-grep/rules/no-inline-mod.yml new file mode 100644 index 0000000..2d67213 --- /dev/null +++ b/config/ast-grep/rules/no-inline-mod.yml @@ -0,0 +1,12 @@ +id: no-inline-mod +language: Rust +severity: error +message: | + Inline `mod X { ... }` blocks are forbidden. Move the module body into a + separate file (`X.rs` or `X/mod.rs`) and declare it with `mod X;` instead. +rule: + kind: mod_item + has: + kind: declaration_list +ignores: + - generated/** diff --git a/config/ast-grep/rules/no-result-alias.yml b/config/ast-grep/rules/no-result-alias.yml new file mode 100644 index 0000000..f6d46c4 --- /dev/null +++ b/config/ast-grep/rules/no-result-alias.yml @@ -0,0 +1,15 @@ +id: no-result-alias +language: Rust +severity: error +message: | + Defining a `type Result<...>` alias shadows `std::result::Result` at file + scope and makes every `Result` ambiguous to readers. Spell the error + type at each call site instead (`Result`). +rule: + all: + - kind: type_item + - has: + kind: type_identifier + regex: "^Result$" +ignores: + - generated/** diff --git a/config/ast-grep/rules/no-rust-line-continuation.yml b/config/ast-grep/rules/no-rust-line-continuation.yml index a347e71..ff19077 100644 --- a/config/ast-grep/rules/no-rust-line-continuation.yml +++ b/config/ast-grep/rules/no-rust-line-continuation.yml @@ -7,3 +7,5 @@ message: | rule: kind: string_literal regex: '\\\n' +ignores: + - generated/** diff --git a/config/ast-grep/rules/no-shadow-result.yml b/config/ast-grep/rules/no-shadow-result.yml new file mode 100644 index 0000000..193ff4f --- /dev/null +++ b/config/ast-grep/rules/no-shadow-result.yml @@ -0,0 +1,16 @@ +id: no-shadow-result +language: Rust +severity: error +message: | + Importing `Result` via `use` shadows `std::result::Result` at file scope. + Reference the local alias by its qualified path at call sites + (e.g. `crate::Result` or `et_int_gen::Result`), or alias the + import (`use foo::Result as FooResult;`). +rule: + all: + - kind: use_declaration + - regex: '\bResult\b' + - not: + regex: '\bResult\s+as\s' +ignores: + - generated/** diff --git a/generated/.gitignore b/generated/.gitignore new file mode 100644 index 0000000..2c0212d --- /dev/null +++ b/generated/.gitignore @@ -0,0 +1,6 @@ +# Local build artifacts — checked-in generated source lives in +# {dart-ws,docs/asyncapi,python-ws}/ and is committed. +dart-ws/.dart_tool/ +dart-ws/pubspec.lock +python-ws/dist/ +python-ws/.venv/ diff --git a/generated/README.md b/generated/README.md new file mode 100644 index 0000000..9e0033f --- /dev/null +++ b/generated/README.md @@ -0,0 +1,72 @@ +# `generated/` + +Artifacts derived from a Rust source of truth — most are regenerated by +`mise run gen-specs`. A handful of files in this tree are **hand-maintained** +package metadata (package descriptions, dependency declarations, README +copy). They live here because they sit next to the regenerated code, not +because they're themselves generated. + +`mise run gen-specs-check` runs the generators and diffs the result against +the checked-in tree, so hand edits to a regenerated file will be reverted on +the next regen. + +## Hand-maintained + +These are committed under `generated/` for proximity to the generated code +they accompany, but the regeneration pipeline never writes to them. Edit them +directly: + +- `dart-ws/pubspec.yaml` — Dart package metadata. +- `dart-ws/README.md` +- `python-ws/pyproject.toml` — Python package metadata, version, deps. +- `python-ws/README.md` +- `python-ws/et_ws/__init__.py` — Package exports / re-imports. +- `python-rest/pyproject.toml` — Python package metadata, version, runtime + deps (`httpx`, `attrs`). `openapi-python-client` is invoked with + `--meta none`, so it only writes the source package and never touches + this file. +- `rust-rest/Cargo.toml` — Crate name, deps, workspace inheritance. + `progenitor` (driven in-process by `et-int-gen`) only writes + `src/lib.rs`, leaving this file untouched. +- `zig-rest/build.zig.zon` — Zig package manifest (name, version, + fingerprint). Regen writes only `src/et_rest_client.zig`. + +## Regenerated by `mise run gen-specs` + +- `specs/ws.yaml` — AsyncAPI 3.0 description of the WebSocket + protocol. Source: `edge_toolkit::ws::WsMessage` annotated for `asyncapi-rust`, + emitted by `utilities/int-gen`. +- `specs/rest.yaml` — OpenAPI 3.0 description of the ws-server's REST + surface. Source: `#[utoipa::path]` annotations on handlers in + `services/{ws-server,modules,storage}`, aggregated by `utilities/int-gen`. +- `specs/wit/world.wit` — The `et:ws-wasi` world consumed by + `services/ws-wasi-runner` and every WASI ws-module. Built with + `wit-encoder` in `utilities/int-gen/src/wit/world.rs`. +- `specs/wit/deps/et-ws-messages/messages.wit` — Typed WIT mirror of + `WsMessage` (records + tagged union). Built with `wit-encoder` in + `utilities/int-gen/src/wit/messages.rs`. +- `dart-ws/lib/ws_messages.dart` — Dart 3 sealed-class client for the WS + protocol. Pipeline: `WsMessage` JSON Schema → KDL + (`utilities/int-gen/src/kdl.rs`) → `dart-typegen` → `.dart`. +- `python-ws/et_ws/messages.py` — Pydantic v2 models for the WS protocol. + Pipeline: `WsMessage` JSON Schema → `datamodel-code-generator` → `.py`. +- `python-rest/et_rest_client/` — Typed Python (httpx-based) client for the + REST surface. Pipeline: `specs/rest.yaml` → `openapi-python-client` → + `et_rest_client` package. +- `rust-rest/src/lib.rs` — Typed Rust client for the REST surface, generated + by `progenitor` (driven in-process by `et-int-gen`) from + `specs/rest.yaml`. +- `zig-rest/src/et_rest_client.zig` — Typed Zig client for the REST + surface. Pipeline: `et-int-gen` writes `target/int-gen/rest.json`, + invokes `openapi2zig` as a subprocess, then uses `tree-sitter-zig` to + swap `requestRaw`'s body for a single `extern fn js_rest_request` call + (the JS host bridges that to `fetch()` via SharedArrayBuffer + + Atomics). Zig's lazy compilation + DCE drops the remaining + `std.http`/`std.Io` machinery automatically. + +## Fetched (upstream WIT) + +`specs/wit/deps/wasi-*/` — WASI interface packages pulled verbatim from +upstream at pinned tags/SHAs by `mise run fetch-wit-deps`. Not regenerated +by `gen-specs`; bump them by editing the version pins in +`utilities/int-gen/src/wit/upstream.rs` and rerunning `fetch-wit-deps`. diff --git a/generated/dart-ws/README.md b/generated/dart-ws/README.md new file mode 100644 index 0000000..fcb8eba --- /dev/null +++ b/generated/dart-ws/README.md @@ -0,0 +1,33 @@ +# et_ws — Dart client for the Edge Toolkit WS protocol + +`lib/ws_messages.dart` is regenerated from `edge_toolkit::ws::WsMessage` via +`mise run gen-ws-spec`. `pubspec.yaml` and this README are checked in by hand. + +## Usage + +Plain Dart 3 sealed classes — no `build_runner`, no extra dependencies. Add +this package as a path dependency in your consumer's `pubspec.yaml`: + +```yaml +dependencies: + et_ws: + path: ../../../generated/dart-ws +``` + +Then: + +```dart +import 'package:et_ws/ws_messages.dart'; + +final msg = WsMessage.fromJson(jsonDecode(rawText)); +switch (msg) { + case WsAgentMessage(:final fromAgentId, :final message): + handle(fromAgentId, message); + case WsListAgentsResponse(:final agents): + update(agents); + default: + // ignore +} + +ws.send(jsonEncode(WsBroadcastMessage(message: payload).toJson())); +``` diff --git a/generated/dart-ws/lib/ws_messages.dart b/generated/dart-ws/lib/ws_messages.dart new file mode 100644 index 0000000..6c14f69 --- /dev/null +++ b/generated/dart-ws/lib/ws_messages.dart @@ -0,0 +1,1121 @@ +// ignore_for_file: unnecessary_cast +final class AgentSummary { + final String agentId; + final String? lastKnownIp; + final AgentConnectionState state; + + const AgentSummary({ + required this.agentId, + this.lastKnownIp, + required this.state, + }); + + static AgentSummaryBuilder builder({ + required String agentId, + String? lastKnownIp, + required AgentConnectionState state, + }) => + AgentSummaryBuilder( + agentId: agentId, + lastKnownIp: lastKnownIp == null ? null : (lastKnownIp as String), + state: state, + ); + AgentSummaryBuilder toBuilder() => AgentSummaryBuilder( + agentId: agentId, + lastKnownIp: lastKnownIp == null ? null : (lastKnownIp as String), + state: state, + ); + + Map toJson() => { + "agent_id": agentId, + "last_known_ip": lastKnownIp, + "state": state.toJson(), + }; + factory AgentSummary.fromJson(Map json) => AgentSummary( + agentId: json["agent_id"] as String, + lastKnownIp: json["last_known_ip"] == null + ? null + : json["last_known_ip"] == null + ? null + : json["last_known_ip"] as String, + state: AgentConnectionState.fromJson(json["state"]), + ); + + @override + String toString() => "AgentSummary(" + "agentId: $agentId, " + "lastKnownIp: $lastKnownIp, " + "state: $state" + ")"; + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + if (other is! AgentSummary) { + return false; + } + if (agentId != other.agentId) { + return false; + } + if (lastKnownIp != other.lastKnownIp) { + return false; + } + if (state != other.state) { + return false; + } + return true; + } + + @override + int get hashCode => + Object.hashAll([agentId.hashCode, lastKnownIp?.hashCode, state.hashCode]); +} + +/// Builder class for [AgentSummary] +final class AgentSummaryBuilder { + String agentId; + String? lastKnownIp; + AgentConnectionState state; + + AgentSummaryBuilder({ + required this.agentId, + required this.lastKnownIp, + required this.state, + }); + + AgentSummary build() => AgentSummary( + agentId: agentId, + lastKnownIp: lastKnownIp == null ? null : (lastKnownIp as String), + state: state, + ); +} + +sealed class WsMessage { + const WsMessage(); + + WsMessageBuilder toBuilder(); + + Map toJson(); + factory WsMessage.fromJson(Map json) => + switch (json["type"]) { + "et-connect" => WsConnect.fromJson(json), + "et-connect-ack" => WsConnectAck.fromJson(json), + "et-alive" => WsAlive.fromJson(json), + "et-list-agents" => WsListAgents.fromJson(json), + "et-list-agents-response" => WsListAgentsResponse.fromJson(json), + "et-send-agent-message" => WsSendAgentMessage.fromJson(json), + "et-broadcast-message" => WsBroadcastMessage.fromJson(json), + "et-agent-message" => WsAgentMessage.fromJson(json), + "et-message-ack" => WsMessageAck.fromJson(json), + "et-message-status" => WsMessageStatus.fromJson(json), + "et-invalid" => WsInvalid.fromJson(json), + "et-client-event" => WsClientEvent.fromJson(json), + "et-response" => WsResponse.fromJson(json), + final other => throw ArgumentError("unknown discriminant: $other"), + }; +} + +sealed class WsMessageBuilder { + WsMessage build(); +} + +final class WsConnect extends WsMessage { + final String? agentId; + + const WsConnect({this.agentId}) : super(); + + static WsConnectBuilder builder({String? agentId}) => + WsConnectBuilder(agentId: agentId == null ? null : (agentId as String)); + WsConnectBuilder toBuilder() => + WsConnectBuilder(agentId: agentId == null ? null : (agentId as String)); + + @override + Map toJson() => {"agent_id": agentId, "type": "et-connect"}; + factory WsConnect.fromJson(Map json) => WsConnect( + agentId: json["agent_id"] == null + ? null + : json["agent_id"] == null + ? null + : json["agent_id"] as String, + ); + + @override + String toString() => "WsConnect(" + "agentId: $agentId" + ")"; + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + if (other is! WsConnect) { + return false; + } + if (agentId != other.agentId) { + return false; + } + return true; + } + + @override + int get hashCode => Object.hashAll([agentId?.hashCode]); +} + +/// Builder class for [WsConnect] +final class WsConnectBuilder extends WsMessageBuilder { + String? agentId; + + WsConnectBuilder({required this.agentId}) : super(); + + WsConnect build() => + WsConnect(agentId: agentId == null ? null : (agentId as String)); +} + +final class WsConnectAck extends WsMessage { + final String agentId; + final ConnectStatus status; + + const WsConnectAck({required this.agentId, required this.status}) : super(); + + static WsConnectAckBuilder builder({ + required String agentId, + required ConnectStatus status, + }) => + WsConnectAckBuilder(agentId: agentId, status: status); + WsConnectAckBuilder toBuilder() => + WsConnectAckBuilder(agentId: agentId, status: status); + + @override + Map toJson() => { + "agent_id": agentId, + "status": status.toJson(), + "type": "et-connect-ack", + }; + factory WsConnectAck.fromJson(Map json) => WsConnectAck( + agentId: json["agent_id"] as String, + status: ConnectStatus.fromJson(json["status"]), + ); + + @override + String toString() => "WsConnectAck(" + "agentId: $agentId, " + "status: $status" + ")"; + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + if (other is! WsConnectAck) { + return false; + } + if (agentId != other.agentId) { + return false; + } + if (status != other.status) { + return false; + } + return true; + } + + @override + int get hashCode => Object.hashAll([agentId.hashCode, status.hashCode]); +} + +/// Builder class for [WsConnectAck] +final class WsConnectAckBuilder extends WsMessageBuilder { + String agentId; + ConnectStatus status; + + WsConnectAckBuilder({required this.agentId, required this.status}) : super(); + + WsConnectAck build() => WsConnectAck(agentId: agentId, status: status); +} + +final class WsAlive extends WsMessage { + final String timestamp; + + const WsAlive({required this.timestamp}) : super(); + + static WsAliveBuilder builder({required String timestamp}) => + WsAliveBuilder(timestamp: timestamp); + WsAliveBuilder toBuilder() => WsAliveBuilder(timestamp: timestamp); + + @override + Map toJson() => {"timestamp": timestamp, "type": "et-alive"}; + factory WsAlive.fromJson(Map json) => + WsAlive(timestamp: json["timestamp"] as String); + + @override + String toString() => "WsAlive(" + "timestamp: $timestamp" + ")"; + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + if (other is! WsAlive) { + return false; + } + if (timestamp != other.timestamp) { + return false; + } + return true; + } + + @override + int get hashCode => Object.hashAll([timestamp.hashCode]); +} + +/// Builder class for [WsAlive] +final class WsAliveBuilder extends WsMessageBuilder { + String timestamp; + + WsAliveBuilder({required this.timestamp}) : super(); + + WsAlive build() => WsAlive(timestamp: timestamp); +} + +final class WsListAgents extends WsMessage { + const WsListAgents() : super(); + + static WsListAgentsBuilder builder() => WsListAgentsBuilder(); + WsListAgentsBuilder toBuilder() => WsListAgentsBuilder(); + + @override + Map toJson() => {"type": "et-list-agents"}; + factory WsListAgents.fromJson(Map json) => WsListAgents(); + + @override + String toString() => "WsListAgents(" + ")"; + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + if (other is! WsListAgents) { + return false; + } + return true; + } + + @override + int get hashCode => Object.hashAll([]); +} + +/// Builder class for [WsListAgents] +final class WsListAgentsBuilder extends WsMessageBuilder { + WsListAgentsBuilder() : super(); + + WsListAgents build() => WsListAgents(); +} + +final class WsListAgentsResponse extends WsMessage { + final List agents; + + const WsListAgentsResponse({required this.agents}) : super(); + + static WsListAgentsResponseBuilder builder({ + required List agents, + }) => + WsListAgentsResponseBuilder( + agents: agents.map((elem) => elem.toBuilder()).toList(), + ); + WsListAgentsResponseBuilder toBuilder() => WsListAgentsResponseBuilder( + agents: agents.map((elem) => elem.toBuilder()).toList(), + ); + + @override + Map toJson() => { + "agents": agents.map((inner) => inner.toJson()).toList(), + "type": "et-list-agents-response", + }; + factory WsListAgentsResponse.fromJson(Map json) => + WsListAgentsResponse( + agents: (json["agents"] as List) + .map( + (inner) => AgentSummary.fromJson(inner as Map), + ) + .toList(), + ); + + @override + String toString() => "WsListAgentsResponse(" + "agents: $agents" + ")"; + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + if (other is! WsListAgentsResponse) { + return false; + } + if (agents.length != other.agents.length) { + return false; + } + for (var i = 0; i < agents.length; i++) { + if (agents[i] != other.agents[i]) { + return false; + } + } + return true; + } + + @override + int get hashCode => + Object.hashAll([Object.hashAll(agents.map((elem) => elem.hashCode))]); +} + +/// Builder class for [WsListAgentsResponse] +final class WsListAgentsResponseBuilder extends WsMessageBuilder { + List agents; + + WsListAgentsResponseBuilder({required this.agents}) : super(); + + WsListAgentsResponse build() => + WsListAgentsResponse(agents: agents.map((elem) => elem.build()).toList()); +} + +final class WsSendAgentMessage extends WsMessage { + final Map message; + final String toAgentId; + + const WsSendAgentMessage({required this.message, required this.toAgentId}) + : super(); + + static WsSendAgentMessageBuilder builder({ + required Map message, + required String toAgentId, + }) => + WsSendAgentMessageBuilder( + message: message.map((key, value) => MapEntry(key, value)), + toAgentId: toAgentId, + ); + WsSendAgentMessageBuilder toBuilder() => WsSendAgentMessageBuilder( + message: message.map((key, value) => MapEntry(key, value)), + toAgentId: toAgentId, + ); + + @override + Map toJson() => { + "message": message.map((key, value) => MapEntry(key, value)), + "to_agent_id": toAgentId, + "type": "et-send-agent-message", + }; + factory WsSendAgentMessage.fromJson(Map json) => + WsSendAgentMessage( + message: (json["message"] as Map).map( + (key, value) => MapEntry(key as String, value as dynamic), + ), + toAgentId: json["to_agent_id"] as String, + ); + + @override + String toString() => "WsSendAgentMessage(" + "message: $message, " + "toAgentId: $toAgentId" + ")"; + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + if (other is! WsSendAgentMessage) { + return false; + } + if (message.length != other.message.length) { + return false; + } + for (final entry in message.entries) { + if (entry.value != other.message[entry.key]) { + return false; + } + } + if (toAgentId != other.toAgentId) { + return false; + } + return true; + } + + @override + int get hashCode => Object.hashAll([ + Object.hashAll( + message.entries.expand((entry) => [entry.key, entry.value.hashCode]), + ), + toAgentId.hashCode, + ]); +} + +/// Builder class for [WsSendAgentMessage] +final class WsSendAgentMessageBuilder extends WsMessageBuilder { + Map message; + String toAgentId; + + WsSendAgentMessageBuilder({required this.message, required this.toAgentId}) + : super(); + + WsSendAgentMessage build() => WsSendAgentMessage( + message: message.map((key, value) => MapEntry(key, value)), + toAgentId: toAgentId, + ); +} + +final class WsBroadcastMessage extends WsMessage { + final Map message; + + const WsBroadcastMessage({required this.message}) : super(); + + static WsBroadcastMessageBuilder builder({ + required Map message, + }) => + WsBroadcastMessageBuilder( + message: message.map((key, value) => MapEntry(key, value)), + ); + WsBroadcastMessageBuilder toBuilder() => WsBroadcastMessageBuilder( + message: message.map((key, value) => MapEntry(key, value)), + ); + + @override + Map toJson() => { + "message": message.map((key, value) => MapEntry(key, value)), + "type": "et-broadcast-message", + }; + factory WsBroadcastMessage.fromJson(Map json) => + WsBroadcastMessage( + message: (json["message"] as Map).map( + (key, value) => MapEntry(key as String, value as dynamic), + ), + ); + + @override + String toString() => "WsBroadcastMessage(" + "message: $message" + ")"; + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + if (other is! WsBroadcastMessage) { + return false; + } + if (message.length != other.message.length) { + return false; + } + for (final entry in message.entries) { + if (entry.value != other.message[entry.key]) { + return false; + } + } + return true; + } + + @override + int get hashCode => Object.hashAll([ + Object.hashAll( + message.entries.expand((entry) => [entry.key, entry.value.hashCode]), + ), + ]); +} + +/// Builder class for [WsBroadcastMessage] +final class WsBroadcastMessageBuilder extends WsMessageBuilder { + Map message; + + WsBroadcastMessageBuilder({required this.message}) : super(); + + WsBroadcastMessage build() => WsBroadcastMessage( + message: message.map((key, value) => MapEntry(key, value)), + ); +} + +final class WsAgentMessage extends WsMessage { + final String fromAgentId; + final Map message; + final String messageId; + final MessageScope scope; + final String serverReceivedAt; + + const WsAgentMessage({ + required this.fromAgentId, + required this.message, + required this.messageId, + required this.scope, + required this.serverReceivedAt, + }) : super(); + + static WsAgentMessageBuilder builder({ + required String fromAgentId, + required Map message, + required String messageId, + required MessageScope scope, + required String serverReceivedAt, + }) => + WsAgentMessageBuilder( + fromAgentId: fromAgentId, + message: message.map((key, value) => MapEntry(key, value)), + messageId: messageId, + scope: scope, + serverReceivedAt: serverReceivedAt, + ); + WsAgentMessageBuilder toBuilder() => WsAgentMessageBuilder( + fromAgentId: fromAgentId, + message: message.map((key, value) => MapEntry(key, value)), + messageId: messageId, + scope: scope, + serverReceivedAt: serverReceivedAt, + ); + + @override + Map toJson() => { + "from_agent_id": fromAgentId, + "message": message.map((key, value) => MapEntry(key, value)), + "message_id": messageId, + "scope": scope.toJson(), + "server_received_at": serverReceivedAt, + "type": "et-agent-message", + }; + factory WsAgentMessage.fromJson(Map json) => WsAgentMessage( + fromAgentId: json["from_agent_id"] as String, + message: (json["message"] as Map).map( + (key, value) => MapEntry(key as String, value as dynamic), + ), + messageId: json["message_id"] as String, + scope: MessageScope.fromJson(json["scope"]), + serverReceivedAt: json["server_received_at"] as String, + ); + + @override + String toString() => "WsAgentMessage(" + "fromAgentId: $fromAgentId, " + "message: $message, " + "messageId: $messageId, " + "scope: $scope, " + "serverReceivedAt: $serverReceivedAt" + ")"; + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + if (other is! WsAgentMessage) { + return false; + } + if (fromAgentId != other.fromAgentId) { + return false; + } + if (message.length != other.message.length) { + return false; + } + for (final entry in message.entries) { + if (entry.value != other.message[entry.key]) { + return false; + } + } + if (messageId != other.messageId) { + return false; + } + if (scope != other.scope) { + return false; + } + if (serverReceivedAt != other.serverReceivedAt) { + return false; + } + return true; + } + + @override + int get hashCode => Object.hashAll([ + fromAgentId.hashCode, + Object.hashAll( + message.entries.expand((entry) => [entry.key, entry.value.hashCode]), + ), + messageId.hashCode, + scope.hashCode, + serverReceivedAt.hashCode, + ]); +} + +/// Builder class for [WsAgentMessage] +final class WsAgentMessageBuilder extends WsMessageBuilder { + String fromAgentId; + Map message; + String messageId; + MessageScope scope; + String serverReceivedAt; + + WsAgentMessageBuilder({ + required this.fromAgentId, + required this.message, + required this.messageId, + required this.scope, + required this.serverReceivedAt, + }) : super(); + + WsAgentMessage build() => WsAgentMessage( + fromAgentId: fromAgentId, + message: message.map((key, value) => MapEntry(key, value)), + messageId: messageId, + scope: scope, + serverReceivedAt: serverReceivedAt, + ); +} + +final class WsMessageAck extends WsMessage { + final String messageId; + + const WsMessageAck({required this.messageId}) : super(); + + static WsMessageAckBuilder builder({required String messageId}) => + WsMessageAckBuilder(messageId: messageId); + WsMessageAckBuilder toBuilder() => WsMessageAckBuilder(messageId: messageId); + + @override + Map toJson() => { + "message_id": messageId, + "type": "et-message-ack", + }; + factory WsMessageAck.fromJson(Map json) => + WsMessageAck(messageId: json["message_id"] as String); + + @override + String toString() => "WsMessageAck(" + "messageId: $messageId" + ")"; + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + if (other is! WsMessageAck) { + return false; + } + if (messageId != other.messageId) { + return false; + } + return true; + } + + @override + int get hashCode => Object.hashAll([messageId.hashCode]); +} + +/// Builder class for [WsMessageAck] +final class WsMessageAckBuilder extends WsMessageBuilder { + String messageId; + + WsMessageAckBuilder({required this.messageId}) : super(); + + WsMessageAck build() => WsMessageAck(messageId: messageId); +} + +final class WsMessageStatus extends WsMessage { + final String detail; + final String? messageId; + final MessageDeliveryStatus status; + + const WsMessageStatus({ + required this.detail, + this.messageId, + required this.status, + }) : super(); + + static WsMessageStatusBuilder builder({ + required String detail, + String? messageId, + required MessageDeliveryStatus status, + }) => + WsMessageStatusBuilder( + detail: detail, + messageId: messageId == null ? null : (messageId as String), + status: status, + ); + WsMessageStatusBuilder toBuilder() => WsMessageStatusBuilder( + detail: detail, + messageId: messageId == null ? null : (messageId as String), + status: status, + ); + + @override + Map toJson() => { + "detail": detail, + "message_id": messageId, + "status": status.toJson(), + "type": "et-message-status", + }; + factory WsMessageStatus.fromJson(Map json) => + WsMessageStatus( + detail: json["detail"] as String, + messageId: json["message_id"] == null + ? null + : json["message_id"] == null + ? null + : json["message_id"] as String, + status: MessageDeliveryStatus.fromJson(json["status"]), + ); + + @override + String toString() => "WsMessageStatus(" + "detail: $detail, " + "messageId: $messageId, " + "status: $status" + ")"; + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + if (other is! WsMessageStatus) { + return false; + } + if (detail != other.detail) { + return false; + } + if (messageId != other.messageId) { + return false; + } + if (status != other.status) { + return false; + } + return true; + } + + @override + int get hashCode => + Object.hashAll([detail.hashCode, messageId?.hashCode, status.hashCode]); +} + +/// Builder class for [WsMessageStatus] +final class WsMessageStatusBuilder extends WsMessageBuilder { + String detail; + String? messageId; + MessageDeliveryStatus status; + + WsMessageStatusBuilder({ + required this.detail, + required this.messageId, + required this.status, + }) : super(); + + WsMessageStatus build() => WsMessageStatus( + detail: detail, + messageId: messageId == null ? null : (messageId as String), + status: status, + ); +} + +final class WsInvalid extends WsMessage { + final String detail; + final String? messageId; + + const WsInvalid({required this.detail, this.messageId}) : super(); + + static WsInvalidBuilder builder({ + required String detail, + String? messageId, + }) => + WsInvalidBuilder( + detail: detail, + messageId: messageId == null ? null : (messageId as String), + ); + WsInvalidBuilder toBuilder() => WsInvalidBuilder( + detail: detail, + messageId: messageId == null ? null : (messageId as String), + ); + + @override + Map toJson() => { + "detail": detail, + "message_id": messageId, + "type": "et-invalid", + }; + factory WsInvalid.fromJson(Map json) => WsInvalid( + detail: json["detail"] as String, + messageId: json["message_id"] == null + ? null + : json["message_id"] == null + ? null + : json["message_id"] as String, + ); + + @override + String toString() => "WsInvalid(" + "detail: $detail, " + "messageId: $messageId" + ")"; + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + if (other is! WsInvalid) { + return false; + } + if (detail != other.detail) { + return false; + } + if (messageId != other.messageId) { + return false; + } + return true; + } + + @override + int get hashCode => Object.hashAll([detail.hashCode, messageId?.hashCode]); +} + +/// Builder class for [WsInvalid] +final class WsInvalidBuilder extends WsMessageBuilder { + String detail; + String? messageId; + + WsInvalidBuilder({required this.detail, required this.messageId}) : super(); + + WsInvalid build() => WsInvalid( + detail: detail, + messageId: messageId == null ? null : (messageId as String), + ); +} + +final class WsClientEvent extends WsMessage { + final String action; + final String capability; + final Map details; + + const WsClientEvent({ + required this.action, + required this.capability, + required this.details, + }) : super(); + + static WsClientEventBuilder builder({ + required String action, + required String capability, + required Map details, + }) => + WsClientEventBuilder( + action: action, + capability: capability, + details: details.map((key, value) => MapEntry(key, value)), + ); + WsClientEventBuilder toBuilder() => WsClientEventBuilder( + action: action, + capability: capability, + details: details.map((key, value) => MapEntry(key, value)), + ); + + @override + Map toJson() => { + "action": action, + "capability": capability, + "details": details.map((key, value) => MapEntry(key, value)), + "type": "et-client-event", + }; + factory WsClientEvent.fromJson(Map json) => WsClientEvent( + action: json["action"] as String, + capability: json["capability"] as String, + details: (json["details"] as Map).map( + (key, value) => MapEntry(key as String, value as dynamic), + ), + ); + + @override + String toString() => "WsClientEvent(" + "action: $action, " + "capability: $capability, " + "details: $details" + ")"; + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + if (other is! WsClientEvent) { + return false; + } + if (action != other.action) { + return false; + } + if (capability != other.capability) { + return false; + } + if (details.length != other.details.length) { + return false; + } + for (final entry in details.entries) { + if (entry.value != other.details[entry.key]) { + return false; + } + } + return true; + } + + @override + int get hashCode => Object.hashAll([ + action.hashCode, + capability.hashCode, + Object.hashAll( + details.entries.expand((entry) => [entry.key, entry.value.hashCode]), + ), + ]); +} + +/// Builder class for [WsClientEvent] +final class WsClientEventBuilder extends WsMessageBuilder { + String action; + String capability; + Map details; + + WsClientEventBuilder({ + required this.action, + required this.capability, + required this.details, + }) : super(); + + WsClientEvent build() => WsClientEvent( + action: action, + capability: capability, + details: details.map((key, value) => MapEntry(key, value)), + ); +} + +final class WsResponse extends WsMessage { + final String message; + + const WsResponse({required this.message}) : super(); + + static WsResponseBuilder builder({required String message}) => + WsResponseBuilder(message: message); + WsResponseBuilder toBuilder() => WsResponseBuilder(message: message); + + @override + Map toJson() => {"message": message, "type": "et-response"}; + factory WsResponse.fromJson(Map json) => + WsResponse(message: json["message"] as String); + + @override + String toString() => "WsResponse(" + "message: $message" + ")"; + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + if (other is! WsResponse) { + return false; + } + if (message != other.message) { + return false; + } + return true; + } + + @override + int get hashCode => Object.hashAll([message.hashCode]); +} + +/// Builder class for [WsResponse] +final class WsResponseBuilder extends WsMessageBuilder { + String message; + + WsResponseBuilder({required this.message}) : super(); + + WsResponse build() => WsResponse(message: message); +} + +enum AgentConnectionState { + connected, + disconnected; + + factory AgentConnectionState.fromJson(dynamic json) => switch (json) { + "connected" => AgentConnectionState.connected, + "disconnected" => AgentConnectionState.disconnected, + final other => throw ArgumentError("Unknown variant: $other"), + }; + + dynamic toJson() => switch (this) { + AgentConnectionState.connected => "connected", + AgentConnectionState.disconnected => "disconnected", + }; + @override + String toString() => switch (this) { + AgentConnectionState.connected => "connected", + AgentConnectionState.disconnected => "disconnected", + }; +} + +enum ConnectStatus { + assigned, + reconnected; + + factory ConnectStatus.fromJson(dynamic json) => switch (json) { + "assigned" => ConnectStatus.assigned, + "reconnected" => ConnectStatus.reconnected, + final other => throw ArgumentError("Unknown variant: $other"), + }; + + dynamic toJson() => switch (this) { + ConnectStatus.assigned => "assigned", + ConnectStatus.reconnected => "reconnected", + }; + @override + String toString() => switch (this) { + ConnectStatus.assigned => "assigned", + ConnectStatus.reconnected => "reconnected", + }; +} + +enum MessageDeliveryStatus { + delivered, + queued, + acknowledged, + broadcast; + + factory MessageDeliveryStatus.fromJson(dynamic json) => switch (json) { + "delivered" => MessageDeliveryStatus.delivered, + "queued" => MessageDeliveryStatus.queued, + "acknowledged" => MessageDeliveryStatus.acknowledged, + "broadcast" => MessageDeliveryStatus.broadcast, + final other => throw ArgumentError("Unknown variant: $other"), + }; + + dynamic toJson() => switch (this) { + MessageDeliveryStatus.delivered => "delivered", + MessageDeliveryStatus.queued => "queued", + MessageDeliveryStatus.acknowledged => "acknowledged", + MessageDeliveryStatus.broadcast => "broadcast", + }; + @override + String toString() => switch (this) { + MessageDeliveryStatus.delivered => "delivered", + MessageDeliveryStatus.queued => "queued", + MessageDeliveryStatus.acknowledged => "acknowledged", + MessageDeliveryStatus.broadcast => "broadcast", + }; +} + +enum MessageScope { + direct, + broadcast; + + factory MessageScope.fromJson(dynamic json) => switch (json) { + "direct" => MessageScope.direct, + "broadcast" => MessageScope.broadcast, + final other => throw ArgumentError("Unknown variant: $other"), + }; + + dynamic toJson() => switch (this) { + MessageScope.direct => "direct", + MessageScope.broadcast => "broadcast", + }; + @override + String toString() => switch (this) { + MessageScope.direct => "direct", + MessageScope.broadcast => "broadcast", + }; +} diff --git a/generated/dart-ws/pubspec.yaml b/generated/dart-ws/pubspec.yaml new file mode 100644 index 0000000..ae0f8aa --- /dev/null +++ b/generated/dart-ws/pubspec.yaml @@ -0,0 +1,7 @@ +name: et_ws +description: Typed Dart client for the Edge Toolkit WebSocket protocol. +publish_to: none +version: 0.1.0 + +environment: + sdk: ^3.4.0 diff --git a/generated/python-rest/et_rest_client/__init__.py b/generated/python-rest/et_rest_client/__init__.py new file mode 100644 index 0000000..3ae42d2 --- /dev/null +++ b/generated/python-rest/et_rest_client/__init__.py @@ -0,0 +1,8 @@ +"""A client library for accessing Edge Toolkit REST API""" + +from .client import AuthenticatedClient, Client + +__all__ = ( + "AuthenticatedClient", + "Client", +) diff --git a/generated/python-rest/et_rest_client/api/__init__.py b/generated/python-rest/et_rest_client/api/__init__.py new file mode 100644 index 0000000..81f9fa2 --- /dev/null +++ b/generated/python-rest/et_rest_client/api/__init__.py @@ -0,0 +1 @@ +"""Contains methods for accessing the API""" diff --git a/generated/python-rest/et_rest_client/api/et_modules_service/__init__.py b/generated/python-rest/et_rest_client/api/et_modules_service/__init__.py new file mode 100644 index 0000000..2d7c0b2 --- /dev/null +++ b/generated/python-rest/et_rest_client/api/et_modules_service/__init__.py @@ -0,0 +1 @@ +"""Contains endpoint functions for accessing the API""" diff --git a/generated/python-rest/et_rest_client/api/et_modules_service/get_module_file.py b/generated/python-rest/et_rest_client/api/et_modules_service/get_module_file.py new file mode 100644 index 0000000..0e29d74 --- /dev/null +++ b/generated/python-rest/et_rest_client/api/et_modules_service/get_module_file.py @@ -0,0 +1,115 @@ +from http import HTTPStatus +from typing import Any +from urllib.parse import quote + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...types import Response + + +def _get_kwargs( + name: str, + path: str, +) -> dict[str, Any]: + + _kwargs: dict[str, Any] = { + "method": "get", + "url": "/modules/{name}/{path}".format( + name=quote(str(name), safe=""), + path=quote(str(path), safe=""), + ), + } + + return _kwargs + + +def _parse_response(*, client: AuthenticatedClient | Client, response: httpx.Response) -> Any | None: + if response.status_code == 200: + return None + + if response.status_code == 404: + return None + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response(*, client: AuthenticatedClient | Client, response: httpx.Response) -> Response[Any]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + name: str, + path: str, + *, + client: AuthenticatedClient | Client, +) -> Response[Any]: + """OpenAPI placeholder for `GET /modules/{name}/{path}`. `Files::new(...)` in + `configure()` actually serves these requests, but utoipa needs a function + to attach `#[utoipa::path]` to. The fn name shapes the generated client's + method name (via the OpenAPI `operationId`), so call it what callers want. + + Args: + name (str): + path (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Any] + """ + + kwargs = _get_kwargs( + name=name, + path=path, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +async def asyncio_detailed( + name: str, + path: str, + *, + client: AuthenticatedClient | Client, +) -> Response[Any]: + """OpenAPI placeholder for `GET /modules/{name}/{path}`. `Files::new(...)` in + `configure()` actually serves these requests, but utoipa needs a function + to attach `#[utoipa::path]` to. The fn name shapes the generated client's + method name (via the OpenAPI `operationId`), so call it what callers want. + + Args: + name (str): + path (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Any] + """ + + kwargs = _get_kwargs( + name=name, + path=path, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) diff --git a/generated/python-rest/et_rest_client/api/et_modules_service/list_modules_handler.py b/generated/python-rest/et_rest_client/api/et_modules_service/list_modules_handler.py new file mode 100644 index 0000000..c1d754f --- /dev/null +++ b/generated/python-rest/et_rest_client/api/et_modules_service/list_modules_handler.py @@ -0,0 +1,119 @@ +from http import HTTPStatus +from typing import Any, cast + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...types import Response + + +def _get_kwargs() -> dict[str, Any]: + + _kwargs: dict[str, Any] = { + "method": "get", + "url": "/modules/", + } + + return _kwargs + + +def _parse_response(*, client: AuthenticatedClient | Client, response: httpx.Response) -> list[str] | None: + if response.status_code == 200: + response_200 = cast(list[str], response.json()) + + return response_200 + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response(*, client: AuthenticatedClient | Client, response: httpx.Response) -> Response[list[str]]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + *, + client: AuthenticatedClient | Client, +) -> Response[list[str]]: + """ + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[list[str]] + """ + + kwargs = _get_kwargs() + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + *, + client: AuthenticatedClient | Client, +) -> list[str] | None: + """ + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + list[str] + """ + + return sync_detailed( + client=client, + ).parsed + + +async def asyncio_detailed( + *, + client: AuthenticatedClient | Client, +) -> Response[list[str]]: + """ + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[list[str]] + """ + + kwargs = _get_kwargs() + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + *, + client: AuthenticatedClient | Client, +) -> list[str] | None: + """ + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + list[str] + """ + + return ( + await asyncio_detailed( + client=client, + ) + ).parsed diff --git a/generated/python-rest/et_rest_client/api/et_storage_service/__init__.py b/generated/python-rest/et_rest_client/api/et_storage_service/__init__.py new file mode 100644 index 0000000..2d7c0b2 --- /dev/null +++ b/generated/python-rest/et_rest_client/api/et_storage_service/__init__.py @@ -0,0 +1 @@ +"""Contains endpoint functions for accessing the API""" diff --git a/generated/python-rest/et_rest_client/api/et_storage_service/get_file.py b/generated/python-rest/et_rest_client/api/et_storage_service/get_file.py new file mode 100644 index 0000000..b8826a2 --- /dev/null +++ b/generated/python-rest/et_rest_client/api/et_storage_service/get_file.py @@ -0,0 +1,115 @@ +from http import HTTPStatus +from typing import Any +from urllib.parse import quote + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...types import Response + + +def _get_kwargs( + agent_id: str, + filename: str, +) -> dict[str, Any]: + + _kwargs: dict[str, Any] = { + "method": "get", + "url": "/storage/{agent_id}/{filename}".format( + agent_id=quote(str(agent_id), safe=""), + filename=quote(str(filename), safe=""), + ), + } + + return _kwargs + + +def _parse_response(*, client: AuthenticatedClient | Client, response: httpx.Response) -> Any | None: + if response.status_code == 200: + return None + + if response.status_code == 404: + return None + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response(*, client: AuthenticatedClient | Client, response: httpx.Response) -> Response[Any]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + agent_id: str, + filename: str, + *, + client: AuthenticatedClient | Client, +) -> Response[Any]: + """OpenAPI placeholder for `GET /storage/{agent_id}/{filename}`. `Files::new(...)` + in `configure()` actually serves these requests; this fn exists only so + utoipa has somewhere to attach `#[utoipa::path]`. The fn name flows through + the OpenAPI `operationId` into the generated client's method name. + + Args: + agent_id (str): + filename (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Any] + """ + + kwargs = _get_kwargs( + agent_id=agent_id, + filename=filename, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +async def asyncio_detailed( + agent_id: str, + filename: str, + *, + client: AuthenticatedClient | Client, +) -> Response[Any]: + """OpenAPI placeholder for `GET /storage/{agent_id}/{filename}`. `Files::new(...)` + in `configure()` actually serves these requests; this fn exists only so + utoipa has somewhere to attach `#[utoipa::path]`. The fn name flows through + the OpenAPI `operationId` into the generated client's method name. + + Args: + agent_id (str): + filename (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Any] + """ + + kwargs = _get_kwargs( + agent_id=agent_id, + filename=filename, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) diff --git a/generated/python-rest/et_rest_client/api/et_storage_service/put_file.py b/generated/python-rest/et_rest_client/api/et_storage_service/put_file.py new file mode 100644 index 0000000..b961dfc --- /dev/null +++ b/generated/python-rest/et_rest_client/api/et_storage_service/put_file.py @@ -0,0 +1,126 @@ +from http import HTTPStatus +from typing import Any +from urllib.parse import quote + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...types import File, Response + + +def _get_kwargs( + agent_id: str, + filename: str, + *, + body: File, +) -> dict[str, Any]: + headers: dict[str, Any] = {} + + _kwargs: dict[str, Any] = { + "method": "put", + "url": "/storage/{agent_id}/{filename}".format( + agent_id=quote(str(agent_id), safe=""), + filename=quote(str(filename), safe=""), + ), + } + + _kwargs["content"] = body.payload + + headers["Content-Type"] = "application/octet-stream" + + _kwargs["headers"] = headers + return _kwargs + + +def _parse_response(*, client: AuthenticatedClient | Client, response: httpx.Response) -> Any | None: + if response.status_code == 200: + return None + + if response.status_code == 400: + return None + + if response.status_code == 404: + return None + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response(*, client: AuthenticatedClient | Client, response: httpx.Response) -> Response[Any]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + agent_id: str, + filename: str, + *, + client: AuthenticatedClient | Client, + body: File, +) -> Response[Any]: + """ + Args: + agent_id (str): + filename (str): + body (File): Phantom type used to label binary request/response bodies as + `string`/`binary` in the OpenAPI document. Never constructed at runtime. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Any] + """ + + kwargs = _get_kwargs( + agent_id=agent_id, + filename=filename, + body=body, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +async def asyncio_detailed( + agent_id: str, + filename: str, + *, + client: AuthenticatedClient | Client, + body: File, +) -> Response[Any]: + """ + Args: + agent_id (str): + filename (str): + body (File): Phantom type used to label binary request/response bodies as + `string`/`binary` in the OpenAPI document. Never constructed at runtime. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Any] + """ + + kwargs = _get_kwargs( + agent_id=agent_id, + filename=filename, + body=body, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) diff --git a/generated/python-rest/et_rest_client/api/et_ws_server/__init__.py b/generated/python-rest/et_rest_client/api/et_ws_server/__init__.py new file mode 100644 index 0000000..2d7c0b2 --- /dev/null +++ b/generated/python-rest/et_rest_client/api/et_ws_server/__init__.py @@ -0,0 +1 @@ +"""Contains endpoint functions for accessing the API""" diff --git a/generated/python-rest/et_rest_client/api/et_ws_server/health.py b/generated/python-rest/et_rest_client/api/et_ws_server/health.py new file mode 100644 index 0000000..599a79d --- /dev/null +++ b/generated/python-rest/et_rest_client/api/et_ws_server/health.py @@ -0,0 +1,120 @@ +from http import HTTPStatus +from typing import Any + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.health_response import HealthResponse +from ...types import Response + + +def _get_kwargs() -> dict[str, Any]: + + _kwargs: dict[str, Any] = { + "method": "get", + "url": "/health", + } + + return _kwargs + + +def _parse_response(*, client: AuthenticatedClient | Client, response: httpx.Response) -> HealthResponse | None: + if response.status_code == 200: + response_200 = HealthResponse.from_dict(response.json()) + + return response_200 + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response(*, client: AuthenticatedClient | Client, response: httpx.Response) -> Response[HealthResponse]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + *, + client: AuthenticatedClient | Client, +) -> Response[HealthResponse]: + """ + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[HealthResponse] + """ + + kwargs = _get_kwargs() + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + *, + client: AuthenticatedClient | Client, +) -> HealthResponse | None: + """ + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + HealthResponse + """ + + return sync_detailed( + client=client, + ).parsed + + +async def asyncio_detailed( + *, + client: AuthenticatedClient | Client, +) -> Response[HealthResponse]: + """ + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[HealthResponse] + """ + + kwargs = _get_kwargs() + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + *, + client: AuthenticatedClient | Client, +) -> HealthResponse | None: + """ + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + HealthResponse + """ + + return ( + await asyncio_detailed( + client=client, + ) + ).parsed diff --git a/generated/python-rest/et_rest_client/client.py b/generated/python-rest/et_rest_client/client.py new file mode 100644 index 0000000..1b7055a --- /dev/null +++ b/generated/python-rest/et_rest_client/client.py @@ -0,0 +1,268 @@ +import ssl +from typing import Any + +import httpx +from attrs import define, evolve, field + + +@define +class Client: + """A class for keeping track of data related to the API + + The following are accepted as keyword arguments and will be used to construct httpx Clients internally: + + ``base_url``: The base URL for the API, all requests are made to a relative path to this URL + + ``cookies``: A dictionary of cookies to be sent with every request + + ``headers``: A dictionary of headers to be sent with every request + + ``timeout``: The maximum amount of a time a request can take. API functions will raise + httpx.TimeoutException if this is exceeded. + + ``verify_ssl``: Whether or not to verify the SSL certificate of the API server. This should be True in production, + but can be set to False for testing purposes. + + ``follow_redirects``: Whether or not to follow redirects. Default value is False. + + ``httpx_args``: A dictionary of additional arguments to be passed to the ``httpx.Client`` and ``httpx.AsyncClient`` constructor. + + + Attributes: + raise_on_unexpected_status: Whether or not to raise an errors.UnexpectedStatus if the API returns a + status code that was not documented in the source OpenAPI document. Can also be provided as a keyword + argument to the constructor. + """ + + raise_on_unexpected_status: bool = field(default=False, kw_only=True) + _base_url: str = field(alias="base_url") + _cookies: dict[str, str] = field(factory=dict, kw_only=True, alias="cookies") + _headers: dict[str, str] = field(factory=dict, kw_only=True, alias="headers") + _timeout: httpx.Timeout | None = field(default=None, kw_only=True, alias="timeout") + _verify_ssl: str | bool | ssl.SSLContext = field(default=True, kw_only=True, alias="verify_ssl") + _follow_redirects: bool = field(default=False, kw_only=True, alias="follow_redirects") + _httpx_args: dict[str, Any] = field(factory=dict, kw_only=True, alias="httpx_args") + _client: httpx.Client | None = field(default=None, init=False) + _async_client: httpx.AsyncClient | None = field(default=None, init=False) + + def with_headers(self, headers: dict[str, str]) -> "Client": + """Get a new client matching this one with additional headers""" + if self._client is not None: + self._client.headers.update(headers) + if self._async_client is not None: + self._async_client.headers.update(headers) + return evolve(self, headers={**self._headers, **headers}) + + def with_cookies(self, cookies: dict[str, str]) -> "Client": + """Get a new client matching this one with additional cookies""" + if self._client is not None: + self._client.cookies.update(cookies) + if self._async_client is not None: + self._async_client.cookies.update(cookies) + return evolve(self, cookies={**self._cookies, **cookies}) + + def with_timeout(self, timeout: httpx.Timeout) -> "Client": + """Get a new client matching this one with a new timeout configuration""" + if self._client is not None: + self._client.timeout = timeout + if self._async_client is not None: + self._async_client.timeout = timeout + return evolve(self, timeout=timeout) + + def set_httpx_client(self, client: httpx.Client) -> "Client": + """Manually set the underlying httpx.Client + + **NOTE**: This will override any other settings on the client, including cookies, headers, and timeout. + """ + self._client = client + return self + + def get_httpx_client(self) -> httpx.Client: + """Get the underlying httpx.Client, constructing a new one if not previously set""" + if self._client is None: + self._client = httpx.Client( + base_url=self._base_url, + cookies=self._cookies, + headers=self._headers, + timeout=self._timeout, + verify=self._verify_ssl, + follow_redirects=self._follow_redirects, + **self._httpx_args, + ) + return self._client + + def __enter__(self) -> "Client": + """Enter a context manager for self.client—you cannot enter twice (see httpx docs)""" + self.get_httpx_client().__enter__() + return self + + def __exit__(self, *args: Any, **kwargs: Any) -> None: + """Exit a context manager for internal httpx.Client (see httpx docs)""" + self.get_httpx_client().__exit__(*args, **kwargs) + + def set_async_httpx_client(self, async_client: httpx.AsyncClient) -> "Client": + """Manually set the underlying httpx.AsyncClient + + **NOTE**: This will override any other settings on the client, including cookies, headers, and timeout. + """ + self._async_client = async_client + return self + + def get_async_httpx_client(self) -> httpx.AsyncClient: + """Get the underlying httpx.AsyncClient, constructing a new one if not previously set""" + if self._async_client is None: + self._async_client = httpx.AsyncClient( + base_url=self._base_url, + cookies=self._cookies, + headers=self._headers, + timeout=self._timeout, + verify=self._verify_ssl, + follow_redirects=self._follow_redirects, + **self._httpx_args, + ) + return self._async_client + + async def __aenter__(self) -> "Client": + """Enter a context manager for underlying httpx.AsyncClient—you cannot enter twice (see httpx docs)""" + await self.get_async_httpx_client().__aenter__() + return self + + async def __aexit__(self, *args: Any, **kwargs: Any) -> None: + """Exit a context manager for underlying httpx.AsyncClient (see httpx docs)""" + await self.get_async_httpx_client().__aexit__(*args, **kwargs) + + +@define +class AuthenticatedClient: + """A Client which has been authenticated for use on secured endpoints + + The following are accepted as keyword arguments and will be used to construct httpx Clients internally: + + ``base_url``: The base URL for the API, all requests are made to a relative path to this URL + + ``cookies``: A dictionary of cookies to be sent with every request + + ``headers``: A dictionary of headers to be sent with every request + + ``timeout``: The maximum amount of a time a request can take. API functions will raise + httpx.TimeoutException if this is exceeded. + + ``verify_ssl``: Whether or not to verify the SSL certificate of the API server. This should be True in production, + but can be set to False for testing purposes. + + ``follow_redirects``: Whether or not to follow redirects. Default value is False. + + ``httpx_args``: A dictionary of additional arguments to be passed to the ``httpx.Client`` and ``httpx.AsyncClient`` constructor. + + + Attributes: + raise_on_unexpected_status: Whether or not to raise an errors.UnexpectedStatus if the API returns a + status code that was not documented in the source OpenAPI document. Can also be provided as a keyword + argument to the constructor. + token: The token to use for authentication + prefix: The prefix to use for the Authorization header + auth_header_name: The name of the Authorization header + """ + + raise_on_unexpected_status: bool = field(default=False, kw_only=True) + _base_url: str = field(alias="base_url") + _cookies: dict[str, str] = field(factory=dict, kw_only=True, alias="cookies") + _headers: dict[str, str] = field(factory=dict, kw_only=True, alias="headers") + _timeout: httpx.Timeout | None = field(default=None, kw_only=True, alias="timeout") + _verify_ssl: str | bool | ssl.SSLContext = field(default=True, kw_only=True, alias="verify_ssl") + _follow_redirects: bool = field(default=False, kw_only=True, alias="follow_redirects") + _httpx_args: dict[str, Any] = field(factory=dict, kw_only=True, alias="httpx_args") + _client: httpx.Client | None = field(default=None, init=False) + _async_client: httpx.AsyncClient | None = field(default=None, init=False) + + token: str + prefix: str = "Bearer" + auth_header_name: str = "Authorization" + + def with_headers(self, headers: dict[str, str]) -> "AuthenticatedClient": + """Get a new client matching this one with additional headers""" + if self._client is not None: + self._client.headers.update(headers) + if self._async_client is not None: + self._async_client.headers.update(headers) + return evolve(self, headers={**self._headers, **headers}) + + def with_cookies(self, cookies: dict[str, str]) -> "AuthenticatedClient": + """Get a new client matching this one with additional cookies""" + if self._client is not None: + self._client.cookies.update(cookies) + if self._async_client is not None: + self._async_client.cookies.update(cookies) + return evolve(self, cookies={**self._cookies, **cookies}) + + def with_timeout(self, timeout: httpx.Timeout) -> "AuthenticatedClient": + """Get a new client matching this one with a new timeout configuration""" + if self._client is not None: + self._client.timeout = timeout + if self._async_client is not None: + self._async_client.timeout = timeout + return evolve(self, timeout=timeout) + + def set_httpx_client(self, client: httpx.Client) -> "AuthenticatedClient": + """Manually set the underlying httpx.Client + + **NOTE**: This will override any other settings on the client, including cookies, headers, and timeout. + """ + self._client = client + return self + + def get_httpx_client(self) -> httpx.Client: + """Get the underlying httpx.Client, constructing a new one if not previously set""" + if self._client is None: + self._headers[self.auth_header_name] = f"{self.prefix} {self.token}" if self.prefix else self.token + self._client = httpx.Client( + base_url=self._base_url, + cookies=self._cookies, + headers=self._headers, + timeout=self._timeout, + verify=self._verify_ssl, + follow_redirects=self._follow_redirects, + **self._httpx_args, + ) + return self._client + + def __enter__(self) -> "AuthenticatedClient": + """Enter a context manager for self.client—you cannot enter twice (see httpx docs)""" + self.get_httpx_client().__enter__() + return self + + def __exit__(self, *args: Any, **kwargs: Any) -> None: + """Exit a context manager for internal httpx.Client (see httpx docs)""" + self.get_httpx_client().__exit__(*args, **kwargs) + + def set_async_httpx_client(self, async_client: httpx.AsyncClient) -> "AuthenticatedClient": + """Manually set the underlying httpx.AsyncClient + + **NOTE**: This will override any other settings on the client, including cookies, headers, and timeout. + """ + self._async_client = async_client + return self + + def get_async_httpx_client(self) -> httpx.AsyncClient: + """Get the underlying httpx.AsyncClient, constructing a new one if not previously set""" + if self._async_client is None: + self._headers[self.auth_header_name] = f"{self.prefix} {self.token}" if self.prefix else self.token + self._async_client = httpx.AsyncClient( + base_url=self._base_url, + cookies=self._cookies, + headers=self._headers, + timeout=self._timeout, + verify=self._verify_ssl, + follow_redirects=self._follow_redirects, + **self._httpx_args, + ) + return self._async_client + + async def __aenter__(self) -> "AuthenticatedClient": + """Enter a context manager for underlying httpx.AsyncClient—you cannot enter twice (see httpx docs)""" + await self.get_async_httpx_client().__aenter__() + return self + + async def __aexit__(self, *args: Any, **kwargs: Any) -> None: + """Exit a context manager for underlying httpx.AsyncClient (see httpx docs)""" + await self.get_async_httpx_client().__aexit__(*args, **kwargs) diff --git a/generated/python-rest/et_rest_client/errors.py b/generated/python-rest/et_rest_client/errors.py new file mode 100644 index 0000000..5f92e76 --- /dev/null +++ b/generated/python-rest/et_rest_client/errors.py @@ -0,0 +1,16 @@ +"""Contains shared errors types that can be raised from API functions""" + + +class UnexpectedStatus(Exception): + """Raised by api functions when the response status an undocumented status and Client.raise_on_unexpected_status is True""" + + def __init__(self, status_code: int, content: bytes): + self.status_code = status_code + self.content = content + + super().__init__( + f"Unexpected status code: {status_code}\n\nResponse content:\n{content.decode(errors='ignore')}" + ) + + +__all__ = ["UnexpectedStatus"] diff --git a/generated/python-rest/et_rest_client/models/__init__.py b/generated/python-rest/et_rest_client/models/__init__.py new file mode 100644 index 0000000..dfed2ce --- /dev/null +++ b/generated/python-rest/et_rest_client/models/__init__.py @@ -0,0 +1,5 @@ +"""Contains all the data models used in inputs/outputs""" + +from .health_response import HealthResponse + +__all__ = ("HealthResponse",) diff --git a/generated/python-rest/et_rest_client/models/health_response.py b/generated/python-rest/et_rest_client/models/health_response.py new file mode 100644 index 0000000..c2f148e --- /dev/null +++ b/generated/python-rest/et_rest_client/models/health_response.py @@ -0,0 +1,70 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +T = TypeVar("T", bound="HealthResponse") + + +@_attrs_define +class HealthResponse: + """Server liveness probe response. Returned by `GET /health`. + + Attributes: + service (str): + status (str): + """ + + service: str + status: str + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + service = self.service + + status = self.status + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "service": service, + "status": status, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + service = d.pop("service") + + status = d.pop("status") + + health_response = cls( + service=service, + status=status, + ) + + health_response.additional_properties = d + return health_response + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/generated/python-rest/et_rest_client/types.py b/generated/python-rest/et_rest_client/types.py new file mode 100644 index 0000000..b64af09 --- /dev/null +++ b/generated/python-rest/et_rest_client/types.py @@ -0,0 +1,54 @@ +"""Contains some shared types for properties""" + +from collections.abc import Mapping, MutableMapping +from http import HTTPStatus +from typing import IO, BinaryIO, Generic, Literal, TypeVar + +from attrs import define + + +class Unset: + def __bool__(self) -> Literal[False]: + return False + + +UNSET: Unset = Unset() + +# The types that `httpx.Client(files=)` can accept, copied from that library. +FileContent = IO[bytes] | bytes | str +FileTypes = ( + # (filename, file (or bytes), content_type) + tuple[str | None, FileContent, str | None] + # (filename, file (or bytes), content_type, headers) + | tuple[str | None, FileContent, str | None, Mapping[str, str]] +) +RequestFiles = list[tuple[str, FileTypes]] + + +@define +class File: + """Contains information for file uploads""" + + payload: BinaryIO + file_name: str | None = None + mime_type: str | None = None + + def to_tuple(self) -> FileTypes: + """Return a tuple representation that httpx will accept for multipart/form-data""" + return self.file_name, self.payload, self.mime_type + + +T = TypeVar("T") + + +@define +class Response(Generic[T]): + """A response from an endpoint""" + + status_code: HTTPStatus + content: bytes + headers: MutableMapping[str, str] + parsed: T | None + + +__all__ = ["UNSET", "File", "FileTypes", "RequestFiles", "Response", "Unset"] diff --git a/generated/python-rest/pyproject.toml b/generated/python-rest/pyproject.toml new file mode 100644 index 0000000..9699f50 --- /dev/null +++ b/generated/python-rest/pyproject.toml @@ -0,0 +1,15 @@ +[project] +dependencies = ["attrs>=23", "httpx>=0.25"] +description = "Generated REST client for the Edge Toolkit ws-server API" +license = "Apache-2.0 OR MIT" +name = "et-rest-client" +requires-python = ">=3.10" +version = "0.1.0" + +[build-system] +build-backend = "uv_build" +requires = ["uv_build>=0.10.2,<0.11.0"] + +[tool.uv.build-backend] +module-name = "et_rest_client" +module-root = "" diff --git a/generated/python-ws/README.md b/generated/python-ws/README.md new file mode 100644 index 0000000..171cdab --- /dev/null +++ b/generated/python-ws/README.md @@ -0,0 +1,13 @@ +# et_ws — Pydantic client for the Edge Toolkit WS protocol + +`et_ws/messages.py` is regenerated from `edge_toolkit::ws::WsMessage` via +`mise run gen-python-ws`. This README and `pyproject.toml` are checked in by hand. + +## Build + +```sh +mise run build-et-ws-wheel +``` + +Produces `dist/et_ws--py3-none-any.whl`. Pyodide consumers install it via +`micropip.install` from a URL served by the ws-server. diff --git a/generated/python-ws/et_ws/__init__.py b/generated/python-ws/et_ws/__init__.py new file mode 100644 index 0000000..64655f5 --- /dev/null +++ b/generated/python-ws/et_ws/__init__.py @@ -0,0 +1,6 @@ +"""Pydantic models for the Edge Toolkit WS protocol. + +`messages.py` is regenerated by `mise run gen-python-ws`. This file is static. +""" + +from et_ws.messages import * # noqa: F401,F403 diff --git a/generated/python-ws/et_ws/messages.py b/generated/python-ws/et_ws/messages.py new file mode 100644 index 0000000..3a74e9c --- /dev/null +++ b/generated/python-ws/et_ws/messages.py @@ -0,0 +1,143 @@ +from __future__ import annotations + +from enum import Enum +from typing import Any, Literal + +from pydantic import BaseModel, Field, RootModel + + +class WsConnect(BaseModel): + agent_id: str | None = None + type: Literal["et-connect"] + + +class WsAlive(BaseModel): + timestamp: str + type: Literal["et-alive"] + + +class WsListAgents(BaseModel): + type: Literal["et-list-agents"] + + +class WsSendAgentMessage(BaseModel): + message: Any = Field(..., description="Arbitrary JSON value (opaque to the protocol)") + to_agent_id: str + type: Literal["et-send-agent-message"] + + +class WsBroadcastMessage(BaseModel): + message: Any = Field(..., description="Arbitrary JSON value (opaque to the protocol)") + type: Literal["et-broadcast-message"] + + +class WsMessageAck(BaseModel): + message_id: str + type: Literal["et-message-ack"] + + +class WsInvalid(BaseModel): + detail: str + message_id: str | None = None + type: Literal["et-invalid"] + + +class WsClientEvent(BaseModel): + action: str + capability: str + details: Any = Field(..., description="Arbitrary JSON value (opaque to the protocol)") + type: Literal["et-client-event"] + + +class WsResponse(BaseModel): + message: str + type: Literal["et-response"] + + +class AgentConnectionState(Enum): + connected = "connected" + disconnected = "disconnected" + + +class AgentSummary(BaseModel): + agent_id: str + last_known_ip: str | None = None + state: AgentConnectionState + + +class ConnectStatus(Enum): + assigned = "assigned" + reconnected = "reconnected" + + +class MessageDeliveryStatus(Enum): + delivered = "delivered" + queued = "queued" + acknowledged = "acknowledged" + broadcast = "broadcast" + + +class MessageScope(Enum): + direct = "direct" + broadcast = "broadcast" + + +class WsConnectAck(BaseModel): + agent_id: str + status: ConnectStatus + type: Literal["et-connect-ack"] + + +class WsListAgentsResponse(BaseModel): + agents: list[AgentSummary] + type: Literal["et-list-agents-response"] + + +class WsAgentMessage(BaseModel): + from_agent_id: str + message: Any = Field(..., description="Arbitrary JSON value (opaque to the protocol)") + message_id: str + scope: MessageScope + server_received_at: str + type: Literal["et-agent-message"] + + +class WsMessageStatus(BaseModel): + detail: str + message_id: str | None = None + status: MessageDeliveryStatus + type: Literal["et-message-status"] + + +class WsMessage( + RootModel[ + WsConnect + | WsConnectAck + | WsAlive + | WsListAgents + | WsListAgentsResponse + | WsSendAgentMessage + | WsBroadcastMessage + | WsAgentMessage + | WsMessageAck + | WsMessageStatus + | WsInvalid + | WsClientEvent + | WsResponse + ] +): + root: ( + WsConnect + | WsConnectAck + | WsAlive + | WsListAgents + | WsListAgentsResponse + | WsSendAgentMessage + | WsBroadcastMessage + | WsAgentMessage + | WsMessageAck + | WsMessageStatus + | WsInvalid + | WsClientEvent + | WsResponse + ) = Field(..., title="WsMessage") diff --git a/generated/python-ws/pyproject.toml b/generated/python-ws/pyproject.toml new file mode 100644 index 0000000..fe8b7d9 --- /dev/null +++ b/generated/python-ws/pyproject.toml @@ -0,0 +1,15 @@ +[project] +dependencies = ["pydantic>=2"] +description = "Generated Pydantic models for the Edge Toolkit WS protocol" +license = "Apache-2.0 OR MIT" +name = "et-ws" +requires-python = ">=3.10" +version = "0.1.0" + +[build-system] +build-backend = "uv_build" +requires = ["uv_build>=0.10.2,<0.11.0"] + +[tool.uv.build-backend] +module-name = "et_ws" +module-root = "" diff --git a/generated/rust-rest/Cargo.toml b/generated/rust-rest/Cargo.toml new file mode 100644 index 0000000..cc57cf6 --- /dev/null +++ b/generated/rust-rest/Cargo.toml @@ -0,0 +1,35 @@ +[package] +name = "et-rest-client" +description = "Rust client for ws-server's REST API. src/lib.rs regenerated; this Cargo.toml is hand-maintained." +version = "0.1.0" +edition.workspace = true +license.workspace = true +repository.workspace = true + +[features] +default = ["tracing"] +# OTel traceparent injection runs in the progenitor pre-hook. The hook body +# is `#[cfg(feature = "tracing")]`-gated by the generator (see +# `utilities/int-gen/src/rest.rs`), so without this feature it's a no-op and +# the heavyweight OTel deps don't get pulled in — required for the browser +# WASM targets that consume this crate. +tracing = ["dep:opentelemetry", "dep:opentelemetry-http", "dep:tracing-opentelemetry"] + +[dependencies] +bytes = "1.9" +futures-core = "0.3" +opentelemetry = { workspace = true, optional = true } +opentelemetry-http = { version = "0.31", optional = true } +progenitor-client = "0.14.0" +serde = { version = "1.0", features = ["derive"] } +serde_urlencoded = "0.7" +tracing.workspace = true +tracing-opentelemetry = { workspace = true, optional = true } + +# `rustls` and `stream` are native-only — WASM reqwest dispatches via the +# browser's `fetch()` and has no notion of a TLS stack. +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +reqwest = { version = "0.13", default-features = false, features = ["json", "query", "rustls", "stream"] } + +[target.'cfg(target_arch = "wasm32")'.dependencies] +reqwest = { version = "0.13", default-features = false, features = ["json"] } diff --git a/generated/rust-rest/src/lib.rs b/generated/rust-rest/src/lib.rs new file mode 100644 index 0000000..0176da7 --- /dev/null +++ b/generated/rust-rest/src/lib.rs @@ -0,0 +1,416 @@ +#[allow(unused_imports)] +pub use progenitor_client::{ByteStream, ClientInfo, Error, ResponseValue}; +#[allow(unused_imports)] +use progenitor_client::{ClientHooks, OperationInfo, RequestBuilderExt, encode_path}; +/// Types used as operation parameters and responses. +#[allow(clippy::all)] +pub mod types { + /// Error types. + pub mod error { + /// Error from a `TryFrom` or `FromStr` implementation. + pub struct ConversionError(::std::borrow::Cow<'static, str>); + impl ::std::error::Error for ConversionError {} + impl ::std::fmt::Display for ConversionError { + fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> Result<(), ::std::fmt::Error> { + ::std::fmt::Display::fmt(&self.0, f) + } + } + impl ::std::fmt::Debug for ConversionError { + fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> Result<(), ::std::fmt::Error> { + ::std::fmt::Debug::fmt(&self.0, f) + } + } + impl From<&'static str> for ConversionError { + fn from(value: &'static str) -> Self { + Self(value.into()) + } + } + impl From for ConversionError { + fn from(value: String) -> Self { + Self(value.into()) + } + } + } + ///Server liveness probe response. Returned by `GET /health`. + /// + ///
JSON schema + /// + /// ```json + ///{ + /// "description": "Server liveness probe response. Returned by `GET /health`.", + /// "type": "object", + /// "required": [ + /// "service", + /// "status" + /// ], + /// "properties": { + /// "service": { + /// "type": "string" + /// }, + /// "status": { + /// "type": "string" + /// } + /// } + ///} + /// ``` + ///
+ #[derive(::serde::Deserialize, ::serde::Serialize, Clone, Debug)] + pub struct HealthResponse { + pub service: ::std::string::String, + pub status: ::std::string::String, + } +} +#[derive(Clone, Debug)] +/**Client for Edge Toolkit REST API + +ws-server HTTP surface: health probe, module discovery, module assets, per-agent storage. + +Version: 0.1.0*/ +pub struct Client { + pub(crate) baseurl: String, + pub(crate) client: reqwest::Client, +} +impl Client { + /// Create a new client. + /// + /// `baseurl` is the base URL provided to the internal + /// `reqwest::Client`, and should include a scheme and hostname, + /// as well as port and a path stem if applicable. + pub fn new(baseurl: &str) -> Self { + #[cfg(not(target_arch = "wasm32"))] + let client = { + let dur = ::std::time::Duration::from_secs(15u64); + reqwest::ClientBuilder::new().connect_timeout(dur).timeout(dur) + }; + #[cfg(target_arch = "wasm32")] + let client = reqwest::ClientBuilder::new(); + Self::new_with_client(baseurl, client.build().unwrap()) + } + /// Construct a new client with an existing `reqwest::Client`, + /// allowing more control over its configuration. + /// + /// `baseurl` is the base URL provided to the internal + /// `reqwest::Client`, and should include a scheme and hostname, + /// as well as port and a path stem if applicable. + pub fn new_with_client(baseurl: &str, client: reqwest::Client) -> Self { + Self { + baseurl: baseurl.to_string(), + client, + } + } +} +impl ClientInfo<()> for Client { + fn api_version() -> &'static str { + "0.1.0" + } + fn baseurl(&self) -> &str { + self.baseurl.as_str() + } + fn client(&self) -> &reqwest::Client { + &self.client + } + fn inner(&self) -> &() { + &() + } +} +impl ClientHooks<()> for &Client {} +#[allow(clippy::all)] +impl Client { + /**Sends a `GET` request to `/health` + + */ + pub async fn health<'a>(&'a self) -> Result, Error<()>> { + let url = format!("{}/health", self.baseurl,); + let mut header_map = ::reqwest::header::HeaderMap::with_capacity(1usize); + header_map.append( + ::reqwest::header::HeaderName::from_static("api-version"), + ::reqwest::header::HeaderValue::from_static(Self::api_version()), + ); + #[allow(unused_mut)] + let mut request = self + .client + .get(url) + .header( + ::reqwest::header::ACCEPT, + ::reqwest::header::HeaderValue::from_static("application/json"), + ) + .headers(header_map) + .build()?; + let info = OperationInfo { operation_id: "health" }; + match (|request: &mut ::reqwest::Request| { + #[cfg(feature = "tracing")] + { + let cx = <::tracing::Span as ::tracing_opentelemetry::OpenTelemetrySpanExt>::context( + &::tracing::Span::current(), + ); + ::opentelemetry::global::get_text_map_propagator(|propagator| { + propagator.inject_context(&cx, &mut ::opentelemetry_http::HeaderInjector(request.headers_mut())); + }); + } + #[cfg(not(feature = "tracing"))] + let _ = request; + async { Ok::<(), ::std::convert::Infallible>(()) } + })(&mut request) + .await + { + Ok(_) => {} + Err(e) => return Err(Error::Custom(e.to_string())), + } + self.pre(&mut request, &info).await?; + let result = self.exec(request, &info).await; + self.post(&result, &info).await?; + let response = result?; + match response.status().as_u16() { + 200u16 => ResponseValue::from_response(response).await, + _ => Err(Error::UnexpectedResponse(response)), + } + } + /**Sends a `GET` request to `/modules/` + + */ + pub async fn list_modules_handler<'a>( + &'a self, + ) -> Result>, Error<()>> { + let url = format!("{}/modules/", self.baseurl,); + let mut header_map = ::reqwest::header::HeaderMap::with_capacity(1usize); + header_map.append( + ::reqwest::header::HeaderName::from_static("api-version"), + ::reqwest::header::HeaderValue::from_static(Self::api_version()), + ); + #[allow(unused_mut)] + let mut request = self + .client + .get(url) + .header( + ::reqwest::header::ACCEPT, + ::reqwest::header::HeaderValue::from_static("application/json"), + ) + .headers(header_map) + .build()?; + let info = OperationInfo { + operation_id: "list_modules_handler", + }; + match (|request: &mut ::reqwest::Request| { + #[cfg(feature = "tracing")] + { + let cx = <::tracing::Span as ::tracing_opentelemetry::OpenTelemetrySpanExt>::context( + &::tracing::Span::current(), + ); + ::opentelemetry::global::get_text_map_propagator(|propagator| { + propagator.inject_context(&cx, &mut ::opentelemetry_http::HeaderInjector(request.headers_mut())); + }); + } + #[cfg(not(feature = "tracing"))] + let _ = request; + async { Ok::<(), ::std::convert::Infallible>(()) } + })(&mut request) + .await + { + Ok(_) => {} + Err(e) => return Err(Error::Custom(e.to_string())), + } + self.pre(&mut request, &info).await?; + let result = self.exec(request, &info).await; + self.post(&result, &info).await?; + let response = result?; + match response.status().as_u16() { + 200u16 => ResponseValue::from_response(response).await, + _ => Err(Error::UnexpectedResponse(response)), + } + } + /**OpenAPI placeholder for `GET /modules/{name}/{path}`. `Files::new(...)` in + `configure()` actually serves these requests, but utoipa needs a function + to attach `#[utoipa::path]` to. The fn name shapes the generated client's + method name (via the OpenAPI `operationId`), so call it what callers want + + Sends a `GET` request to `/modules/{name}/{path}` + + Arguments: + - `name`: Module name + - `path`: Relative path of the file under the module's pkg/ dir + */ + pub async fn get_module_file<'a>( + &'a self, + name: &'a str, + path: &'a str, + ) -> Result, Error<()>> { + let url = format!( + "{}/modules/{}/{}", + self.baseurl, + encode_path(&name.to_string()), + encode_path(&path.to_string()), + ); + let mut header_map = ::reqwest::header::HeaderMap::with_capacity(1usize); + header_map.append( + ::reqwest::header::HeaderName::from_static("api-version"), + ::reqwest::header::HeaderValue::from_static(Self::api_version()), + ); + #[allow(unused_mut)] + let mut request = self.client.get(url).headers(header_map).build()?; + let info = OperationInfo { + operation_id: "get_module_file", + }; + match (|request: &mut ::reqwest::Request| { + #[cfg(feature = "tracing")] + { + let cx = <::tracing::Span as ::tracing_opentelemetry::OpenTelemetrySpanExt>::context( + &::tracing::Span::current(), + ); + ::opentelemetry::global::get_text_map_propagator(|propagator| { + propagator.inject_context(&cx, &mut ::opentelemetry_http::HeaderInjector(request.headers_mut())); + }); + } + #[cfg(not(feature = "tracing"))] + let _ = request; + async { Ok::<(), ::std::convert::Infallible>(()) } + })(&mut request) + .await + { + Ok(_) => {} + Err(e) => return Err(Error::Custom(e.to_string())), + } + self.pre(&mut request, &info).await?; + let result = self.exec(request, &info).await; + self.post(&result, &info).await?; + let response = result?; + match response.status().as_u16() { + 200u16 => Ok(ResponseValue::stream(response)), + 404u16 => Err(Error::ErrorResponse(ResponseValue::empty(response))), + _ => Err(Error::UnexpectedResponse(response)), + } + } + /**OpenAPI placeholder for `GET /storage/{agent_id}/{filename}`. `Files::new(...)` + in `configure()` actually serves these requests; this fn exists only so + utoipa has somewhere to attach `#[utoipa::path]`. The fn name flows through + the OpenAPI `operationId` into the generated client's method name + + Sends a `GET` request to `/storage/{agent_id}/{filename}` + + Arguments: + - `agent_id`: Agent identifier + - `filename`: Stored filename + */ + pub async fn get_file<'a>( + &'a self, + agent_id: &'a str, + filename: &'a str, + ) -> Result, Error<()>> { + let url = format!( + "{}/storage/{}/{}", + self.baseurl, + encode_path(&agent_id.to_string()), + encode_path(&filename.to_string()), + ); + let mut header_map = ::reqwest::header::HeaderMap::with_capacity(1usize); + header_map.append( + ::reqwest::header::HeaderName::from_static("api-version"), + ::reqwest::header::HeaderValue::from_static(Self::api_version()), + ); + #[allow(unused_mut)] + let mut request = self.client.get(url).headers(header_map).build()?; + let info = OperationInfo { + operation_id: "get_file", + }; + match (|request: &mut ::reqwest::Request| { + #[cfg(feature = "tracing")] + { + let cx = <::tracing::Span as ::tracing_opentelemetry::OpenTelemetrySpanExt>::context( + &::tracing::Span::current(), + ); + ::opentelemetry::global::get_text_map_propagator(|propagator| { + propagator.inject_context(&cx, &mut ::opentelemetry_http::HeaderInjector(request.headers_mut())); + }); + } + #[cfg(not(feature = "tracing"))] + let _ = request; + async { Ok::<(), ::std::convert::Infallible>(()) } + })(&mut request) + .await + { + Ok(_) => {} + Err(e) => return Err(Error::Custom(e.to_string())), + } + self.pre(&mut request, &info).await?; + let result = self.exec(request, &info).await; + self.post(&result, &info).await?; + let response = result?; + match response.status().as_u16() { + 200u16 => Ok(ResponseValue::stream(response)), + 404u16 => Err(Error::ErrorResponse(ResponseValue::empty(response))), + _ => Err(Error::UnexpectedResponse(response)), + } + } + /**Sends a `PUT` request to `/storage/{agent_id}/{filename}` + + Arguments: + - `agent_id`: Agent identifier (must be a connected agent) + - `filename`: Single-segment filename to write + - `body`: Raw file bytes + */ + pub async fn put_file<'a, B: Into>( + &'a self, + agent_id: &'a str, + filename: &'a str, + body: B, + ) -> Result, Error<()>> { + let url = format!( + "{}/storage/{}/{}", + self.baseurl, + encode_path(&agent_id.to_string()), + encode_path(&filename.to_string()), + ); + let mut header_map = ::reqwest::header::HeaderMap::with_capacity(1usize); + header_map.append( + ::reqwest::header::HeaderName::from_static("api-version"), + ::reqwest::header::HeaderValue::from_static(Self::api_version()), + ); + #[allow(unused_mut)] + let mut request = self + .client + .put(url) + .header( + ::reqwest::header::CONTENT_TYPE, + ::reqwest::header::HeaderValue::from_static("application/octet-stream"), + ) + .body(body) + .headers(header_map) + .build()?; + let info = OperationInfo { + operation_id: "put_file", + }; + match (|request: &mut ::reqwest::Request| { + #[cfg(feature = "tracing")] + { + let cx = <::tracing::Span as ::tracing_opentelemetry::OpenTelemetrySpanExt>::context( + &::tracing::Span::current(), + ); + ::opentelemetry::global::get_text_map_propagator(|propagator| { + propagator.inject_context(&cx, &mut ::opentelemetry_http::HeaderInjector(request.headers_mut())); + }); + } + #[cfg(not(feature = "tracing"))] + let _ = request; + async { Ok::<(), ::std::convert::Infallible>(()) } + })(&mut request) + .await + { + Ok(_) => {} + Err(e) => return Err(Error::Custom(e.to_string())), + } + self.pre(&mut request, &info).await?; + let result = self.exec(request, &info).await; + self.post(&result, &info).await?; + let response = result?; + match response.status().as_u16() { + 200u16 => Ok(ResponseValue::empty(response)), + 400u16 => Err(Error::ErrorResponse(ResponseValue::empty(response))), + 404u16 => Err(Error::ErrorResponse(ResponseValue::empty(response))), + _ => Err(Error::UnexpectedResponse(response)), + } + } +} +/// Items consumers will typically use such as the Client. +pub mod prelude { + #[allow(unused_imports)] + pub use super::Client; +} diff --git a/generated/specs/rest.yaml b/generated/specs/rest.yaml new file mode 100644 index 0000000..2d7c60c --- /dev/null +++ b/generated/specs/rest.yaml @@ -0,0 +1,149 @@ +openapi: 3.0.3 +info: + title: Edge Toolkit REST API + description: "ws-server HTTP surface: health probe, module discovery, module assets, per-agent storage." + version: 0.1.0 +servers: + - url: http://localhost:8080 + description: Default ws-server bind address +paths: + /health: + get: + tags: + - et_ws_server + operationId: health + responses: + "200": + description: Server is up + content: + application/json: + schema: + $ref: "#/components/schemas/HealthResponse" + /modules/: + get: + tags: + - et_modules_service + operationId: list_modules_handler + responses: + "200": + description: Names of available modules + content: + application/json: + schema: + type: array + items: + type: string + /modules/{name}/{path}: + get: + tags: + - et_modules_service + summary: |- + OpenAPI placeholder for `GET /modules/{name}/{path}`. `Files::new(...)` in + `configure()` actually serves these requests, but utoipa needs a function + to attach `#[utoipa::path]` to. The fn name shapes the generated client's + method name (via the OpenAPI `operationId`), so call it what callers want. + operationId: get_module_file + parameters: + - in: path + name: name + description: Module name + required: true + schema: + type: string + style: simple + - in: path + name: path + description: Relative path of the file under the module's pkg/ dir + required: true + schema: + type: string + style: simple + responses: + "200": + description: Static module asset + content: + application/octet-stream: {} + "404": + description: No such module or file + /storage/{agent_id}/{filename}: + get: + tags: + - et_storage_service + summary: |- + OpenAPI placeholder for `GET /storage/{agent_id}/{filename}`. `Files::new(...)` + in `configure()` actually serves these requests; this fn exists only so + utoipa has somewhere to attach `#[utoipa::path]`. The fn name flows through + the OpenAPI `operationId` into the generated client's method name. + operationId: get_file + parameters: + - in: path + name: agent_id + description: Agent identifier + required: true + schema: + type: string + style: simple + - in: path + name: filename + description: Stored filename + required: true + schema: + type: string + style: simple + responses: + "200": + description: Stored file contents + content: + application/octet-stream: {} + "404": + description: No such file + put: + tags: + - et_storage_service + operationId: put_file + parameters: + - in: path + name: agent_id + description: Agent identifier (must be a connected agent) + required: true + schema: + type: string + style: simple + - in: path + name: filename + description: Single-segment filename to write + required: true + schema: + type: string + style: simple + requestBody: + description: Raw file bytes + content: + application/octet-stream: + schema: + description: |- + Phantom type used to label binary request/response bodies as + `string`/`binary` in the OpenAPI document. Never constructed at runtime. + type: string + format: binary + required: true + responses: + "200": + description: File stored + "400": + description: Invalid filename + "404": + description: Agent not found +components: + schemas: + HealthResponse: + description: Server liveness probe response. Returned by `GET /health`. + type: object + properties: + service: + type: string + status: + type: string + required: + - status + - service diff --git a/generated/specs/wit/deps/et-ws-messages/messages.wit b/generated/specs/wit/deps/et-ws-messages/messages.wit new file mode 100644 index 0000000..735b327 --- /dev/null +++ b/generated/specs/wit/deps/et-ws-messages/messages.wit @@ -0,0 +1,91 @@ +package et:ws-messages@0.1.0; + +/// Typed WS protocol messages — each `ws-message` case maps 1:1 to a Rust `WsMessage` variant on the wire. +interface messages { + enum agent-connection-state { + connected, + disconnected, + } + enum connect-status { + assigned, + reconnected, + } + enum message-delivery-status { + delivered, + queued, + acknowledged, + broadcast, + } + enum message-scope { + direct, + broadcast, + } + record agent-summary { + agent-id: string, + last-known-ip: option, + state: agent-connection-state, + } + record connect-payload { + agent-id: option, + } + record connect-ack-payload { + agent-id: string, + status: connect-status, + } + record alive-payload { + timestamp: string, + } + record list-agents-response-payload { + agents: list, + } + record send-agent-message-payload { + message: string, + to-agent-id: string, + } + record broadcast-message-payload { + message: string, + } + record agent-message-payload { + from-agent-id: string, + message: string, + message-id: string, + scope: message-scope, + server-received-at: string, + } + record message-ack-payload { + message-id: string, + } + record message-status-payload { + detail: string, + message-id: option, + status: message-delivery-status, + } + record invalid-payload { + detail: string, + message-id: option, + } + record client-event-payload { + action: string, + capability: string, + details: string, + } + record response-payload { + message: string, + } + /// Tagged union covering every wire-format WS message. + variant ws-message { + connect(connect-payload), + connect-ack(connect-ack-payload), + alive(alive-payload), + list-agents, + list-agents-response(list-agents-response-payload), + send-agent-message(send-agent-message-payload), + broadcast-message(broadcast-message-payload), + agent-message(agent-message-payload), + message-ack(message-ack-payload), + message-status(message-status-payload), + invalid(invalid-payload), + client-event(client-event-payload), + response(response-payload), + } +} diff --git a/generated/specs/wit/deps/wasi-clocks/monotonic-clock.wit b/generated/specs/wit/deps/wasi-clocks/monotonic-clock.wit new file mode 100644 index 0000000..f3bc839 --- /dev/null +++ b/generated/specs/wit/deps/wasi-clocks/monotonic-clock.wit @@ -0,0 +1,50 @@ +package wasi:clocks@0.2.6; +/// WASI Monotonic Clock is a clock API intended to let users measure elapsed +/// time. +/// +/// It is intended to be portable at least between Unix-family platforms and +/// Windows. +/// +/// A monotonic clock is a clock which has an unspecified initial value, and +/// successive reads of the clock will produce non-decreasing values. +@since(version = 0.2.0) +interface monotonic-clock { + @since(version = 0.2.0) + use wasi:io/poll@0.2.6.{pollable}; + + /// An instant in time, in nanoseconds. An instant is relative to an + /// unspecified initial value, and can only be compared to instances from + /// the same monotonic-clock. + @since(version = 0.2.0) + type instant = u64; + + /// A duration of time, in nanoseconds. + @since(version = 0.2.0) + type duration = u64; + + /// Read the current value of the clock. + /// + /// The clock is monotonic, therefore calling this function repeatedly will + /// produce a sequence of non-decreasing values. + @since(version = 0.2.0) + now: func() -> instant; + + /// Query the resolution of the clock. Returns the duration of time + /// corresponding to a clock tick. + @since(version = 0.2.0) + resolution: func() -> duration; + + /// Create a `pollable` which will resolve once the specified instant + /// has occurred. + @since(version = 0.2.0) + subscribe-instant: func( + when: instant, + ) -> pollable; + + /// Create a `pollable` that will resolve after the specified duration has + /// elapsed from the time this function is invoked. + @since(version = 0.2.0) + subscribe-duration: func( + when: duration, + ) -> pollable; +} diff --git a/generated/specs/wit/deps/wasi-clocks/timezone.wit b/generated/specs/wit/deps/wasi-clocks/timezone.wit new file mode 100644 index 0000000..ca98ad1 --- /dev/null +++ b/generated/specs/wit/deps/wasi-clocks/timezone.wit @@ -0,0 +1,55 @@ +package wasi:clocks@0.2.6; + +@unstable(feature = clocks-timezone) +interface timezone { + @unstable(feature = clocks-timezone) + use wall-clock.{datetime}; + + /// Return information needed to display the given `datetime`. This includes + /// the UTC offset, the time zone name, and a flag indicating whether + /// daylight saving time is active. + /// + /// If the timezone cannot be determined for the given `datetime`, return a + /// `timezone-display` for `UTC` with a `utc-offset` of 0 and no daylight + /// saving time. + @unstable(feature = clocks-timezone) + display: func(when: datetime) -> timezone-display; + + /// The same as `display`, but only return the UTC offset. + @unstable(feature = clocks-timezone) + utc-offset: func(when: datetime) -> s32; + + /// Information useful for displaying the timezone of a specific `datetime`. + /// + /// This information may vary within a single `timezone` to reflect daylight + /// saving time adjustments. + @unstable(feature = clocks-timezone) + record timezone-display { + /// The number of seconds difference between UTC time and the local + /// time of the timezone. + /// + /// The returned value will always be less than 86400 which is the + /// number of seconds in a day (24*60*60). + /// + /// In implementations that do not expose an actual time zone, this + /// should return 0. + utc-offset: s32, + + /// The abbreviated name of the timezone to display to a user. The name + /// `UTC` indicates Coordinated Universal Time. Otherwise, this should + /// reference local standards for the name of the time zone. + /// + /// In implementations that do not expose an actual time zone, this + /// should be the string `UTC`. + /// + /// In time zones that do not have an applicable name, a formatted + /// representation of the UTC offset may be returned, such as `-04:00`. + name: string, + + /// Whether daylight saving time is active. + /// + /// In implementations that do not expose an actual time zone, this + /// should return false. + in-daylight-saving-time: bool, + } +} diff --git a/generated/specs/wit/deps/wasi-clocks/wall-clock.wit b/generated/specs/wit/deps/wasi-clocks/wall-clock.wit new file mode 100644 index 0000000..76636a0 --- /dev/null +++ b/generated/specs/wit/deps/wasi-clocks/wall-clock.wit @@ -0,0 +1,46 @@ +package wasi:clocks@0.2.6; +/// WASI Wall Clock is a clock API intended to let users query the current +/// time. The name "wall" makes an analogy to a "clock on the wall", which +/// is not necessarily monotonic as it may be reset. +/// +/// It is intended to be portable at least between Unix-family platforms and +/// Windows. +/// +/// A wall clock is a clock which measures the date and time according to +/// some external reference. +/// +/// External references may be reset, so this clock is not necessarily +/// monotonic, making it unsuitable for measuring elapsed time. +/// +/// It is intended for reporting the current date and time for humans. +@since(version = 0.2.0) +interface wall-clock { + /// A time and date in seconds plus nanoseconds. + @since(version = 0.2.0) + record datetime { + seconds: u64, + nanoseconds: u32, + } + + /// Read the current value of the clock. + /// + /// This clock is not monotonic, therefore calling this function repeatedly + /// will not necessarily produce a sequence of non-decreasing values. + /// + /// The returned timestamps represent the number of seconds since + /// 1970-01-01T00:00:00Z, also known as [POSIX's Seconds Since the Epoch], + /// also known as [Unix Time]. + /// + /// The nanoseconds field of the output is always less than 1000000000. + /// + /// [POSIX's Seconds Since the Epoch]: https://pubs.opengroup.org/onlinepubs/9699919799/xrat/V4_xbd_chap04.html#tag_21_04_16 + /// [Unix Time]: https://en.wikipedia.org/wiki/Unix_time + @since(version = 0.2.0) + now: func() -> datetime; + + /// Query the resolution of the clock. + /// + /// The nanoseconds field of the output is always less than 1000000000. + @since(version = 0.2.0) + resolution: func() -> datetime; +} diff --git a/generated/specs/wit/deps/wasi-clocks/world.wit b/generated/specs/wit/deps/wasi-clocks/world.wit new file mode 100644 index 0000000..5c53c51 --- /dev/null +++ b/generated/specs/wit/deps/wasi-clocks/world.wit @@ -0,0 +1,11 @@ +package wasi:clocks@0.2.6; + +@since(version = 0.2.0) +world imports { + @since(version = 0.2.0) + import monotonic-clock; + @since(version = 0.2.0) + import wall-clock; + @unstable(feature = clocks-timezone) + import timezone; +} diff --git a/generated/specs/wit/deps/wasi-io/error.wit b/generated/specs/wit/deps/wasi-io/error.wit new file mode 100644 index 0000000..784f74a --- /dev/null +++ b/generated/specs/wit/deps/wasi-io/error.wit @@ -0,0 +1,34 @@ +package wasi:io@0.2.6; + +@since(version = 0.2.0) +interface error { + /// A resource which represents some error information. + /// + /// The only method provided by this resource is `to-debug-string`, + /// which provides some human-readable information about the error. + /// + /// In the `wasi:io` package, this resource is returned through the + /// `wasi:io/streams/stream-error` type. + /// + /// To provide more specific error information, other interfaces may + /// offer functions to "downcast" this error into more specific types. For example, + /// errors returned from streams derived from filesystem types can be described using + /// the filesystem's own error-code type. This is done using the function + /// `wasi:filesystem/types/filesystem-error-code`, which takes a `borrow` + /// parameter and returns an `option`. + /// + /// The set of functions which can "downcast" an `error` into a more + /// concrete type is open. + @since(version = 0.2.0) + resource error { + /// Returns a string that is suitable to assist humans in debugging + /// this error. + /// + /// WARNING: The returned string should not be consumed mechanically! + /// It may change across platforms, hosts, or other implementation + /// details. Parsing this string is a major platform-compatibility + /// hazard. + @since(version = 0.2.0) + to-debug-string: func() -> string; + } +} diff --git a/generated/specs/wit/deps/wasi-io/poll.wit b/generated/specs/wit/deps/wasi-io/poll.wit new file mode 100644 index 0000000..7f71183 --- /dev/null +++ b/generated/specs/wit/deps/wasi-io/poll.wit @@ -0,0 +1,47 @@ +package wasi:io@0.2.6; + +/// A poll API intended to let users wait for I/O events on multiple handles +/// at once. +@since(version = 0.2.0) +interface poll { + /// `pollable` represents a single I/O event which may be ready, or not. + @since(version = 0.2.0) + resource pollable { + + /// Return the readiness of a pollable. This function never blocks. + /// + /// Returns `true` when the pollable is ready, and `false` otherwise. + @since(version = 0.2.0) + ready: func() -> bool; + + /// `block` returns immediately if the pollable is ready, and otherwise + /// blocks until ready. + /// + /// This function is equivalent to calling `poll.poll` on a list + /// containing only this pollable. + @since(version = 0.2.0) + block: func(); + } + + /// Poll for completion on a set of pollables. + /// + /// This function takes a list of pollables, which identify I/O sources of + /// interest, and waits until one or more of the events is ready for I/O. + /// + /// The result `list` contains one or more indices of handles in the + /// argument list that is ready for I/O. + /// + /// This function traps if either: + /// - the list is empty, or: + /// - the list contains more elements than can be indexed with a `u32` value. + /// + /// A timeout can be implemented by adding a pollable from the + /// wasi-clocks API to the list. + /// + /// This function does not return a `result`; polling in itself does not + /// do any I/O so it doesn't fail. If any of the I/O sources identified by + /// the pollables has an error, it is indicated by marking the source as + /// being ready for I/O. + @since(version = 0.2.0) + poll: func(in: list>) -> list; +} diff --git a/generated/specs/wit/deps/wasi-io/streams.wit b/generated/specs/wit/deps/wasi-io/streams.wit new file mode 100644 index 0000000..c5da38c --- /dev/null +++ b/generated/specs/wit/deps/wasi-io/streams.wit @@ -0,0 +1,290 @@ +package wasi:io@0.2.6; + +/// WASI I/O is an I/O abstraction API which is currently focused on providing +/// stream types. +/// +/// In the future, the component model is expected to add built-in stream types; +/// when it does, they are expected to subsume this API. +@since(version = 0.2.0) +interface streams { + @since(version = 0.2.0) + use error.{error}; + @since(version = 0.2.0) + use poll.{pollable}; + + /// An error for input-stream and output-stream operations. + @since(version = 0.2.0) + variant stream-error { + /// The last operation (a write or flush) failed before completion. + /// + /// More information is available in the `error` payload. + /// + /// After this, the stream will be closed. All future operations return + /// `stream-error::closed`. + last-operation-failed(error), + /// The stream is closed: no more input will be accepted by the + /// stream. A closed output-stream will return this error on all + /// future operations. + closed + } + + /// An input bytestream. + /// + /// `input-stream`s are *non-blocking* to the extent practical on underlying + /// platforms. I/O operations always return promptly; if fewer bytes are + /// promptly available than requested, they return the number of bytes promptly + /// available, which could even be zero. To wait for data to be available, + /// use the `subscribe` function to obtain a `pollable` which can be polled + /// for using `wasi:io/poll`. + @since(version = 0.2.0) + resource input-stream { + /// Perform a non-blocking read from the stream. + /// + /// When the source of a `read` is binary data, the bytes from the source + /// are returned verbatim. When the source of a `read` is known to the + /// implementation to be text, bytes containing the UTF-8 encoding of the + /// text are returned. + /// + /// This function returns a list of bytes containing the read data, + /// when successful. The returned list will contain up to `len` bytes; + /// it may return fewer than requested, but not more. The list is + /// empty when no bytes are available for reading at this time. The + /// pollable given by `subscribe` will be ready when more bytes are + /// available. + /// + /// This function fails with a `stream-error` when the operation + /// encounters an error, giving `last-operation-failed`, or when the + /// stream is closed, giving `closed`. + /// + /// When the caller gives a `len` of 0, it represents a request to + /// read 0 bytes. If the stream is still open, this call should + /// succeed and return an empty list, or otherwise fail with `closed`. + /// + /// The `len` parameter is a `u64`, which could represent a list of u8 which + /// is not possible to allocate in wasm32, or not desirable to allocate as + /// as a return value by the callee. The callee may return a list of bytes + /// less than `len` in size while more bytes are available for reading. + @since(version = 0.2.0) + read: func( + /// The maximum number of bytes to read + len: u64 + ) -> result, stream-error>; + + /// Read bytes from a stream, after blocking until at least one byte can + /// be read. Except for blocking, behavior is identical to `read`. + @since(version = 0.2.0) + blocking-read: func( + /// The maximum number of bytes to read + len: u64 + ) -> result, stream-error>; + + /// Skip bytes from a stream. Returns number of bytes skipped. + /// + /// Behaves identical to `read`, except instead of returning a list + /// of bytes, returns the number of bytes consumed from the stream. + @since(version = 0.2.0) + skip: func( + /// The maximum number of bytes to skip. + len: u64, + ) -> result; + + /// Skip bytes from a stream, after blocking until at least one byte + /// can be skipped. Except for blocking behavior, identical to `skip`. + @since(version = 0.2.0) + blocking-skip: func( + /// The maximum number of bytes to skip. + len: u64, + ) -> result; + + /// Create a `pollable` which will resolve once either the specified stream + /// has bytes available to read or the other end of the stream has been + /// closed. + /// The created `pollable` is a child resource of the `input-stream`. + /// Implementations may trap if the `input-stream` is dropped before + /// all derived `pollable`s created with this function are dropped. + @since(version = 0.2.0) + subscribe: func() -> pollable; + } + + + /// An output bytestream. + /// + /// `output-stream`s are *non-blocking* to the extent practical on + /// underlying platforms. Except where specified otherwise, I/O operations also + /// always return promptly, after the number of bytes that can be written + /// promptly, which could even be zero. To wait for the stream to be ready to + /// accept data, the `subscribe` function to obtain a `pollable` which can be + /// polled for using `wasi:io/poll`. + /// + /// Dropping an `output-stream` while there's still an active write in + /// progress may result in the data being lost. Before dropping the stream, + /// be sure to fully flush your writes. + @since(version = 0.2.0) + resource output-stream { + /// Check readiness for writing. This function never blocks. + /// + /// Returns the number of bytes permitted for the next call to `write`, + /// or an error. Calling `write` with more bytes than this function has + /// permitted will trap. + /// + /// When this function returns 0 bytes, the `subscribe` pollable will + /// become ready when this function will report at least 1 byte, or an + /// error. + @since(version = 0.2.0) + check-write: func() -> result; + + /// Perform a write. This function never blocks. + /// + /// When the destination of a `write` is binary data, the bytes from + /// `contents` are written verbatim. When the destination of a `write` is + /// known to the implementation to be text, the bytes of `contents` are + /// transcoded from UTF-8 into the encoding of the destination and then + /// written. + /// + /// Precondition: check-write gave permit of Ok(n) and contents has a + /// length of less than or equal to n. Otherwise, this function will trap. + /// + /// returns Err(closed) without writing if the stream has closed since + /// the last call to check-write provided a permit. + @since(version = 0.2.0) + write: func( + contents: list + ) -> result<_, stream-error>; + + /// Perform a write of up to 4096 bytes, and then flush the stream. Block + /// until all of these operations are complete, or an error occurs. + /// + /// This is a convenience wrapper around the use of `check-write`, + /// `subscribe`, `write`, and `flush`, and is implemented with the + /// following pseudo-code: + /// + /// ```text + /// let pollable = this.subscribe(); + /// while !contents.is_empty() { + /// // Wait for the stream to become writable + /// pollable.block(); + /// let Ok(n) = this.check-write(); // eliding error handling + /// let len = min(n, contents.len()); + /// let (chunk, rest) = contents.split_at(len); + /// this.write(chunk ); // eliding error handling + /// contents = rest; + /// } + /// this.flush(); + /// // Wait for completion of `flush` + /// pollable.block(); + /// // Check for any errors that arose during `flush` + /// let _ = this.check-write(); // eliding error handling + /// ``` + @since(version = 0.2.0) + blocking-write-and-flush: func( + contents: list + ) -> result<_, stream-error>; + + /// Request to flush buffered output. This function never blocks. + /// + /// This tells the output-stream that the caller intends any buffered + /// output to be flushed. the output which is expected to be flushed + /// is all that has been passed to `write` prior to this call. + /// + /// Upon calling this function, the `output-stream` will not accept any + /// writes (`check-write` will return `ok(0)`) until the flush has + /// completed. The `subscribe` pollable will become ready when the + /// flush has completed and the stream can accept more writes. + @since(version = 0.2.0) + flush: func() -> result<_, stream-error>; + + /// Request to flush buffered output, and block until flush completes + /// and stream is ready for writing again. + @since(version = 0.2.0) + blocking-flush: func() -> result<_, stream-error>; + + /// Create a `pollable` which will resolve once the output-stream + /// is ready for more writing, or an error has occurred. When this + /// pollable is ready, `check-write` will return `ok(n)` with n>0, or an + /// error. + /// + /// If the stream is closed, this pollable is always ready immediately. + /// + /// The created `pollable` is a child resource of the `output-stream`. + /// Implementations may trap if the `output-stream` is dropped before + /// all derived `pollable`s created with this function are dropped. + @since(version = 0.2.0) + subscribe: func() -> pollable; + + /// Write zeroes to a stream. + /// + /// This should be used precisely like `write` with the exact same + /// preconditions (must use check-write first), but instead of + /// passing a list of bytes, you simply pass the number of zero-bytes + /// that should be written. + @since(version = 0.2.0) + write-zeroes: func( + /// The number of zero-bytes to write + len: u64 + ) -> result<_, stream-error>; + + /// Perform a write of up to 4096 zeroes, and then flush the stream. + /// Block until all of these operations are complete, or an error + /// occurs. + /// + /// This is a convenience wrapper around the use of `check-write`, + /// `subscribe`, `write-zeroes`, and `flush`, and is implemented with + /// the following pseudo-code: + /// + /// ```text + /// let pollable = this.subscribe(); + /// while num_zeroes != 0 { + /// // Wait for the stream to become writable + /// pollable.block(); + /// let Ok(n) = this.check-write(); // eliding error handling + /// let len = min(n, num_zeroes); + /// this.write-zeroes(len); // eliding error handling + /// num_zeroes -= len; + /// } + /// this.flush(); + /// // Wait for completion of `flush` + /// pollable.block(); + /// // Check for any errors that arose during `flush` + /// let _ = this.check-write(); // eliding error handling + /// ``` + @since(version = 0.2.0) + blocking-write-zeroes-and-flush: func( + /// The number of zero-bytes to write + len: u64 + ) -> result<_, stream-error>; + + /// Read from one stream and write to another. + /// + /// The behavior of splice is equivalent to: + /// 1. calling `check-write` on the `output-stream` + /// 2. calling `read` on the `input-stream` with the smaller of the + /// `check-write` permitted length and the `len` provided to `splice` + /// 3. calling `write` on the `output-stream` with that read data. + /// + /// Any error reported by the call to `check-write`, `read`, or + /// `write` ends the splice and reports that error. + /// + /// This function returns the number of bytes transferred; it may be less + /// than `len`. + @since(version = 0.2.0) + splice: func( + /// The stream to read from + src: borrow, + /// The number of bytes to splice + len: u64, + ) -> result; + + /// Read from one stream and write to another, with blocking. + /// + /// This is similar to `splice`, except that it blocks until the + /// `output-stream` is ready for writing, and the `input-stream` + /// is ready for reading, before performing the `splice`. + @since(version = 0.2.0) + blocking-splice: func( + /// The stream to read from + src: borrow, + /// The number of bytes to splice + len: u64, + ) -> result; + } +} diff --git a/generated/specs/wit/deps/wasi-io/world.wit b/generated/specs/wit/deps/wasi-io/world.wit new file mode 100644 index 0000000..84c85c0 --- /dev/null +++ b/generated/specs/wit/deps/wasi-io/world.wit @@ -0,0 +1,10 @@ +package wasi:io@0.2.6; + +@since(version = 0.2.0) +world imports { + @since(version = 0.2.0) + import streams; + + @since(version = 0.2.0) + import poll; +} diff --git a/generated/specs/wit/deps/wasi-keyvalue/atomic.wit b/generated/specs/wit/deps/wasi-keyvalue/atomic.wit new file mode 100644 index 0000000..059efc4 --- /dev/null +++ b/generated/specs/wit/deps/wasi-keyvalue/atomic.wit @@ -0,0 +1,22 @@ +/// A keyvalue interface that provides atomic operations. +/// +/// Atomic operations are single, indivisible operations. When a fault causes an atomic operation to +/// fail, it will appear to the invoker of the atomic operation that the action either completed +/// successfully or did nothing at all. +/// +/// Please note that this interface is bare functions that take a reference to a bucket. This is to +/// get around the current lack of a way to "extend" a resource with additional methods inside of +/// wit. Future version of the interface will instead extend these methods on the base `bucket` +/// resource. +interface atomics { + use store.{bucket, error}; + + /// Atomically increment the value associated with the key in the store by the given delta. It + /// returns the new value. + /// + /// If the key does not exist in the store, it creates a new key-value pair with the value set + /// to the given delta. + /// + /// If any other error occurs, it returns an `Err(error)`. + increment: func(bucket: borrow, key: string, delta: u64) -> result; +} \ No newline at end of file diff --git a/generated/specs/wit/deps/wasi-keyvalue/batch.wit b/generated/specs/wit/deps/wasi-keyvalue/batch.wit new file mode 100644 index 0000000..70c05fe --- /dev/null +++ b/generated/specs/wit/deps/wasi-keyvalue/batch.wit @@ -0,0 +1,63 @@ +/// A keyvalue interface that provides batch operations. +/// +/// A batch operation is an operation that operates on multiple keys at once. +/// +/// Batch operations are useful for reducing network round-trip time. For example, if you want to +/// get the values associated with 100 keys, you can either do 100 get operations or you can do 1 +/// batch get operation. The batch operation is faster because it only needs to make 1 network call +/// instead of 100. +/// +/// A batch operation does not guarantee atomicity, meaning that if the batch operation fails, some +/// of the keys may have been modified and some may not. +/// +/// This interface does has the same consistency guarantees as the `store` interface, meaning that +/// you should be able to "read your writes." +/// +/// Please note that this interface is bare functions that take a reference to a bucket. This is to +/// get around the current lack of a way to "extend" a resource with additional methods inside of +/// wit. Future version of the interface will instead extend these methods on the base `bucket` +/// resource. +interface batch { + use store.{bucket, error}; + + /// Get the key-value pairs associated with the keys in the store. It returns a list of + /// key-value pairs. + /// + /// If any of the keys do not exist in the store, it returns a `none` value for that pair in the + /// list. + /// + /// MAY show an out-of-date value if there are concurrent writes to the store. + /// + /// If any other error occurs, it returns an `Err(error)`. + get-many: func(bucket: borrow, keys: list) -> result>>>, error>; + + /// Set the values associated with the keys in the store. If the key already exists in the + /// store, it overwrites the value. + /// + /// Note that the key-value pairs are not guaranteed to be set in the order they are provided. + /// + /// If any of the keys do not exist in the store, it creates a new key-value pair. + /// + /// If any other error occurs, it returns an `Err(error)`. When an error occurs, it does not + /// rollback the key-value pairs that were already set. Thus, this batch operation does not + /// guarantee atomicity, implying that some key-value pairs could be set while others might + /// fail. + /// + /// Other concurrent operations may also be able to see the partial results. + set-many: func(bucket: borrow, key-values: list>>) -> result<_, error>; + + /// Delete the key-value pairs associated with the keys in the store. + /// + /// Note that the key-value pairs are not guaranteed to be deleted in the order they are + /// provided. + /// + /// If any of the keys do not exist in the store, it skips the key. + /// + /// If any other error occurs, it returns an `Err(error)`. When an error occurs, it does not + /// rollback the key-value pairs that were already deleted. Thus, this batch operation does not + /// guarantee atomicity, implying that some key-value pairs could be deleted while others might + /// fail. + /// + /// Other concurrent operations may also be able to see the partial results. + delete-many: func(bucket: borrow, keys: list) -> result<_, error>; +} diff --git a/generated/specs/wit/deps/wasi-keyvalue/store.wit b/generated/specs/wit/deps/wasi-keyvalue/store.wit new file mode 100644 index 0000000..3354ea2 --- /dev/null +++ b/generated/specs/wit/deps/wasi-keyvalue/store.wit @@ -0,0 +1,122 @@ +/// A keyvalue interface that provides eventually consistent key-value operations. +/// +/// Each of these operations acts on a single key-value pair. +/// +/// The value in the key-value pair is defined as a `u8` byte array and the intention is that it is +/// the common denominator for all data types defined by different key-value stores to handle data, +/// ensuring compatibility between different key-value stores. Note: the clients will be expecting +/// serialization/deserialization overhead to be handled by the key-value store. The value could be +/// a serialized object from JSON, HTML or vendor-specific data types like AWS S3 objects. +/// +/// Data consistency in a key value store refers to the guarantee that once a write operation +/// completes, all subsequent read operations will return the value that was written. +/// +/// Any implementation of this interface must have enough consistency to guarantee "reading your +/// writes." In particular, this means that the client should never get a value that is older than +/// the one it wrote, but it MAY get a newer value if one was written around the same time. These +/// guarantees only apply to the same client (which will likely be provided by the host or an +/// external capability of some kind). In this context a "client" is referring to the caller or +/// guest that is consuming this interface. Once a write request is committed by a specific client, +/// all subsequent read requests by the same client will reflect that write or any subsequent +/// writes. Another client running in a different context may or may not immediately see the result +/// due to the replication lag. As an example of all of this, if a value at a given key is A, and +/// the client writes B, then immediately reads, it should get B. If something else writes C in +/// quick succession, then the client may get C. However, a client running in a separate context may +/// still see A or B +interface store { + /// The set of errors which may be raised by functions in this package + variant error { + /// The host does not recognize the store identifier requested. + no-such-store, + + /// The requesting component does not have access to the specified store + /// (which may or may not exist). + access-denied, + + /// Some implementation-specific error has occurred (e.g. I/O) + other(string) + } + + /// A response to a `list-keys` operation. + record key-response { + /// The list of keys returned by the query. + keys: list, + /// The continuation token to use to fetch the next page of keys. If this is `null`, then + /// there are no more keys to fetch. + cursor: option + } + + /// Get the bucket with the specified identifier. + /// + /// `identifier` must refer to a bucket provided by the host. + /// + /// `error::no-such-store` will be raised if the `identifier` is not recognized. + open: func(identifier: string) -> result; + + /// A bucket is a collection of key-value pairs. Each key-value pair is stored as a entry in the + /// bucket, and the bucket itself acts as a collection of all these entries. + /// + /// It is worth noting that the exact terminology for bucket in key-value stores can very + /// depending on the specific implementation. For example: + /// + /// 1. Amazon DynamoDB calls a collection of key-value pairs a table + /// 2. Redis has hashes, sets, and sorted sets as different types of collections + /// 3. Cassandra calls a collection of key-value pairs a column family + /// 4. MongoDB calls a collection of key-value pairs a collection + /// 5. Riak calls a collection of key-value pairs a bucket + /// 6. Memcached calls a collection of key-value pairs a slab + /// 7. Azure Cosmos DB calls a collection of key-value pairs a container + /// + /// In this interface, we use the term `bucket` to refer to a collection of key-value pairs + resource bucket { + /// Get the value associated with the specified `key` + /// + /// The value is returned as an option. If the key-value pair exists in the + /// store, it returns `Ok(value)`. If the key does not exist in the + /// store, it returns `Ok(none)`. + /// + /// If any other error occurs, it returns an `Err(error)`. + get: func(key: string) -> result>, error>; + + /// Set the value associated with the key in the store. If the key already + /// exists in the store, it overwrites the value. + /// + /// If the key does not exist in the store, it creates a new key-value pair. + /// + /// If any other error occurs, it returns an `Err(error)`. + set: func(key: string, value: list) -> result<_, error>; + + /// Delete the key-value pair associated with the key in the store. + /// + /// If the key does not exist in the store, it does nothing. + /// + /// If any other error occurs, it returns an `Err(error)`. + delete: func(key: string) -> result<_, error>; + + /// Check if the key exists in the store. + /// + /// If the key exists in the store, it returns `Ok(true)`. If the key does + /// not exist in the store, it returns `Ok(false)`. + /// + /// If any other error occurs, it returns an `Err(error)`. + exists: func(key: string) -> result; + + /// Get all the keys in the store with an optional cursor (for use in pagination). It + /// returns a list of keys. Please note that for most KeyValue implementations, this is a + /// can be a very expensive operation and so it should be used judiciously. Implementations + /// can return any number of keys in a single response, but they should never attempt to + /// send more data than is reasonable (i.e. on a small edge device, this may only be a few + /// KB, while on a large machine this could be several MB). Any response should also return + /// a cursor that can be used to fetch the next page of keys. See the `key-response` record + /// for more information. + /// + /// Note that the keys are not guaranteed to be returned in any particular order. + /// + /// If the store is empty, it returns an empty list. + /// + /// MAY show an out-of-date list of keys if there are concurrent writes to the store. + /// + /// If any error occurs, it returns an `Err(error)`. + list-keys: func(cursor: option) -> result; + } +} diff --git a/generated/specs/wit/deps/wasi-keyvalue/watch.wit b/generated/specs/wit/deps/wasi-keyvalue/watch.wit new file mode 100644 index 0000000..ff13f75 --- /dev/null +++ b/generated/specs/wit/deps/wasi-keyvalue/watch.wit @@ -0,0 +1,16 @@ +/// A keyvalue interface that provides watch operations. +/// +/// This interface is used to provide event-driven mechanisms to handle +/// keyvalue changes. +interface watcher { + /// A keyvalue interface that provides handle-watch operations. + use store.{bucket}; + + /// Handle the `set` event for the given bucket and key. It includes a reference to the `bucket` + /// that can be used to interact with the store. + on-set: func(bucket: bucket, key: string, value: list); + + /// Handle the `delete` event for the given bucket and key. It includes a reference to the + /// `bucket` that can be used to interact with the store. + on-delete: func(bucket: bucket, key: string); +} \ No newline at end of file diff --git a/generated/specs/wit/deps/wasi-keyvalue/world.wit b/generated/specs/wit/deps/wasi-keyvalue/world.wit new file mode 100644 index 0000000..066148c --- /dev/null +++ b/generated/specs/wit/deps/wasi-keyvalue/world.wit @@ -0,0 +1,26 @@ +package wasi:keyvalue@0.2.0-draft; + +/// The `wasi:keyvalue/imports` world provides common APIs for interacting with key-value stores. +/// Components targeting this world will be able to do: +/// +/// 1. CRUD (create, read, update, delete) operations on key-value stores. +/// 2. Atomic `increment` and CAS (compare-and-swap) operations. +/// 3. Batch operations that can reduce the number of round trips to the network. +world imports { + /// The `store` capability allows the component to perform eventually consistent operations on + /// the key-value store. + import store; + + /// The `atomic` capability allows the component to perform atomic / `increment` and CAS + /// (compare-and-swap) operations. + import atomics; + + /// The `batch` capability allows the component to perform eventually consistent batch + /// operations that can reduce the number of round trips to the network. + import batch; +} + +world watch-service { + include imports; + export watcher; +} \ No newline at end of file diff --git a/generated/specs/wit/deps/wasi-logging/logging.wit b/generated/specs/wit/deps/wasi-logging/logging.wit new file mode 100644 index 0000000..8c0bdf8 --- /dev/null +++ b/generated/specs/wit/deps/wasi-logging/logging.wit @@ -0,0 +1,35 @@ +/// WASI Logging is a logging API intended to let users emit log messages with +/// simple priority levels and context values. +interface logging { + /// A log level, describing a kind of message. + enum level { + /// Describes messages about the values of variables and the flow of + /// control within a program. + trace, + + /// Describes messages likely to be of interest to someone debugging a + /// program. + debug, + + /// Describes messages likely to be of interest to someone monitoring a + /// program. + info, + + /// Describes messages indicating hazardous situations. + warn, + + /// Describes messages indicating serious errors. + error, + + /// Describes messages indicating fatal errors. + critical, + } + + /// Emit a log message. + /// + /// A log message has a `level` describing what kind of message is being + /// sent, a context, which is an uninterpreted string meant to help + /// consumers group similar messages, and a string containing the message + /// text. + log: func(level: level, context: string, message: string); +} diff --git a/generated/specs/wit/deps/wasi-logging/world.wit b/generated/specs/wit/deps/wasi-logging/world.wit new file mode 100644 index 0000000..070f041 --- /dev/null +++ b/generated/specs/wit/deps/wasi-logging/world.wit @@ -0,0 +1,5 @@ +package wasi:logging@0.1.0-draft; + +world imports { + import logging; +} diff --git a/services/ws-wasi-runner/wit/deps/wasi-nn/wasi-nn.wit b/generated/specs/wit/deps/wasi-nn/wasi-nn.wit similarity index 100% rename from services/ws-wasi-runner/wit/deps/wasi-nn/wasi-nn.wit rename to generated/specs/wit/deps/wasi-nn/wasi-nn.wit diff --git a/services/ws-wasi-runner/wit/deps/wasi-webgpu/webgpu.wit b/generated/specs/wit/deps/wasi-webgpu/webgpu.wit similarity index 61% rename from services/ws-wasi-runner/wit/deps/wasi-webgpu/webgpu.wit rename to generated/specs/wit/deps/wasi-webgpu/webgpu.wit index 1b1fd66..1f0f8ca 100644 --- a/services/ws-wasi-runner/wit/deps/wasi-webgpu/webgpu.wit +++ b/generated/specs/wit/deps/wasi-webgpu/webgpu.wit @@ -1,86 +1,31 @@ -package wasi:webgpu@0.0.1; - -// Trimmed from upstream WebAssembly/wasi-gfx@03c3e95 (main as of 2026-05-11). -// The preserved surface is what `services/ws-modules/wasi-graphics-info` needs -// to run a 4x4 compute matmul on the host GPU: request an adapter+device, -// create buffers / a shader module / a compute pipeline / bind group(s), -// submit a compute pass, read back results. Everything else (render pipelines, -// textures, samplers, canvas/surface presentation, render bundles, query sets, -// async pipeline creation, error-scope / lost-device machinery, debug markers, -// indirect dispatch) is stripped. Removed items are noted inline as -// `// TRIMMED: ...` so re-syncing with upstream stays mechanical. -// -// NOTE: this file is *not wire-compatible* with the unmodified wasi-webgpu; -// it's deliberately a subset. If wasi-gfx publishes on crates.io as proper -// versioned crates, replace this whole vendored tree with upstream WIT plus -// the matching wasmtime host crate instead of carrying the divergence. +// GENERATED FILE — do not edit by hand. +// Source: WebAssembly/wasi-gfx (webgpu.wit), parsed via wit-parser and +// post-processed by utilities/spec-gen/src/wit/upstream.rs. +// Only items in WEBGPU_KEEP_NAMES survive; the wgpu-backed host +// impl only supports the compute-only subset. +// Regenerate via: mise run fetch-wit-deps -// TRIMMED: use wasi:io/poll@0.2.0.{ pollable } - only referenced by -// gpu-device.onuncapturederror-subscribe, which is trimmed. -// TRIMMED: use wasi:graphics-context/graphics-context@0.0.1.{ context, abstract-buffer } -// - feeds gpu-device.connect-graphics-context and -// gpu-texture.from-graphics-buffer, both trimmed. +package wasi:webgpu@0.0.1; interface webgpu { - resource gpu-supported-limits { - max-texture-dimension1-d: func() -> u32; - max-texture-dimension2-d: func() -> u32; - max-texture-dimension3-d: func() -> u32; - max-texture-array-layers: func() -> u32; - max-bind-groups: func() -> u32; - max-bind-groups-plus-vertex-buffers: func() -> u32; - max-bindings-per-bind-group: func() -> u32; - max-dynamic-uniform-buffers-per-pipeline-layout: func() -> u32; - max-dynamic-storage-buffers-per-pipeline-layout: func() -> u32; - max-sampled-textures-per-shader-stage: func() -> u32; - max-samplers-per-shader-stage: func() -> u32; - max-storage-buffers-per-shader-stage: func() -> u32; - max-storage-textures-per-shader-stage: func() -> u32; - max-uniform-buffers-per-shader-stage: func() -> u32; - max-uniform-buffer-binding-size: func() -> u64; - max-storage-buffer-binding-size: func() -> u64; - min-uniform-buffer-offset-alignment: func() -> u32; - min-storage-buffer-offset-alignment: func() -> u32; - max-vertex-buffers: func() -> u32; - max-buffer-size: func() -> u64; - max-vertex-attributes: func() -> u32; - max-vertex-buffer-array-stride: func() -> u32; - max-inter-stage-shader-variables: func() -> u32; - max-color-attachments: func() -> u32; - max-color-attachment-bytes-per-sample: func() -> u32; - max-compute-workgroup-storage-size: func() -> u32; - max-compute-invocations-per-workgroup: func() -> u32; - max-compute-workgroup-size-x: func() -> u32; - max-compute-workgroup-size-y: func() -> u32; - max-compute-workgroup-size-z: func() -> u32; - max-compute-workgroups-per-dimension: func() -> u32; + record create-pipeline-error { + kind: create-pipeline-error-kind, + message: string, } - resource gpu-supported-features { - has: func(value: string) -> bool; + variant create-pipeline-error-kind { + gpu-pipeline-error(gpu-pipeline-error-reason), } - // TRIMMED resource wgsl-language-features (queried via gpu.wgsl-language-features, trimmed). - resource gpu-adapter-info { - vendor: func() -> string; - architecture: func() -> string; - device: func() -> string; - description: func() -> string; - subgroup-min-size: func() -> u32; - subgroup-max-size: func() -> u32; + record get-mapped-range-error { + kind: get-mapped-range-error-kind, + message: string, + } + variant get-mapped-range-error-kind { + operation-error, + range-error, + type-error, } resource gpu { request-adapter: func(options: option) -> option; - // TRIMMED: get-preferred-canvas-format (canvas presentation not used). - // TRIMMED: wgsl-language-features (no shader feature querying needed). - } - enum gpu-power-preference { - low-power, - high-performance, - } - record gpu-request-adapter-options { - feature-level: option, - power-preference: option, - force-fallback-adapter: option, - xr-compatible: option, } resource gpu-adapter { features: func() -> gpu-supported-features; @@ -89,61 +34,42 @@ interface webgpu { is-fallback-adapter: func() -> bool; request-device: func(descriptor: option) -> result; } - resource record-option-gpu-size64 { - constructor(); - add: func(key: string, value: option); - get: func(key: string) -> option>; - has: func(key: string) -> bool; - remove: func(key: string); - keys: func() -> list; - values: func() -> list>; - entries: func() -> list>>; + resource gpu-adapter-info { + vendor: func() -> string; + architecture: func() -> string; + device: func() -> string; + description: func() -> string; + subgroup-min-size: func() -> u32; + subgroup-max-size: func() -> u32; } - enum gpu-feature-name { - depth-clip-control, - depth32float-stencil8, - texture-compression-bc, - texture-compression-bc-sliced3d, - texture-compression-etc2, - texture-compression-astc, - texture-compression-astc-sliced3d, - timestamp-query, - indirect-first-instance, - shader-f16, - rg11b10ufloat-renderable, - bgra8unorm-storage, - float32-filterable, - float32-blendable, - clip-distances, - dual-source-blending, - subgroups, + resource gpu-bind-group { + label: func() -> string; + set-label: func(label: string); } - resource gpu-device { - features: func() -> gpu-supported-features; - limits: func() -> gpu-supported-limits; - adapter-info: func() -> gpu-adapter-info; - queue: func() -> gpu-queue; - destroy: func(); - create-buffer: func(descriptor: gpu-buffer-descriptor) -> gpu-buffer; - // TRIMMED: create-texture (no texture pipeline in this subset). - // TRIMMED: create-sampler (textures/samplers trimmed). - create-bind-group-layout: func(descriptor: gpu-bind-group-layout-descriptor) -> gpu-bind-group-layout; - create-pipeline-layout: func(descriptor: gpu-pipeline-layout-descriptor) -> gpu-pipeline-layout; - create-bind-group: func(descriptor: gpu-bind-group-descriptor) -> gpu-bind-group; - create-shader-module: func(descriptor: gpu-shader-module-descriptor) -> gpu-shader-module; - create-compute-pipeline: func(descriptor: gpu-compute-pipeline-descriptor) -> gpu-compute-pipeline; - // TRIMMED: create-render-pipeline, create-compute-pipeline-async, - // create-render-pipeline-async (no render path; sync compute - // pipeline creation is enough for the matmul demo). - create-command-encoder: func(descriptor: option) -> gpu-command-encoder; - // TRIMMED: create-render-bundle-encoder, create-query-set - // (no rendering, no GPU timing queries). + record gpu-bind-group-descriptor { + layout: borrow, + entries: list, + label: option, + } + record gpu-bind-group-entry { + binding: gpu-index32, + %resource: gpu-binding-resource, + } + resource gpu-bind-group-layout { label: func() -> string; set-label: func(label: string); - // TRIMMED: lost, push-error-scope, pop-error-scope, - // onuncapturederror-subscribe (error-scope / device-lost flow not - // exposed; failures surface via the per-call result<_, *-error>). - // TRIMMED: connect-graphics-context (no presentation surface). + } + record gpu-bind-group-layout-descriptor { + entries: list, + label: option, + } + record gpu-bind-group-layout-entry { + binding: gpu-index32, + visibility: gpu-shader-stage-flags, + buffer: option, + } + variant gpu-binding-resource { + gpu-buffer-binding(gpu-buffer-binding), } resource gpu-buffer { size: func() -> gpu-size64-out; @@ -157,12 +83,33 @@ interface webgpu { set-label: func(label: string); get-mapped-range-set-with-copy: func(data: list, offset: option, size: option) -> result<_, get-mapped-range-error>; } + record gpu-buffer-binding { + buffer: borrow, + offset: option, + size: option, + } + record gpu-buffer-binding-layout { + %type: option, + has-dynamic-offset: option, + min-binding-size: option, + } + enum gpu-buffer-binding-type { + uniform, + storage, + read-only-storage, + } + record gpu-buffer-descriptor { + size: gpu-size64, + usage: gpu-buffer-usage-flags, + mapped-at-creation: option, + label: option, + } + type gpu-buffer-dynamic-offset = u32; enum gpu-buffer-map-state { unmapped, pending, mapped, } - type gpu-buffer-usage-flags = u32; resource gpu-buffer-usage { MAP-READ: static func() -> gpu-flags-constant; MAP-WRITE: static func() -> gpu-flags-constant; @@ -175,91 +122,35 @@ interface webgpu { INDIRECT: static func() -> gpu-flags-constant; QUERY-RESOLVE: static func() -> gpu-flags-constant; } - type gpu-map-mode-flags = u32; - resource gpu-map-mode { - READ: static func() -> gpu-flags-constant; - WRITE: static func() -> gpu-flags-constant; - } - // TRIMMED resource gpu-texture and its descriptors/views/usage/dimension/ - // aspect/format enums (no textures in the compute-only subset). - // TRIMMED enum gpu-texture-dimension, gpu-texture-view-dimension, - // gpu-texture-aspect, gpu-texture-format (96 variants). - // TRIMMED type gpu-texture-usage-flags, resource gpu-texture-usage, - // resource gpu-texture-view. - // TRIMMED resource gpu-sampler and its descriptor/binding/layout records - // plus gpu-address-mode, gpu-filter-mode, gpu-mipmap-filter-mode, - // gpu-compare-function, gpu-sampler-binding-type, - // gpu-texture-sample-type, gpu-storage-texture-access. - resource gpu-bind-group-layout { + type gpu-buffer-usage-flags = u32; + resource gpu-command-buffer { label: func() -> string; set-label: func(label: string); } - type gpu-shader-stage-flags = u32; - resource gpu-shader-stage { - VERTEX: static func() -> gpu-flags-constant; - FRAGMENT: static func() -> gpu-flags-constant; - COMPUTE: static func() -> gpu-flags-constant; - } - enum gpu-buffer-binding-type { - uniform, - storage, - read-only-storage, + record gpu-command-buffer-descriptor { + label: option, } - // TRIMMED record gpu-sampler-binding-layout, gpu-texture-binding-layout, - // gpu-storage-texture-binding-layout (only buffer bindings used). - resource gpu-bind-group { + resource gpu-command-encoder { + begin-compute-pass: func(descriptor: option) -> gpu-compute-pass-encoder; + copy-buffer-to-buffer: func(source: borrow, source-offset: gpu-size64, destination: borrow, destination-offset: gpu-size64, size: gpu-size64); + finish: func(descriptor: option) -> gpu-command-buffer; label: func() -> string; set-label: func(label: string); } - resource gpu-pipeline-layout { - label: func() -> string; - set-label: func(label: string); + record gpu-command-encoder-descriptor { + label: option, } - record gpu-pipeline-layout-descriptor { - bind-group-layouts: list>>, + record gpu-compute-pass-descriptor { label: option, } - resource gpu-shader-module { + resource gpu-compute-pass-encoder { + set-pipeline: func(pipeline: borrow); + dispatch-workgroups: func(workgroup-count-x: gpu-size32, workgroup-count-y: option, workgroup-count-z: option); + end: func(); label: func() -> string; set-label: func(label: string); - // TRIMMED: get-compilation-info (no compile-time diagnostic surfacing; - // shader errors trap during create-compute-pipeline instead). - } - // TRIMMED resource gpu-compilation-message, gpu-compilation-info, - // enum gpu-compilation-message-type (paired with get-compilation-info). - enum gpu-pipeline-error-reason { - validation, - internal, - } - variant gpu-layout-mode { - specific(borrow), - auto, - } - record gpu-shader-module-compilation-hint { - entry-point: string, - layout: option, - } - record gpu-shader-module-descriptor { - code: string, - compilation-hints: option>, - label: option, - } - resource record-gpu-pipeline-constant-value { - constructor(); - add: func(key: string, value: gpu-pipeline-constant-value); - get: func(key: string) -> option; - has: func(key: string) -> bool; - remove: func(key: string); - keys: func() -> list; - values: func() -> list; - entries: func() -> list>; - } - record gpu-programmable-stage { - module: borrow, - entry-point: option, - constants: option, + set-bind-group: func(index: gpu-index32, bind-group: option>, dynamic-offsets-data: option>, dynamic-offsets-data-start: option, dynamic-offsets-data-length: option) -> result<_, set-bind-group-error>; } - type gpu-pipeline-constant-value = f64; resource gpu-compute-pipeline { label: func() -> string; set-label: func(label: string); @@ -270,211 +161,211 @@ interface webgpu { layout: gpu-layout-mode, label: option, } - // TRIMMED resource gpu-render-pipeline plus the entire render pipeline - // descriptor tree: gpu-render-pipeline-descriptor, gpu-vertex-state, - // gpu-vertex-buffer-layout, gpu-vertex-attribute, gpu-fragment-state, - // gpu-color-target-state, gpu-blend-state, gpu-blend-component, - // gpu-primitive-state, gpu-depth-stencil-state, gpu-stencil-face-state, - // gpu-multisample-state; and their supporting enums - // (gpu-primitive-topology, gpu-front-face, gpu-cull-mode, - // gpu-blend-factor, gpu-blend-operation, gpu-stencil-operation, - // gpu-index-format, gpu-vertex-format, gpu-vertex-step-mode); - // and gpu-color-write flags resource / type. - resource gpu-command-buffer { + resource gpu-device { + features: func() -> gpu-supported-features; + limits: func() -> gpu-supported-limits; + adapter-info: func() -> gpu-adapter-info; + queue: func() -> gpu-queue; + destroy: func(); + create-buffer: func(descriptor: gpu-buffer-descriptor) -> gpu-buffer; + create-bind-group-layout: func(descriptor: gpu-bind-group-layout-descriptor) -> gpu-bind-group-layout; + create-pipeline-layout: func(descriptor: gpu-pipeline-layout-descriptor) -> gpu-pipeline-layout; + create-bind-group: func(descriptor: gpu-bind-group-descriptor) -> gpu-bind-group; + create-shader-module: func(descriptor: gpu-shader-module-descriptor) -> gpu-shader-module; + create-compute-pipeline: func(descriptor: gpu-compute-pipeline-descriptor) -> gpu-compute-pipeline; + create-command-encoder: func(descriptor: option) -> gpu-command-encoder; label: func() -> string; set-label: func(label: string); } - record gpu-command-buffer-descriptor { + record gpu-device-descriptor { + required-features: option>, + required-limits: option, + default-queue: option, label: option, } - resource gpu-command-encoder { - // TRIMMED: begin-render-pass (no render passes). - begin-compute-pass: func(descriptor: option) -> gpu-compute-pass-encoder; - copy-buffer-to-buffer: func(source: borrow, source-offset: gpu-size64, destination: borrow, destination-offset: gpu-size64, size: gpu-size64); - // TRIMMED: copy-buffer-to-texture, copy-texture-to-buffer, - // copy-texture-to-texture (no textures in this subset). - // TRIMMED: clear-buffer (not used; write-buffer-with-copy serves init). - // TRIMMED: resolve-query-set (no query sets). - finish: func(descriptor: option) -> gpu-command-buffer; - label: func() -> string; - set-label: func(label: string); - // TRIMMED: push-debug-group, pop-debug-group, insert-debug-marker - // (debug markers unused; can be re-added without functional impact). + enum gpu-feature-name { + depth-clip-control, + depth32float-stencil8, + texture-compression-bc, + texture-compression-bc-sliced3d, + texture-compression-etc2, + texture-compression-astc, + texture-compression-astc-sliced3d, + timestamp-query, + indirect-first-instance, + shader-f16, + rg11b10ufloat-renderable, + bgra8unorm-storage, + float32-filterable, + float32-blendable, + clip-distances, + dual-source-blending, + subgroups, } - record gpu-command-encoder-descriptor { - label: option, + type gpu-flags-constant = u32; + type gpu-index32 = u32; + variant gpu-layout-mode { + specific(borrow), + auto, } - resource gpu-compute-pass-encoder { - set-pipeline: func(pipeline: borrow); - dispatch-workgroups: func(workgroup-count-x: gpu-size32, workgroup-count-y: option, workgroup-count-z: option); - // TRIMMED: dispatch-workgroups-indirect (no indirect buffers). - end: func(); + resource gpu-map-mode { + READ: static func() -> gpu-flags-constant; + WRITE: static func() -> gpu-flags-constant; + } + type gpu-map-mode-flags = u32; + type gpu-pipeline-constant-value = f64; + enum gpu-pipeline-error-reason { + validation, + internal, + } + resource gpu-pipeline-layout { label: func() -> string; set-label: func(label: string); - // TRIMMED: push-debug-group, pop-debug-group, insert-debug-marker. - set-bind-group: func(index: gpu-index32, bind-group: option>, dynamic-offsets-data: option>, dynamic-offsets-data-start: option, dynamic-offsets-data-length: option) -> result<_, set-bind-group-error>; } - // TRIMMED resource gpu-render-pass-encoder and all render-pass primitives - // (set-viewport, set-scissor-rect, set-blend-constant, - // set-stencil-reference, begin/end-occlusion-query, execute-bundles, - // set-index-buffer, set-vertex-buffer, draw / draw-indexed / - // draw-indirect / draw-indexed-indirect). - // TRIMMED enum gpu-load-op, gpu-store-op (only used by render-pass attachments). - // TRIMMED resource gpu-render-bundle, gpu-render-bundle-encoder and their - // descriptors gpu-render-bundle-descriptor, - // gpu-render-bundle-encoder-descriptor. - record gpu-queue-descriptor { + record gpu-pipeline-layout-descriptor { + bind-group-layouts: list>>, label: option, } - record gpu-device-descriptor { - required-features: option>, - required-limits: option, - default-queue: option, - label: option, + enum gpu-power-preference { + low-power, + high-performance, + } + record gpu-programmable-stage { + module: borrow, + entry-point: option, + constants: option, } resource gpu-queue { submit: func(command-buffers: list>); - // TRIMMED: on-submitted-work-done (no completion pollables in this subset). write-buffer-with-copy: func(buffer: borrow, buffer-offset: gpu-size64, data: list, data-offset: option, size: option) -> result<_, write-buffer-error>; - // TRIMMED: write-texture-with-copy (no textures). label: func() -> string; set-label: func(label: string); } - // TRIMMED resource gpu-query-set, enum gpu-query-type, - // record gpu-query-set-descriptor (no GPU-side timestamping queries). - // TRIMMED resource gpu-canvas-context and all canvas presentation records: - // gpu-canvas-configuration, gpu-canvas-configuration-owned, - // gpu-canvas-tone-mapping, gpu-canvas-alpha-mode, - // gpu-canvas-tone-mapping-mode, predefined-color-space. - // TRIMMED enum gpu-device-lost-reason, resource gpu-device-lost-info - // (device.lost trimmed). - // TRIMMED resource gpu-error, enum gpu-error-filter, variant gpu-error-kind, - // resource gpu-uncaptured-error-event, record pop-error-scope-error, - // variant pop-error-scope-error-kind (error-scope flow trimmed). - type gpu-buffer-dynamic-offset = u32; - // TRIMMED type gpu-stencil-value, gpu-sample-mask, gpu-depth-bias, - // gpu-signed-offset32, gpu-integer-coordinate, gpu-integer-coordinate-out - // (only referenced by render / texture / query primitives). - // TRIMMED record gpu-render-pass-depth-stencil-attachment, - // gpu-render-pass-color-attachment, gpu-render-pass-descriptor, - // gpu-render-pass-timestamp-writes. - type gpu-size64 = u64; - record gpu-buffer-descriptor { - size: gpu-size64, - usage: gpu-buffer-usage-flags, - mapped-at-creation: option, + record gpu-queue-descriptor { label: option, } - record gpu-buffer-binding-layout { - %type: option, - has-dynamic-offset: option, - min-binding-size: option, - } - record gpu-buffer-binding { - buffer: borrow, - offset: option, - size: option, - } - variant gpu-binding-resource { - gpu-buffer-binding(gpu-buffer-binding), - // TRIMMED: gpu-sampler(borrow), - // gpu-texture-view(borrow) - resources trimmed. - } - // TRIMMED record gpu-texture-view-descriptor, gpu-texture-descriptor, - // gpu-texel-copy-buffer-layout, gpu-texel-copy-buffer-info, - // gpu-texel-copy-texture-info, gpu-origin3-d, gpu-extent3-d. - type gpu-index32 = u32; - record gpu-bind-group-layout-entry { - binding: gpu-index32, - visibility: gpu-shader-stage-flags, - buffer: option, - // TRIMMED: sampler: option, - // texture: option, - // storage-texture: option - // - non-buffer binding kinds not used. + record gpu-request-adapter-options { + feature-level: option, + power-preference: option, + force-fallback-adapter: option, + xr-compatible: option, } - record gpu-bind-group-layout-descriptor { - entries: list, - label: option, + resource gpu-shader-module { + label: func() -> string; + set-label: func(label: string); } - record gpu-bind-group-entry { - binding: gpu-index32, - %resource: gpu-binding-resource, + record gpu-shader-module-compilation-hint { + entry-point: string, + layout: option, } - record gpu-bind-group-descriptor { - layout: borrow, - entries: list, + record gpu-shader-module-descriptor { + code: string, + compilation-hints: option>, label: option, } - // TRIMMED record gpu-vertex-attribute, gpu-vertex-buffer-layout, - // gpu-vertex-state, gpu-multisample-state, - // gpu-render-pipeline-descriptor (render path). - // TRIMMED record gpu-compute-pass-timestamp-writes (no query sets to write to). - record gpu-compute-pass-descriptor { - // TRIMMED: timestamp-writes: option - // - referenced gpu-query-set which is trimmed. - label: option, + resource gpu-shader-stage { + VERTEX: static func() -> gpu-flags-constant; + FRAGMENT: static func() -> gpu-flags-constant; + COMPUTE: static func() -> gpu-flags-constant; } + type gpu-shader-stage-flags = u32; type gpu-size32 = u32; - type gpu-size64-out = u64; type gpu-size32-out = u32; - type gpu-flags-constant = u32; - // TRIMMED record gpu-color (only used by render-pass blend constants). - get-gpu: func() -> gpu; - variant request-device-error-kind { - type-error, - operation-error, - } - record request-device-error { - kind: request-device-error-kind, - message: string, + type gpu-size64 = u64; + type gpu-size64-out = u64; + resource gpu-supported-features { + has: func(value: string) -> bool; } - variant create-pipeline-error-kind { - gpu-pipeline-error(gpu-pipeline-error-reason), + resource gpu-supported-limits { + max-texture-dimension1-d: func() -> u32; + max-texture-dimension2-d: func() -> u32; + max-texture-dimension3-d: func() -> u32; + max-texture-array-layers: func() -> u32; + max-bind-groups: func() -> u32; + max-bind-groups-plus-vertex-buffers: func() -> u32; + max-bindings-per-bind-group: func() -> u32; + max-dynamic-uniform-buffers-per-pipeline-layout: func() -> u32; + max-dynamic-storage-buffers-per-pipeline-layout: func() -> u32; + max-sampled-textures-per-shader-stage: func() -> u32; + max-samplers-per-shader-stage: func() -> u32; + max-storage-buffers-per-shader-stage: func() -> u32; + max-storage-textures-per-shader-stage: func() -> u32; + max-uniform-buffers-per-shader-stage: func() -> u32; + max-uniform-buffer-binding-size: func() -> u64; + max-storage-buffer-binding-size: func() -> u64; + min-uniform-buffer-offset-alignment: func() -> u32; + min-storage-buffer-offset-alignment: func() -> u32; + max-vertex-buffers: func() -> u32; + max-buffer-size: func() -> u64; + max-vertex-attributes: func() -> u32; + max-vertex-buffer-array-stride: func() -> u32; + max-inter-stage-shader-variables: func() -> u32; + max-color-attachments: func() -> u32; + max-color-attachment-bytes-per-sample: func() -> u32; + max-compute-workgroup-storage-size: func() -> u32; + max-compute-invocations-per-workgroup: func() -> u32; + max-compute-workgroup-size-x: func() -> u32; + max-compute-workgroup-size-y: func() -> u32; + max-compute-workgroup-size-z: func() -> u32; + max-compute-workgroups-per-dimension: func() -> u32; } - record create-pipeline-error { - kind: create-pipeline-error-kind, + record map-async-error { + kind: map-async-error-kind, message: string, } - // TRIMMED variant create-query-set-error-kind, record create-query-set-error - // (no create-query-set). variant map-async-error-kind { operation-error, range-error, abort-error, } - record map-async-error { - kind: map-async-error-kind, - message: string, + resource record-gpu-pipeline-constant-value { + constructor(); + add: func(key: string, value: gpu-pipeline-constant-value); + get: func(key: string) -> option; + has: func(key: string) -> bool; + remove: func(key: string); + keys: func() -> list; + values: func() -> list; + entries: func() -> list>; } - variant get-mapped-range-error-kind { - operation-error, - range-error, - type-error, + resource record-option-gpu-size64 { + constructor(); + add: func(key: string, value: option); + get: func(key: string) -> option>; + has: func(key: string) -> bool; + remove: func(key: string); + keys: func() -> list; + values: func() -> list>; + entries: func() -> list>>; } - record get-mapped-range-error { - kind: get-mapped-range-error-kind, + record request-device-error { + kind: request-device-error-kind, message: string, } - variant unmap-error-kind { - abort-error, + variant request-device-error-kind { + type-error, + operation-error, } - record unmap-error { - kind: unmap-error-kind, + record set-bind-group-error { + kind: set-bind-group-error-kind, message: string, } variant set-bind-group-error-kind { range-error, } - record set-bind-group-error { - kind: set-bind-group-error-kind, + record unmap-error { + kind: unmap-error-kind, message: string, } - variant write-buffer-error-kind { - operation-error, + variant unmap-error-kind { + abort-error, } record write-buffer-error { kind: write-buffer-error-kind, message: string, } + variant write-buffer-error-kind { + operation-error, + } + get-gpu: func() -> gpu; } diff --git a/generated/specs/wit/world.wit b/generated/specs/wit/world.wit new file mode 100644 index 0000000..d9d3afd --- /dev/null +++ b/generated/specs/wit/world.wit @@ -0,0 +1,41 @@ +package et:ws-wasi@0.1.0; + +interface ws { + use et:ws-messages/messages@0.1.0.{ ws-message }; + type ws-error = string; + enum state { + connecting, + connected, + closing, + closed, + } + connect: func() -> result<_, ws-error>; + get-state: func() -> state; + agent-id: func() -> string; + send: func(message: ws-message) -> result<_, ws-error>; + recv: func(timeout-ms: u32) -> result, ws-error>; + disconnect: func(); +} + +interface entry { + run: func() -> result<_, string>; +} + +world runner { + import wasi:logging/logging@0.1.0-draft; + import wasi:keyvalue/store@0.2.0-draft; + import ws; + import wasi:webgpu/webgpu@0.0.1; + export entry; +} + +world module { + include runner; + import wasi:clocks/wall-clock@0.2.6; + import wasi:clocks/monotonic-clock@0.2.6; + import wasi:io/poll@0.2.6; + import wasi:nn/tensor@0.2.0-rc-2024-10-28; + import wasi:nn/graph@0.2.0-rc-2024-10-28; + import wasi:nn/inference@0.2.0-rc-2024-10-28; + import wasi:nn/errors@0.2.0-rc-2024-10-28; +} diff --git a/generated/specs/ws.yaml b/generated/specs/ws.yaml new file mode 100644 index 0000000..892f69c --- /dev/null +++ b/generated/specs/ws.yaml @@ -0,0 +1,304 @@ +asyncapi: 3.0.0 +channels: + ws: + address: /ws +components: + messages: + et-agent-message: + contentType: application/json + name: et-agent-message + payload: + properties: + from_agent_id: + type: string + message: + description: Arbitrary JSON value (opaque to the protocol) + message_id: + type: string + scope: + $ref: "#/components/schemas/MessageScope" + server_received_at: + type: string + type: + const: et-agent-message + type: string + required: + - type + - message_id + - from_agent_id + - scope + - server_received_at + - message + title: WsAgentMessage + type: object + title: et-agent-message + et-alive: + contentType: application/json + name: et-alive + payload: + properties: + timestamp: + type: string + type: + const: et-alive + type: string + required: + - type + - timestamp + title: WsAlive + type: object + title: et-alive + et-broadcast-message: + contentType: application/json + name: et-broadcast-message + payload: + properties: + message: + description: Arbitrary JSON value (opaque to the protocol) + type: + const: et-broadcast-message + type: string + required: + - type + - message + title: WsBroadcastMessage + type: object + title: et-broadcast-message + et-client-event: + contentType: application/json + name: et-client-event + payload: + properties: + action: + type: string + capability: + type: string + details: + description: Arbitrary JSON value (opaque to the protocol) + type: + const: et-client-event + type: string + required: + - type + - capability + - action + - details + title: WsClientEvent + type: object + title: et-client-event + et-connect: + contentType: application/json + name: et-connect + payload: + properties: + agent_id: + type: + - string + - "null" + type: + const: et-connect + type: string + required: + - type + title: WsConnect + type: object + title: et-connect + et-connect-ack: + contentType: application/json + name: et-connect-ack + payload: + properties: + agent_id: + type: string + status: + $ref: "#/components/schemas/ConnectStatus" + type: + const: et-connect-ack + type: string + required: + - type + - agent_id + - status + title: WsConnectAck + type: object + title: et-connect-ack + et-invalid: + contentType: application/json + name: et-invalid + payload: + properties: + detail: + type: string + message_id: + type: + - string + - "null" + type: + const: et-invalid + type: string + required: + - type + - detail + title: WsInvalid + type: object + title: et-invalid + et-list-agents: + contentType: application/json + name: et-list-agents + payload: + properties: + type: + const: et-list-agents + type: string + required: + - type + title: WsListAgents + type: object + title: et-list-agents + et-list-agents-response: + contentType: application/json + name: et-list-agents-response + payload: + properties: + agents: + items: + $ref: "#/components/schemas/AgentSummary" + type: array + type: + const: et-list-agents-response + type: string + required: + - type + - agents + title: WsListAgentsResponse + type: object + title: et-list-agents-response + et-message-ack: + contentType: application/json + name: et-message-ack + payload: + properties: + message_id: + type: string + type: + const: et-message-ack + type: string + required: + - type + - message_id + title: WsMessageAck + type: object + title: et-message-ack + et-message-status: + contentType: application/json + name: et-message-status + payload: + properties: + detail: + type: string + message_id: + type: + - string + - "null" + status: + $ref: "#/components/schemas/MessageDeliveryStatus" + type: + const: et-message-status + type: string + required: + - type + - status + - detail + title: WsMessageStatus + type: object + title: et-message-status + et-response: + contentType: application/json + name: et-response + payload: + properties: + message: + type: string + type: + const: et-response + type: string + required: + - type + - message + title: WsResponse + type: object + title: et-response + et-send-agent-message: + contentType: application/json + name: et-send-agent-message + payload: + properties: + message: + description: Arbitrary JSON value (opaque to the protocol) + to_agent_id: + type: string + type: + const: et-send-agent-message + type: string + required: + - type + - to_agent_id + - message + title: WsSendAgentMessage + type: object + title: et-send-agent-message + schemas: + AgentConnectionState: + enum: + - connected + - disconnected + type: string + AgentSummary: + properties: + agent_id: + type: string + last_known_ip: + type: + - string + - "null" + state: + $ref: "#/components/schemas/AgentConnectionState" + required: + - agent_id + - state + type: object + ConnectStatus: + enum: + - assigned + - reconnected + type: string + MessageDeliveryStatus: + enum: + - delivered + - queued + - acknowledged + - broadcast + type: string + MessageScope: + enum: + - direct + - broadcast + type: string +info: + description: Hub-style WebSocket protocol. Generated from edge_toolkit::ws::WsMessage. + title: Edge Toolkit WebSocket Protocol + version: 0.1.0 +operations: + receiveWsMessage: + action: receive + channel: + $ref: "#/channels/ws" + sendWsMessage: + action: send + channel: + $ref: "#/channels/ws" +servers: + local: + description: Default ws-server bind address (mise run ws-server) + host: localhost:8080 + protocol: ws diff --git a/generated/zig-rest/build.zig.zon b/generated/zig-rest/build.zig.zon new file mode 100644 index 0000000..de374f2 --- /dev/null +++ b/generated/zig-rest/build.zig.zon @@ -0,0 +1,11 @@ +.{ + .name = .et_rest_client, + .version = "0.1.0", + .description = "Typed Zig REST client for ws-server (src/et_rest_client.zig is regenerated).", + .license = "Apache-2.0 or MIT", + .fingerprint = 0xa1d2f70ec4cd9512, + .paths = .{ + "build.zig.zon", + "src", + }, +} diff --git a/generated/zig-rest/et_rest_client.o b/generated/zig-rest/et_rest_client.o new file mode 100644 index 0000000000000000000000000000000000000000..9075d251d496d8a71211d634b1263344359f7b3a GIT binary patch literal 193 zcmXAiK@P$o5Jj;rYFx;|#7hv))J)qcAsq$+L!-O8G4Tc-SB%U5U*7);N;oK`y3fAv z>TpEf^e~WTgggWcF+aM7-#^;7diCr&^?J6b1+qgK76hpoOK |*value| value.deinit(), + .api_error => |*value| value.deinit(), + .parse_error => |*value| value.raw.deinit(), + } + } + }; +} + +pub const Client = struct { + allocator: std.mem.Allocator, + io: std.Io, + http: std.http.Client, + api_key: []const u8, + base_url: []const u8 = "", + organization: ?[]const u8 = null, + project: ?[]const u8 = null, + default_headers: []const std.http.Header = &.{}, + + pub fn init(allocator: std.mem.Allocator, io: std.Io, api_key: []const u8) Client { + return .{ + .allocator = allocator, + .io = io, + .http = .{ .allocator = allocator, .io = io }, + .api_key = api_key, + }; + } + + pub fn deinit(self: *Client) void { + self.http.deinit(); + } + + pub fn withBaseUrl(self: *Client, base_url: []const u8) void { + self.base_url = base_url; + } +}; + +fn isQueryChar(c: u8) bool { + return std.ascii.isAlphanumeric(c) or switch (c) { + '-', '.', '_', '~' => true, + else => false, + }; +} + +fn writeQueryComponent(writer: *std.Io.Writer, value: []const u8) !void { + try std.Uri.Component.percentEncode(writer, value, isQueryChar); +} + +fn writeQueryValue(writer: *std.Io.Writer, value: anytype) !void { + const T = @TypeOf(value); + switch (@typeInfo(T)) { + .pointer => |ptr| { + if (ptr.size == .slice and ptr.child == u8) { + try writeQueryComponent(writer, value); + } else { + try std.json.Stringify.value(value, .{}, writer); + } + }, + .int, .comptime_int, .float, .comptime_float, .bool => try writer.print("{}", .{value}), + .@"enum" => try writeQueryComponent(writer, @tagName(value)), + else => try std.json.Stringify.value(value, .{}, writer), + } +} + +fn appendQueryParam(writer: *std.Io.Writer, first_query: *bool, name: []const u8, value: anytype) !void { + if (first_query.*) { + try writer.writeByte('?'); + first_query.* = false; + } else { + try writer.writeByte('&'); + } + try writeQueryComponent(writer, name); + try writer.writeByte('='); + try writeQueryValue(writer, value); +} + +pub fn requestRaw(client: *Client, method: std.http.Method, url: []const u8, payload: ?[]const u8) !RawResponse { + // Replaced by et-int-gen: dispatch via the host JS shim instead of + // `std.http.Client.fetch`, which can't reach the network from + // browser wasm. The JS side proxies to `fetch()` via + // SharedArrayBuffer + Atomics so this stays synchronous in Zig. + const allocator = client.allocator; + const method_str = @tagName(method); + const body_slice = payload orelse ""; + const response_buf = try allocator.alloc(u8, 64 * 1024); + const written = js_rest_request( + method_str.ptr, + method_str.len, + url.ptr, + url.len, + body_slice.ptr, + body_slice.len, + response_buf.ptr, + response_buf.len, + ); + if (written < 0) { + allocator.free(response_buf); + return error.RequestFailed; + } + const n: usize = @intCast(written); + const body = try allocator.realloc(response_buf, n); + return .{ .allocator = allocator, .status = .ok, .body = body }; +} + +pub fn getRaw(client: *Client, path: []const u8) !RawResponse { + const url = try std.fmt.allocPrint(client.allocator, "{s}{s}", .{ client.base_url, path }); + defer client.allocator.free(url); + return requestRaw(client, .GET, url, null); +} + +pub fn postJsonRaw(client: *Client, path: []const u8, payload: anytype) !RawResponse { + const allocator = client.allocator; + var str: std.Io.Writer.Allocating = .init(allocator); + defer str.deinit(); + try std.json.Stringify.value(payload, .{ .emit_null_optional_fields = false }, &str.writer); + + const url = try std.fmt.allocPrint(allocator, "{s}{s}", .{ client.base_url, path }); + defer allocator.free(url); + return requestRaw(client, .POST, url, str.written()); +} + +pub fn parseRawResponse(comptime T: type, raw: RawResponse) !ApiResult(T) { + if (raw.status.class() != .success) return .{ .api_error = raw }; + const parsed = std.json.parseFromSlice(T, raw.allocator, raw.body, .{ .ignore_unknown_fields = true }) catch |err| { + return .{ .parse_error = .{ .raw = raw, .error_name = @errorName(err) } }; + }; + return .{ .ok = .{ .allocator = raw.allocator, .body = raw.body, .parsed = parsed } }; +} + +pub fn getJsonResult(comptime T: type, client: *Client, path: []const u8) !ApiResult(T) { + return parseRawResponse(T, try getRaw(client, path)); +} + +pub fn postJsonResult(comptime T: type, client: *Client, path: []const u8, payload: anytype) !ApiResult(T) { + return parseRawResponse(T, try postJsonRaw(client, path, payload)); +} + +const max_sse_line_size = 256 * 1024; +const max_sse_event_size = 1024 * 1024; + +pub fn parseSseBytes(allocator: std.mem.Allocator, bytes: []const u8, callback: anytype) !void { + var reader: std.Io.Reader = .fixed(bytes); + try parseSseReader(allocator, &reader, callback); +} + +pub fn parseSseReader(allocator: std.mem.Allocator, reader: *std.Io.Reader, callback: anytype) !void { + var line_buf: std.Io.Writer.Allocating = .init(allocator); + defer line_buf.deinit(); + + var event_data: std.Io.Writer.Allocating = .init(allocator); + defer event_data.deinit(); + + while (true) { + line_buf.clearRetainingCapacity(); + + _ = reader.streamDelimiterLimit(&line_buf.writer, '\n', .limited(max_sse_line_size)) catch |err| switch (err) { + error.StreamTooLong => return error.SseLineTooLong, + error.ReadFailed => return err, + error.WriteFailed => return err, + }; + + const ended_with_delimiter = blk: { + const byte = reader.peekByte() catch |err| switch (err) { + error.EndOfStream => break :blk false, + error.ReadFailed => return err, + }; + if (byte == '\n') { + _ = try reader.takeByte(); + break :blk true; + } + break :blk false; + }; + + if (try processSseLine(&event_data, line_buf.written(), callback)) return; + if (!ended_with_delimiter) break; + } + + _ = try dispatchSseEvent(&event_data, callback); +} + +fn processSseLine(event_data: *std.Io.Writer.Allocating, raw_line: []const u8, callback: anytype) !bool { + const line = std.mem.trimEnd(u8, raw_line, "\r"); + if (line.len == 0) return try dispatchSseEvent(event_data, callback); + if (line[0] == ':') return false; + + const colon = std.mem.indexOfScalar(u8, line, ':') orelse return false; + const field = line[0..colon]; + if (!std.mem.eql(u8, field, "data")) return false; + + var value = line[colon + 1 ..]; + if (value.len > 0 and value[0] == ' ') value = value[1..]; + const separator_len: usize = if (event_data.written().len == 0) 0 else 1; + if (event_data.written().len + separator_len + value.len > max_sse_event_size) return error.SseEventTooLong; + if (separator_len != 0) try event_data.writer.writeByte('\n'); + try event_data.writer.writeAll(value); + return false; +} + +fn dispatchSseEvent(event_data: *std.Io.Writer.Allocating, callback: anytype) !bool { + const data = event_data.written(); + if (data.len == 0) return false; + defer event_data.clearRetainingCapacity(); + + if (std.mem.eql(u8, data, "[DONE]")) return true; + try callback.event(data); + return false; +} + +fn TypedSseCallback(comptime T: type, comptime Callback: type) type { + return struct { + allocator: std.mem.Allocator, + callback: *Callback, + + pub fn event(self: *@This(), data: []const u8) !void { + var parsed = try std.json.parseFromSlice(T, self.allocator, data, .{ .ignore_unknown_fields = true }); + defer parsed.deinit(); + try self.callback.event(&parsed.value); + } + }; +} + +pub fn parseSseBytesTyped(comptime T: type, allocator: std.mem.Allocator, bytes: []const u8, callback: anytype) !void { + const Callback = @TypeOf(callback.*); + var typed_callback: TypedSseCallback(T, Callback) = .{ .allocator = allocator, .callback = callback }; + try parseSseBytes(allocator, bytes, &typed_callback); +} + +pub fn parseSseReaderTyped(comptime T: type, allocator: std.mem.Allocator, reader: *std.Io.Reader, callback: anytype) !void { + const Callback = @TypeOf(callback.*); + var typed_callback: TypedSseCallback(T, Callback) = .{ .allocator = allocator, .callback = callback }; + try parseSseReader(allocator, reader, &typed_callback); +} + +fn stringifyStreamRequest(allocator: std.mem.Allocator, requestBody: anytype) ![]u8 { + var str: std.Io.Writer.Allocating = .init(allocator); + defer str.deinit(); + try std.json.Stringify.value(requestBody, .{ .emit_null_optional_fields = false }, &str.writer); + + var parsed = try std.json.parseFromSlice(std.json.Value, allocator, str.written(), .{ .ignore_unknown_fields = true }); + defer parsed.deinit(); + + if (parsed.value == .object) { + try parsed.value.object.put(parsed.arena.allocator(), "stream", .{ .bool = true }); + } + + var out: std.Io.Writer.Allocating = .init(allocator); + errdefer out.deinit(); + try std.json.Stringify.value(parsed.value, .{ .emit_null_optional_fields = false }, &out.writer); + return try out.toOwnedSlice(); +} + +fn streamJsonTyped(comptime T: type, client: *Client, path: []const u8, requestBody: anytype, callback: anytype) !void { + const Callback = @TypeOf(callback.*); + var typed_callback: TypedSseCallback(T, Callback) = .{ .allocator = client.allocator, .callback = callback }; + try streamJson(client, path, requestBody, &typed_callback); +} + +fn streamJson(client: *Client, path: []const u8, requestBody: anytype, callback: anytype) !void { + const allocator = client.allocator; + const payload = try stringifyStreamRequest(allocator, requestBody); + defer allocator.free(payload); + + var headers = std.ArrayList(std.http.Header).empty; + defer headers.deinit(allocator); + const auth_header = try appendClientHeaders(allocator, &headers, client, true, "text/event-stream"); + defer if (auth_header) |value| allocator.free(value); + + const url = try std.fmt.allocPrint(allocator, "{s}{s}", .{ client.base_url, path }); + defer allocator.free(url); + const uri = try std.Uri.parse(url); + + var req = try client.http.request(.POST, uri, .{ + .redirect_behavior = .unhandled, + .headers = .{ .accept_encoding = .{ .override = "identity" } }, + .extra_headers = headers.items, + }); + defer req.deinit(); + + req.transfer_encoding = .{ .content_length = payload.len }; + var body = try req.sendBodyUnflushed(&.{}); + try body.writer.writeAll(payload); + try body.end(); + try req.connection.?.flush(); + + var response = try req.receiveHead(&.{}); + if (response.head.status.class() != .success) return error.ResponseError; + + var transfer_buffer: [8 * 1024]u8 = undefined; + const reader = response.reader(&transfer_buffer); + parseSseReader(allocator, reader, callback) catch |err| switch (err) { + error.ReadFailed => return response.bodyErr() orelse err, + else => return err, + }; +} + +fn appendClientHeaders(allocator: std.mem.Allocator, headers: *std.ArrayList(std.http.Header), client: *Client, include_content_type: bool, accept: []const u8) !?[]u8 { + if (include_content_type) { + try headers.append(allocator, .{ .name = "Content-Type", .value = "application/json" }); + } + try headers.append(allocator, .{ .name = "Accept", .value = accept }); + + var auth_header: ?[]u8 = null; + if (client.api_key.len > 0) { + auth_header = try std.fmt.allocPrint(allocator, "Bearer {s}", .{client.api_key}); + try headers.append(allocator, .{ .name = "Authorization", .value = auth_header.? }); + } + if (client.organization) |organization| { + try headers.append(allocator, .{ .name = "OpenAI-Organization", .value = organization }); + } + if (client.project) |project| { + try headers.append(allocator, .{ .name = "OpenAI-Project", .value = project }); + } + for (client.default_headers) |header| { + try headers.append(allocator, header); + } + return auth_header; +} + +pub fn list_modules_handler(client: *Client) !Owned([]const std.json.Value) { + var result = try list_modules_handlerResult(client); + switch (result) { + .ok => |ok| return ok, + .api_error => |*err| { + err.deinit(); + return error.ResponseError; + }, + .parse_error => |*err| { + err.raw.deinit(); + return error.ResponseParseError; + }, + } +} + +pub fn list_modules_handlerRaw(client: *Client) !RawResponse { + const allocator = client.allocator; + var uri_buf: std.Io.Writer.Allocating = .init(allocator); + defer uri_buf.deinit(); + try uri_buf.writer.print("{s}/modules/", .{client.base_url}); + const payload: ?[]const u8 = null; + + return requestRaw(client, std.http.Method.GET, uri_buf.written(), payload); +} + +pub fn list_modules_handlerResult(client: *Client) !ApiResult([]const std.json.Value) { + return parseRawResponse([]const std.json.Value, try list_modules_handlerRaw(client)); +} + +pub fn health(client: *Client) !Owned(HealthResponse) { + var result = try healthResult(client); + switch (result) { + .ok => |ok| return ok, + .api_error => |*err| { + err.deinit(); + return error.ResponseError; + }, + .parse_error => |*err| { + err.raw.deinit(); + return error.ResponseParseError; + }, + } +} + +pub fn healthRaw(client: *Client) !RawResponse { + const allocator = client.allocator; + var uri_buf: std.Io.Writer.Allocating = .init(allocator); + defer uri_buf.deinit(); + try uri_buf.writer.print("{s}/health", .{client.base_url}); + const payload: ?[]const u8 = null; + + return requestRaw(client, std.http.Method.GET, uri_buf.written(), payload); +} + +pub fn healthResult(client: *Client) !ApiResult(HealthResponse) { + return parseRawResponse(HealthResponse, try healthRaw(client)); +} + +///////////////// +// Summary: +// OpenAPI placeholder for `GET /storage/{agent_id}/{filename}`. `Files::new(...)` +// in `configure()` actually serves these requests; this fn exists only so +// utoipa has somewhere to attach `#[utoipa::path]`. The fn name flows through +// the OpenAPI `operationId` into the generated client's method name. +// +pub fn get_file(client: *Client, agent_id: []const u8, filename: []const u8) !void { + var raw = try get_fileRaw(client, agent_id, filename); + defer raw.deinit(); + if (raw.status.class() != .success) return error.ResponseError; +} + +pub fn get_fileRaw(client: *Client, agent_id: []const u8, filename: []const u8) !RawResponse { + const allocator = client.allocator; + var uri_buf: std.Io.Writer.Allocating = .init(allocator); + defer uri_buf.deinit(); + try uri_buf.writer.print("{s}/storage/{s}/{s}", .{ client.base_url, agent_id, filename }); + const payload: ?[]const u8 = null; + + return requestRaw(client, std.http.Method.GET, uri_buf.written(), payload); +} + +pub fn put_file(client: *Client, agent_id: []const u8, filename: []const u8, requestBody: []const u8) !void { + var raw = try put_fileRaw(client, agent_id, filename, requestBody); + defer raw.deinit(); + if (raw.status.class() != .success) return error.ResponseError; +} + +pub fn put_fileRaw(client: *Client, agent_id: []const u8, filename: []const u8, requestBody: []const u8) !RawResponse { + const allocator = client.allocator; + var uri_buf: std.Io.Writer.Allocating = .init(allocator); + defer uri_buf.deinit(); + try uri_buf.writer.print("{s}/storage/{s}/{s}", .{ client.base_url, agent_id, filename }); + + const payload: ?[]const u8 = requestBody; + + return requestRaw(client, std.http.Method.PUT, uri_buf.written(), payload); +} + +///////////////// +// Summary: +// OpenAPI placeholder for `GET /modules/{name}/{path}`. `Files::new(...)` in +// `configure()` actually serves these requests, but utoipa needs a function +// to attach `#[utoipa::path]` to. The fn name shapes the generated client's +// method name (via the OpenAPI `operationId`), so call it what callers want. +// +pub fn get_module_file(client: *Client, name: []const u8, path: []const u8) !void { + var raw = try get_module_fileRaw(client, name, path); + defer raw.deinit(); + if (raw.status.class() != .success) return error.ResponseError; +} + +pub fn get_module_fileRaw(client: *Client, name: []const u8, path: []const u8) !RawResponse { + const allocator = client.allocator; + var uri_buf: std.Io.Writer.Allocating = .init(allocator); + defer uri_buf.deinit(); + try uri_buf.writer.print("{s}/modules/{s}/{s}", .{ client.base_url, name, path }); + const payload: ?[]const u8 = null; + + return requestRaw(client, std.http.Method.GET, uri_buf.written(), payload); +} + +/// Host-provided HTTP transport. The JS shim implements this against +/// browser `fetch()` (via SharedArrayBuffer + Atomics so this looks +/// synchronous to Zig). Returns the number of bytes written to +/// `response_buf`, or a negative value on transport failure / +/// non-2xx status. +extern fn js_rest_request( + method_ptr: [*]const u8, + method_len: usize, + url_ptr: [*]const u8, + url_len: usize, + body_ptr: [*]const u8, + body_len: usize, + response_buf: [*]u8, + response_max: usize, +) i32; diff --git a/libs/edge-toolkit/Cargo.toml b/libs/edge-toolkit/Cargo.toml index d849967..e670156 100644 --- a/libs/edge-toolkit/Cargo.toml +++ b/libs/edge-toolkit/Cargo.toml @@ -7,9 +7,11 @@ license.workspace = true repository.workspace = true [dependencies] +asyncapi-rust = { workspace = true, optional = true } base64.workspace = true lets_find_up.workspace = true log.workspace = true +schemars = { workspace = true, optional = true } secrecy.workspace = true serde.workspace = true serde-env.workspace = true @@ -18,5 +20,10 @@ serde_default.workspace = true serde_json.workspace = true serde_yaml.workspace = true +[features] +# Opt-in derives for emitting JSON Schema / AsyncAPI from the WS protocol types. +# Off by default so wasm and runtime crates don't pull schemars + asyncapi-rust. +schema-export = ["dep:asyncapi-rust", "dep:schemars"] + [dev-dependencies] rstest.workspace = true diff --git a/libs/edge-toolkit/src/config.rs b/libs/edge-toolkit/src/config.rs index 4149b42..8fb93dd 100644 --- a/libs/edge-toolkit/src/config.rs +++ b/libs/edge-toolkit/src/config.rs @@ -43,7 +43,15 @@ pub fn default_modules_folders() -> Vec { if let Some(p) = mise_npm_modules_path("onnxruntime-web") { paths.push(p); } - if let Some(p) = mise_npm_modules_path("pyodide") { + // Pyodide is installed from its GitHub release tarball (see `.mise.toml`), + // not via `npm:pyodide`. mise's http backend extracts the archive flat, + // so the install dir itself holds `package.json` + every wheel — the + // modules service treats the dir as a single module named "pyodide". + // Fall back to the much smaller `npm:pyodide` install if the full + // distribution isn't available: browser modules that only need pyodide's + // runtime (no `micropip.install` of non-stdlib wheels) still work, and + // contributors who don't need the full set can skip the 200 MB download. + if let Some(p) = mise_where("http:pyodide").or_else(|| mise_npm_modules_path("pyodide")) { paths.push(p); } paths diff --git a/libs/edge-toolkit/src/ws.rs b/libs/edge-toolkit/src/ws.rs index bb26973..416c414 100644 --- a/libs/edge-toolkit/src/ws.rs +++ b/libs/edge-toolkit/src/ws.rs @@ -1,6 +1,20 @@ use serde::{Deserialize, Serialize}; +/// Schema for `serde_json::Value`-typed fields. schemars 1.x renders bare +/// `Value` as the boolean schema `true`, which the AsyncAPI Schema model in +/// `asyncapi-rust` 0.2 doesn't accept. Emit an explicit object schema so the +/// payload is described as "arbitrary JSON" without tripping the parser. +#[cfg(feature = "schema-export")] +fn any_json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema { + serde_json::json!({ + "description": "Arbitrary JSON value (opaque to the protocol)", + }) + .try_into() + .expect("any_json_schema is a valid object schema") +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))] #[serde(rename_all = "snake_case")] pub enum ConnectStatus { Assigned, @@ -8,6 +22,7 @@ pub enum ConnectStatus { } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))] #[serde(rename_all = "snake_case")] pub enum MessageDeliveryStatus { Delivered, @@ -17,6 +32,7 @@ pub enum MessageDeliveryStatus { } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))] #[serde(rename_all = "snake_case")] pub enum MessageScope { Direct, @@ -24,6 +40,7 @@ pub enum MessageScope { } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))] #[serde(rename_all = "snake_case")] pub enum AgentConnectionState { Connected, @@ -31,6 +48,7 @@ pub enum AgentConnectionState { } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))] pub struct AgentSummary { pub agent_id: String, pub state: AgentConnectionState, @@ -38,61 +56,72 @@ pub struct AgentSummary { } #[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(tag = "type", rename_all = "snake_case")] +#[cfg_attr( + feature = "schema-export", + derive(schemars::JsonSchema, asyncapi_rust::ToAsyncApiMessage) +)] +#[serde(tag = "type")] pub enum WsMessage { - Connect { - agent_id: Option, - }, - ConnectAck { - agent_id: String, - status: ConnectStatus, - }, - Alive { - timestamp: String, - }, + #[serde(rename = "et-connect")] + #[cfg_attr(feature = "schema-export", schemars(title = "WsConnect"))] + Connect { agent_id: Option }, + #[serde(rename = "et-connect-ack")] + #[cfg_attr(feature = "schema-export", schemars(title = "WsConnectAck"))] + ConnectAck { agent_id: String, status: ConnectStatus }, + #[serde(rename = "et-alive")] + #[cfg_attr(feature = "schema-export", schemars(title = "WsAlive"))] + Alive { timestamp: String }, + #[serde(rename = "et-list-agents")] + #[cfg_attr(feature = "schema-export", schemars(title = "WsListAgents"))] ListAgents, - ListAgentsResponse { - agents: Vec, - }, + #[serde(rename = "et-list-agents-response")] + #[cfg_attr(feature = "schema-export", schemars(title = "WsListAgentsResponse"))] + ListAgentsResponse { agents: Vec }, + #[serde(rename = "et-send-agent-message")] + #[cfg_attr(feature = "schema-export", schemars(title = "WsSendAgentMessage"))] SendAgentMessage { to_agent_id: String, + #[cfg_attr(feature = "schema-export", schemars(schema_with = "any_json_schema"))] message: serde_json::Value, }, + #[serde(rename = "et-broadcast-message")] + #[cfg_attr(feature = "schema-export", schemars(title = "WsBroadcastMessage"))] BroadcastMessage { + #[cfg_attr(feature = "schema-export", schemars(schema_with = "any_json_schema"))] message: serde_json::Value, }, + #[serde(rename = "et-agent-message")] + #[cfg_attr(feature = "schema-export", schemars(title = "WsAgentMessage"))] AgentMessage { message_id: String, from_agent_id: String, scope: MessageScope, server_received_at: String, + #[cfg_attr(feature = "schema-export", schemars(schema_with = "any_json_schema"))] message: serde_json::Value, }, - MessageAck { - message_id: String, - }, + #[serde(rename = "et-message-ack")] + #[cfg_attr(feature = "schema-export", schemars(title = "WsMessageAck"))] + MessageAck { message_id: String }, + #[serde(rename = "et-message-status")] + #[cfg_attr(feature = "schema-export", schemars(title = "WsMessageStatus"))] MessageStatus { message_id: Option, status: MessageDeliveryStatus, detail: String, }, - Invalid { - message_id: Option, - detail: String, - }, + #[serde(rename = "et-invalid")] + #[cfg_attr(feature = "schema-export", schemars(title = "WsInvalid"))] + Invalid { message_id: Option, detail: String }, + #[serde(rename = "et-client-event")] + #[cfg_attr(feature = "schema-export", schemars(title = "WsClientEvent"))] ClientEvent { capability: String, action: String, + #[cfg_attr(feature = "schema-export", schemars(schema_with = "any_json_schema"))] details: serde_json::Value, }, - StoreFile { - filename: String, - }, - FetchFile { - agent_id: String, - filename: String, - }, - Response { - message: String, - }, + #[serde(rename = "et-response")] + #[cfg_attr(feature = "schema-export", schemars(title = "WsResponse"))] + Response { message: String }, } diff --git a/ruff.toml b/ruff.toml new file mode 100644 index 0000000..5f65f9c --- /dev/null +++ b/ruff.toml @@ -0,0 +1,4 @@ +# Match the repo-wide 120 line-length set in .editorconfig. Without this, ruff +# would use its default of 88, so files that ruff considered "already +# formatted" could still trip the editorconfig-check. +line-length = 120 diff --git a/services/modules/Cargo.toml b/services/modules/Cargo.toml index 04d52ee..500eee6 100644 --- a/services/modules/Cargo.toml +++ b/services/modules/Cargo.toml @@ -14,6 +14,7 @@ serde-inline-default.workspace = true serde_default.workspace = true serde_json.workspace = true tracing.workspace = true +utoipa.workspace = true [dev-dependencies] actix-rt = "2" diff --git a/services/modules/src/lib.rs b/services/modules/src/lib.rs index 42dec4c..f98b5e8 100644 --- a/services/modules/src/lib.rs +++ b/services/modules/src/lib.rs @@ -69,11 +69,36 @@ pub fn list_modules(config: &ModulesConfig) -> Vec<(String, PathBuf)> { modules } -async fn list_modules_handler(config: web::Data) -> HttpResponse { +#[utoipa::path( + get, + path = "/modules/", + responses((status = 200, description = "Names of available modules", body = Vec)) +)] +pub async fn list_modules_handler(config: web::Data) -> HttpResponse { let names: Vec = list_modules(&config).into_iter().map(|(name, _)| name).collect(); HttpResponse::Ok().json(names) } +/// OpenAPI placeholder for `GET /modules/{name}/{path}`. `Files::new(...)` in +/// `configure()` actually serves these requests, but utoipa needs a function +/// to attach `#[utoipa::path]` to. The fn name shapes the generated client's +/// method name (via the OpenAPI `operationId`), so call it what callers want. +#[utoipa::path( + get, + path = "/modules/{name}/{path}", + params( + ("name" = String, Path, description = "Module name"), + ("path" = String, Path, description = "Relative path of the file under the module's pkg/ dir") + ), + responses( + (status = 200, description = "Static module asset", content_type = "application/octet-stream"), + (status = 404, description = "No such module or file") + ) +)] +pub async fn get_module_file() -> HttpResponse { + HttpResponse::NotImplemented().finish() +} + /// Register `GET /modules/` (JSON list), `GET /modules/{name}/...` (static files), /// and `GET /` (root module). pub fn configure(cfg: &mut web::ServiceConfig, config: &ModulesConfig) { diff --git a/services/storage/Cargo.toml b/services/storage/Cargo.toml index 22b8d14..dc5a44f 100644 --- a/services/storage/Cargo.toml +++ b/services/storage/Cargo.toml @@ -15,3 +15,4 @@ serde-inline-default.workspace = true serde_default.workspace = true tokio = { version = "1", features = ["full"] } tracing.workspace = true +utoipa.workspace = true diff --git a/services/storage/src/lib.rs b/services/storage/src/lib.rs index 02babff..5c0e7e3 100644 --- a/services/storage/src/lib.rs +++ b/services/storage/src/lib.rs @@ -7,6 +7,13 @@ use futures_util::StreamExt; use serde::Deserialize; use serde_default::DefaultFromSerde; use tracing::info; +use utoipa::ToSchema; + +/// Phantom type used to label binary request/response bodies as +/// `string`/`binary` in the OpenAPI document. Never constructed at runtime. +#[derive(ToSchema)] +#[schema(value_type = String, format = Binary)] +pub struct BinaryBlob(#[allow(dead_code)] Vec); /// Default storage directory. #[must_use] @@ -22,7 +29,25 @@ pub struct StorageConfig { pub path: PathBuf, } -pub async fn agent_put_file( +#[utoipa::path( + put, + path = "/storage/{agent_id}/{filename}", + params( + ("agent_id" = String, Path, description = "Agent identifier (must be a connected agent)"), + ("filename" = String, Path, description = "Single-segment filename to write") + ), + request_body( + content = inline(BinaryBlob), + content_type = "application/octet-stream", + description = "Raw file bytes" + ), + responses( + (status = 200, description = "File stored"), + (status = 400, description = "Invalid filename"), + (status = 404, description = "Agent not found") + ) +)] +pub async fn put_file( req: HttpRequest, mut payload: web::Payload, registry: web::Data>, @@ -62,10 +87,30 @@ pub async fn agent_put_file( Ok(HttpResponse::Ok().finish()) } +/// OpenAPI placeholder for `GET /storage/{agent_id}/{filename}`. `Files::new(...)` +/// in `configure()` actually serves these requests; this fn exists only so +/// utoipa has somewhere to attach `#[utoipa::path]`. The fn name flows through +/// the OpenAPI `operationId` into the generated client's method name. +#[utoipa::path( + get, + path = "/storage/{agent_id}/{filename}", + params( + ("agent_id" = String, Path, description = "Agent identifier"), + ("filename" = String, Path, description = "Stored filename") + ), + responses( + (status = 200, description = "Stored file contents", content_type = "application/octet-stream"), + (status = 404, description = "No such file") + ) +)] +pub async fn get_file() -> HttpResponse { + HttpResponse::NotImplemented().finish() +} + /// Register `PUT /storage/{agent_id}/{filename}` and `GET /storage/...` (static file serving). pub fn configure(cfg: &mut web::ServiceConfig, config: &StorageConfig) { let storage_dir = config.path.clone(); - cfg.route("/storage/{agent_id}/{filename}", web::put().to(agent_put_file::)) + cfg.route("/storage/{agent_id}/{filename}", web::put().to(put_file::)) .service( Files::new("/storage", storage_dir) .show_files_listing() diff --git a/services/ws-modules/dart-comm1/lib/dart_comm1.dart b/services/ws-modules/dart-comm1/lib/dart_comm1.dart index a773eaf..b8088cd 100644 --- a/services/ws-modules/dart-comm1/lib/dart_comm1.dart +++ b/services/ws-modules/dart-comm1/lib/dart_comm1.dart @@ -1,6 +1,9 @@ import 'dart:async'; +import 'dart:convert'; import 'dart:js_interop'; +import 'package:et_ws/ws_messages.dart'; + // JS interop declarations for et_ws_wasm_agent @JS() extension type WsClientConfig._(JSObject _) implements JSObject { @@ -77,6 +80,10 @@ Future waitForAgentId(WsClient client) async { throw Exception('Timeout waiting for agent_id'); } +void send(WsClient client, WsMessage message) { + client.send(jsonEncode(message.toJson())); +} + Future run() async { log('entered run()'); @@ -89,25 +96,28 @@ Future run() async { ((JSString raw) { final data = raw.toDart; try { - // Parse type field manually to avoid a JSON dep - if (data.contains('"list_agents_response"')) { - // Extract first other connected agent id - final idMatches = RegExp( - r'"agent_id"\s*:\s*"([^"]+)"', - ).allMatches(data); - for (final m in idMatches) { - final id = m.group(1)!; - if (id != selfAgentId) { - targetAgentId = id; - break; + final message = WsMessage.fromJson( + jsonDecode(data) as Map, + ); + switch (message) { + case WsListAgentsResponse(:final agents): + for (final summary in agents) { + if (summary.agentId != selfAgentId) { + targetAgentId = summary.agentId; + break; + } } - } - } else if (data.contains('"agent_message"') || - data.contains('"message_status"')) { - log('received: $data'); - appendOutput(data); + case WsAgentMessage() || WsMessageStatus(): + log('received: $data'); + appendOutput(data); + default: + // ignore other protocol messages (connect_ack etc.) } - } catch (_) {} + } on FormatException { + // Unknown message type — server may have broadcast an opaque frame. + } catch (_) { + // Malformed JSON or schema mismatch — ignore. + } }).toJS, ); @@ -118,20 +128,37 @@ Future run() async { // Poll for a peer agent while (targetAgentId == null) { - client.send('{"type":"list_agents"}'); + send(client, const WsListAgents()); await sleep(1000); } log('found peer $targetAgentId, sending broadcast'); - client.send( - '{"type":"broadcast_message","message":{"module":"dart-comm1","step":"broadcast","from_agent_id":"$selfAgentId","message":"dart-comm1 broadcast to all other connected agents"}}', + send( + client, + WsBroadcastMessage( + message: { + 'module': 'dart-comm1', + 'step': 'broadcast', + 'from_agent_id': selfAgentId, + 'message': 'dart-comm1 broadcast to all other connected agents', + }, + ), ); await sleep(3000); log('sending direct message to $targetAgentId'); - client.send( - '{"type":"send_agent_message","to_agent_id":"$targetAgentId","message":{"module":"dart-comm1","step":"direct","from_agent_id":"$selfAgentId","message":"dart-comm1 direct message"}}', + send( + client, + WsSendAgentMessage( + toAgentId: targetAgentId!, + message: { + 'module': 'dart-comm1', + 'step': 'direct', + 'from_agent_id': selfAgentId, + 'message': 'dart-comm1 direct message', + }, + ), ); await sleep(3000); diff --git a/services/ws-modules/dart-comm1/pubspec.yaml b/services/ws-modules/dart-comm1/pubspec.yaml index c7d3aef..d17357a 100644 --- a/services/ws-modules/dart-comm1/pubspec.yaml +++ b/services/ws-modules/dart-comm1/pubspec.yaml @@ -2,11 +2,14 @@ name: et_dart_comm1 description: dart-comm1 workflow module version: 0.1.0 repository: https://github.com/edge-toolkit/core +publish_to: none environment: sdk: ^3.11.5 dependencies: + et_ws: + path: ../../../generated/dart-ws web: ^1.1.0 dev_dependencies: diff --git a/services/ws-modules/data1/Cargo.toml b/services/ws-modules/data1/Cargo.toml index a573d2c..b9613bf 100644 --- a/services/ws-modules/data1/Cargo.toml +++ b/services/ws-modules/data1/Cargo.toml @@ -11,7 +11,11 @@ crate-type = ["cdylib", "rlib"] [dependencies] edge-toolkit = { path = "../../../libs/edge-toolkit" } +# Browser WASM target — disable the `tracing` default feature so the +# opentelemetry deps the native pre-hook needs don't get pulled in. +et-rest-client = { path = "../../../generated/rust-rest", default-features = false } et-ws-wasm-agent = { path = "../../ws-wasm-agent" } +futures-util = "0.3" js-sys = "0.3" serde.workspace = true serde-wasm-bindgen = "0.6" @@ -20,17 +24,7 @@ tracing.workspace = true tracing-wasm = "0.2" wasm-bindgen = "0.2" wasm-bindgen-futures = "0.4" -web-sys = { version = "0.3", features = [ - "Document", - "Element", - "HtmlTextAreaElement", - "Request", - "RequestInit", - "RequestMode", - "Response", - "Window", - "console", -] } +web-sys = { version = "0.3", features = ["Document", "Element", "HtmlTextAreaElement", "Window", "console"] } [dev-dependencies] wasm-bindgen-test = "0.3" diff --git a/services/ws-modules/data1/src/lib.rs b/services/ws-modules/data1/src/lib.rs index cdcf6c3..10b2e8f 100644 --- a/services/ws-modules/data1/src/lib.rs +++ b/services/ws-modules/data1/src/lib.rs @@ -1,13 +1,10 @@ -use std::cell::RefCell; -use std::rc::Rc; - use edge_toolkit::ws::WsMessage; use et_ws_wasm_agent::{WsClient, WsClientConfig, append_to_textarea}; +use futures_util::StreamExt; use js_sys::{Promise, Reflect}; use tracing::info; use wasm_bindgen::prelude::*; use wasm_bindgen_futures::JsFuture; -use web_sys::{Request, RequestInit, RequestMode, Response}; #[wasm_bindgen(start)] pub fn init() { @@ -24,20 +21,11 @@ pub async fn run() -> Result<(), JsValue> { let ws_url = websocket_url()?; let mut client = WsClient::new(WsClientConfig::new(ws_url)); - let last_response = Rc::new(RefCell::new(None)); - let on_message = Closure::wrap(Box::new({ - let last_response = last_response.clone(); - move |value: JsValue| { - let Some(data) = value.as_string() else { - return; - }; - let Ok(message) = serde_json::from_str::(&data) else { - return; - }; - if let WsMessage::Response { message } = message { - *last_response.borrow_mut() = Some(message); - } - } + let on_message = Closure::wrap(Box::new(move |value: JsValue| { + let Some(data) = value.as_string() else { + return; + }; + let _ = serde_json::from_str::(&data); }) as Box); client.set_on_message(on_message.as_ref().clone()); @@ -51,42 +39,30 @@ pub async fn run() -> Result<(), JsValue> { let filename = "test_data.txt"; let test_content = format!("Hello from data1 at {}!", js_sys::Date::new_0().to_iso_string()); - // 1. Request Store URL - log("data1: requesting store URL")?; - client.send( - &serde_json::to_string(&WsMessage::StoreFile { - filename: filename.to_string(), - }) - .unwrap(), - )?; - let store_url = wait_for_response(&last_response, "PUT to ") - .await? - .replace("PUT to ", ""); - - // 2. Perform PUT - let msg = format!("data1: storing data to {store_url}"); + // The typed REST client runs against the page origin — every browser + // module is served from the same ws-server that owns its storage, so + // an empty base URL (relative paths) is what we want. + let rest = et_rest_client::Client::new(""); + + let msg = format!("data1: storing data to /storage/{agent_id}/{filename}"); log(&msg)?; set_module_status(&msg)?; - put_file(&store_url, &test_content).await?; - - // 3. Request Fetch URL - log("data1: requesting fetch URL")?; - client.send( - &serde_json::to_string(&WsMessage::FetchFile { - agent_id: agent_id.clone(), - filename: filename.to_string(), - }) - .unwrap(), - )?; - let fetch_url = wait_for_response(&last_response, "GET from ") - .await? - .replace("GET from ", ""); - - // 4. Perform GET and Verify - let msg = format!("data1: fetching data from {fetch_url}"); + rest.put_file(&agent_id, filename, test_content.clone()) + .await + .map_err(|e| JsValue::from_str(&format!("PUT failed: {e}")))?; + + let msg = format!("data1: fetching data from /storage/{agent_id}/{filename}"); log(&msg)?; set_module_status(&msg)?; - let retrieved_content = get_file(&fetch_url).await?; + let response = rest + .get_file(&agent_id, filename) + .await + .map_err(|e| JsValue::from_str(&format!("GET failed: {e}")))?; + let retrieved_bytes = collect_stream(response.into_inner()) + .await + .map_err(|e| JsValue::from_str(&e))?; + let retrieved_content = + String::from_utf8(retrieved_bytes).map_err(|e| JsValue::from_str(&format!("non-UTF-8 body: {e}")))?; if retrieved_content == test_content { let msg = "data1: VERIFICATION SUCCESS - data matches!"; @@ -110,50 +86,13 @@ pub async fn run() -> Result<(), JsValue> { Ok(()) } -async fn put_file(url: &str, content: &str) -> Result<(), JsValue> { - let opts = RequestInit::new(); - opts.set_method("PUT"); - opts.set_mode(RequestMode::Cors); - opts.set_body(&JsValue::from_str(content)); - - let request = Request::new_with_str_and_init(url, &opts)?; - let window = web_sys::window().unwrap(); - let resp_value = JsFuture::from(window.fetch_with_request(&request)).await?; - let resp: Response = resp_value.dyn_into().unwrap(); - - if resp.status() == 200 { - Ok(()) - } else { - Err(JsValue::from_str(&format!("PUT failed with status {}", resp.status()))) - } -} - -async fn get_file(url: &str) -> Result { - let window = web_sys::window().unwrap(); - let resp_value = JsFuture::from(window.fetch_with_str(url)).await?; - let resp: Response = resp_value.dyn_into().unwrap(); - - if resp.status() != 200 { - return Err(JsValue::from_str(&format!("GET failed with status {}", resp.status()))); - } - - let text_promise = resp.text()?; - let text = JsFuture::from(text_promise).await?; - Ok(text.as_string().unwrap_or_default()) -} - -async fn wait_for_response(cell: &Rc>>, prefix: &str) -> Result { - for _ in 0..50 { - let val = cell.borrow().clone(); - if let Some(s) = val - && s.starts_with(prefix) - { - *cell.borrow_mut() = None; - return Ok(s); - } - sleep_ms(100).await?; +async fn collect_stream(mut stream: et_rest_client::ByteStream) -> Result, String> { + let mut buf = Vec::new(); + while let Some(chunk) = stream.next().await { + let chunk = chunk.map_err(|e| format!("stream chunk: {e}"))?; + buf.extend_from_slice(&chunk); } - Err(JsValue::from_str("Timeout waiting for server response")) + Ok(buf) } fn log(message: &str) -> Result<(), JsValue> { diff --git a/services/ws-modules/dotnet-data1/Program.cs b/services/ws-modules/dotnet-data1/Program.cs index 4f8dc97..9e28afc 100644 --- a/services/ws-modules/dotnet-data1/Program.cs +++ b/services/ws-modules/dotnet-data1/Program.cs @@ -7,10 +7,8 @@ partial class Host { [JSImport("wsConnect", "dotnet-data1")] internal static partial void WsConnect(string url); [JSImport("wsDisconnect", "dotnet-data1")] internal static partial void WsDisconnect(); - [JSImport("wsSend", "dotnet-data1")] internal static partial void WsSend(string msg); [JSImport("wsGetState", "dotnet-data1")] internal static partial string WsGetState(); [JSImport("wsGetAgentId", "dotnet-data1")] internal static partial string WsGetAgentId(); - [JSImport("wsPopResponse", "dotnet-data1")] internal static partial string WsPopResponse(); [JSImport("putFile", "dotnet-data1")] internal static partial Task PutFile(string url, string body); [JSImport("getFile", "dotnet-data1")] internal static partial Task GetFile(string url); [JSImport("log", "dotnet-data1")] internal static partial void Log(string msg); @@ -55,30 +53,17 @@ public static async Task Run() const string filename = "test_data.txt"; var testContent = $"Hello from dotnet-data1 at {Host.GetIsoTimestamp()}!"; + var storageUrl = $"/storage/{agentId}/{filename}"; - // 1. Request store URL - Host.Log("[dotnet-data1] requesting store URL"); - Host.WsSend($$"""{"type":"store_file","filename":"{{filename}}"}"""); - var storeUrl = await WaitForResponse("PUT to "); - storeUrl = storeUrl.Replace("PUT to ", ""); - - // 2. PUT - msg = $"[dotnet-data1] storing data to {storeUrl}"; + msg = $"[dotnet-data1] storing data to {storageUrl}"; Host.Log(msg); Host.SetStatus(msg); - await Host.PutFile(storeUrl, testContent); - - // 3. Request fetch URL - Host.Log("[dotnet-data1] requesting fetch URL"); - Host.WsSend($$"""{"type":"fetch_file","agent_id":"{{agentId}}","filename":"{{filename}}"}"""); - var fetchUrl = await WaitForResponse("GET from "); - fetchUrl = fetchUrl.Replace("GET from ", ""); + await Host.PutFile(storageUrl, testContent); - // 4. GET and verify - msg = $"[dotnet-data1] fetching data from {fetchUrl}"; + msg = $"[dotnet-data1] fetching data from {storageUrl}"; Host.Log(msg); Host.SetStatus(msg); - var retrieved = await Host.GetFile(fetchUrl); + var retrieved = await Host.GetFile(storageUrl); if (retrieved == testContent) { @@ -100,15 +85,4 @@ public static async Task Run() Host.Log(done); Host.SetStatus(done); } - - static async Task WaitForResponse(string prefix) - { - for (int i = 0; i < 50; i++) - { - var r = Host.WsPopResponse(); - if (!string.IsNullOrEmpty(r) && r.StartsWith(prefix)) return r; - await Host.Sleep(100); - } - throw new Exception($"Timeout waiting for response with prefix: {prefix}"); - } } diff --git a/services/ws-modules/dotnet-data1/pkg/et_ws_dotnet_data1.js b/services/ws-modules/dotnet-data1/pkg/et_ws_dotnet_data1.js index 08abae4..4ace1f7 100644 --- a/services/ws-modules/dotnet-data1/pkg/et_ws_dotnet_data1.js +++ b/services/ws-modules/dotnet-data1/pkg/et_ws_dotnet_data1.js @@ -7,7 +7,7 @@ export default async function init() { const { dotnet } = await import(new URL("dotnet.js", import.meta.url).href); const { getAssemblyExports, setModuleImports } = await dotnet.create(); - let ws = null, wsState = "disconnected", agentId = "", lastResponse = null; + let ws = null, wsState = "disconnected", agentId = ""; setModuleImports("dotnet-data1", { wsConnect: (url) => { @@ -15,13 +15,12 @@ export default async function init() { wsState = "connecting"; ws.onopen = () => { wsState = "connected"; - ws.send(JSON.stringify({ type: "connect" })); + ws.send(JSON.stringify({ type: "et-connect" })); }; ws.onmessage = (e) => { try { const msg = JSON.parse(e.data); - if (msg.type === "connect_ack" && msg.agent_id) agentId = msg.agent_id; - else if (msg.type === "response" && msg.message) lastResponse = msg.message; + if (msg.type === "et-connect-ack" && msg.agent_id) agentId = msg.agent_id; } catch {} }; ws.onclose = ws.onerror = () => { @@ -32,14 +31,8 @@ export default async function init() { ws?.close(); wsState = "disconnected"; }, - wsSend: (msg) => ws?.send(msg), wsGetState: () => wsState, wsGetAgentId: () => agentId ?? "", - wsPopResponse: () => { - const r = lastResponse ?? ""; - lastResponse = null; - return r; - }, putFile: (url, body) => fetch(url, { method: "PUT", body }).then(r => { if (!r.ok) throw new Error(`PUT failed: ${r.status}`); diff --git a/services/ws-modules/java-data1/pkg/et_ws_java_data1.js b/services/ws-modules/java-data1/pkg/et_ws_java_data1.js index 9b12e6f..40828f1 100644 --- a/services/ws-modules/java-data1/pkg/et_ws_java_data1.js +++ b/services/ws-modules/java-data1/pkg/et_ws_java_data1.js @@ -4,7 +4,7 @@ let javaRun = null; export default async function init() { - let ws = null, wsState = "disconnected", agentId = "", lastResponse = null; + let ws = null, wsState = "disconnected", agentId = ""; // TeaVM @JSBody calls reference `host` as a global globalThis.host = { @@ -13,13 +13,12 @@ export default async function init() { wsState = "connecting"; ws.onopen = () => { wsState = "connected"; - ws.send(JSON.stringify({ type: "connect" })); + ws.send(JSON.stringify({ type: "et-connect" })); }; ws.onmessage = (e) => { try { const msg = JSON.parse(e.data); - if (msg.type === "connect_ack" && msg.agent_id) agentId = msg.agent_id; - else if (msg.type === "response" && msg.message) lastResponse = msg.message; + if (msg.type === "et-connect-ack" && msg.agent_id) agentId = msg.agent_id; } catch {} }; ws.onclose = ws.onerror = () => { @@ -30,14 +29,8 @@ export default async function init() { ws?.close(); wsState = "disconnected"; }, - wsSend: (msg) => ws?.send(msg), wsGetState: () => wsState, wsGetAgentId: () => agentId ?? "", - wsPopResponse: () => { - const r = lastResponse ?? ""; - lastResponse = null; - return r; - }, putFile: (url, body) => fetch(url, { method: "PUT", body }).then(r => { if (!r.ok) throw new Error(`PUT failed: ${r.status}`); diff --git a/services/ws-modules/java-data1/src/main/java/au/edu/curtin/et/Data1.java b/services/ws-modules/java-data1/src/main/java/au/edu/curtin/et/Data1.java index 977bf06..2abce69 100644 --- a/services/ws-modules/java-data1/src/main/java/au/edu/curtin/et/Data1.java +++ b/services/ws-modules/java-data1/src/main/java/au/edu/curtin/et/Data1.java @@ -2,18 +2,12 @@ import org.teavm.jso.JSBody; import org.teavm.jso.JSExport; -import org.teavm.jso.JSFunctor; import org.teavm.jso.JSObject; import org.teavm.jso.core.JSPromise; import org.teavm.jso.function.JSConsumer; public final class Data1 { - @JSFunctor - interface StringCallback extends JSObject { - void call(String value); - } - @JSBody(params = {"msg"}, script = "host.log(msg);") static native void log(String msg); @@ -29,18 +23,12 @@ interface StringCallback extends JSObject { @JSBody(script = "host.wsDisconnect();") static native void wsDisconnect(); - @JSBody(params = {"msg"}, script = "host.wsSend(msg);") - static native void wsSend(String msg); - @JSBody(script = "return host.wsGetState();") static native String wsGetState(); @JSBody(script = "return host.wsGetAgentId();") static native String wsGetAgentId(); - @JSBody(script = "return host.wsPopResponse();") - static native String wsPopResponse(); - @JSBody(params = {"ms"}, script = "return host.sleep(ms);") static native JSPromise sleep(int ms); @@ -96,7 +84,7 @@ private static void waitForAgentId(int attempt, JSConsumer resolve, JS String msg = "[java-data1] connected as " + agentId; log(msg); setStatus(msg); - doStoreRequest(agentId, resolve, reject); + doStore(agentId, resolve, reject); return; } sleep(100).then(v -> { @@ -105,41 +93,28 @@ private static void waitForAgentId(int attempt, JSConsumer resolve, JS }); } - private static void doStoreRequest(String agentId, JSConsumer resolve, JSConsumer reject) { + private static void doStore(String agentId, JSConsumer resolve, JSConsumer reject) { String filename = "test_data.txt"; String testContent = "Hello from java-data1 at " + getIsoTimestamp() + "!"; - log("[java-data1] requesting store URL"); - wsSend("{\"type\":\"store_file\",\"filename\":\"" + filename + "\"}"); - waitForResponse(0, "PUT to ", storeResponse -> { - String storeUrl = storeResponse.replace("PUT to ", ""); - String msg = "[java-data1] storing data to " + storeUrl; - log(msg); - setStatus(msg); - putFile(storeUrl, testContent).then(v -> { - doFetchRequest(agentId, filename, testContent, resolve, reject); - return null; - }); - }, reject); + String storageUrl = "/storage/" + agentId + "/" + filename; + String msg = "[java-data1] storing data to " + storageUrl; + log(msg); + setStatus(msg); + putFile(storageUrl, testContent).then(v -> { + doFetch(storageUrl, testContent, resolve, reject); + return null; + }); } - private static void doFetchRequest( - String agentId, - String filename, - String testContent, - JSConsumer resolve, - JSConsumer reject) { - log("[java-data1] requesting fetch URL"); - wsSend("{\"type\":\"fetch_file\",\"agent_id\":\"" + agentId + "\",\"filename\":\"" + filename + "\"}"); - waitForResponse(0, "GET from ", fetchResponse -> { - String fetchUrl = fetchResponse.replace("GET from ", ""); - String msg = "[java-data1] fetching data from " + fetchUrl; - log(msg); - setStatus(msg); - getFile(fetchUrl).then(result -> { - verifyAndFinish(testContent, jsObjectToString(result), resolve, reject); - return null; - }); - }, reject); + private static void doFetch( + String storageUrl, String testContent, JSConsumer resolve, JSConsumer reject) { + String msg = "[java-data1] fetching data from " + storageUrl; + log(msg); + setStatus(msg); + getFile(storageUrl).then(result -> { + verifyAndFinish(testContent, jsObjectToString(result), resolve, reject); + return null; + }); } private static void verifyAndFinish( @@ -164,21 +139,4 @@ private static void verifyAndFinish( return null; }); } - - private static void waitForResponse( - int attempt, String prefix, StringCallback onResult, JSConsumer reject) { - if (attempt >= 50) { - reject.accept(jsError("Timeout waiting for response: " + prefix)); - return; - } - String r = wsPopResponse(); - if (r != null && r.startsWith(prefix)) { - onResult.call(r); - return; - } - sleep(100).then(v -> { - waitForResponse(attempt + 1, prefix, onResult, reject); - return null; - }); - } } diff --git a/services/ws-modules/pydata1/pkg/et_ws_pydata1.js b/services/ws-modules/pydata1/pkg/et_ws_pydata1.js index 7128dfe..8ef197f 100644 --- a/services/ws-modules/pydata1/pkg/et_ws_pydata1.js +++ b/services/ws-modules/pydata1/pkg/et_ws_pydata1.js @@ -1,7 +1,7 @@ // et_ws_pydata1.js — Pyodide-based Python module shim // Interface: default(wasmUrl), metadata(), run() -const PYODIDE_CDN = "/modules/pyodide/pyodide.js"; +const PYODIDE_BASE_PATH = "/modules/pyodide/"; let pyodide = null; let pyMod = null; @@ -10,7 +10,7 @@ function loadPyodideScript() { return new Promise((resolve, reject) => { if (globalThis.loadPyodide) return resolve(); const s = document.createElement("script"); - s.src = PYODIDE_CDN; + s.src = `${PYODIDE_BASE_PATH}pyodide.js`; s.onload = resolve; s.onerror = reject; document.head.appendChild(s); @@ -19,16 +19,37 @@ function loadPyodideScript() { export default async function init() { await loadPyodideScript(); - pyodide = await globalThis.loadPyodide(); - - const pkgUrl = new URL("package.json", import.meta.url); - const pkg = await fetch(pkgUrl).then(r => r.json()); - const wheelName = `${pkg.name.replace(/-/g, "_")}-${pkg.version}-py3-none-any.whl`; - const wheelUrl = new URL(`${wheelName}`, import.meta.url); - - await pyodide.loadPackage("micropip"); + // `mise install pyodide` extracts the full GitHub-release distribution + // (~200 MB of pinned wheels) at /modules/pyodide/, so both the runtime + // and `micropip.install("httpx")` resolve from this same origin — no CDN + // dependency at runtime. + pyodide = await globalThis.loadPyodide({ indexURL: PYODIDE_BASE_PATH }); + + // pydata1's runtime stack is split between PyPI deps (httpx + attrs power + // the generated client; pyodide-http rewires httpx to use the browser's + // fetch()) and two local wheels: the generated et-rest-client and pydata1 + // itself. We bring the PyPI deps in via micropip (which resolves + // transitively), then sys.path-inject the local wheels — same pattern as + // pyface1. Going through micropip for the local wheels would make it look + // up "et-rest-client" on PyPI, which we deliberately don't publish. + // Pyodide unvendors `ssl` from the stdlib (loaded on demand via loadPackage) + // and our generated httpx-based client imports it at module top-level. + await pyodide.loadPackage(["micropip", "ssl"]); const micropip = pyodide.pyimport("micropip"); - await micropip.install(wheelUrl.href); + await micropip.install("httpx"); + await micropip.install("attrs"); + await micropip.install("pyodide-http"); + + const injectWheel = async (wheelName) => { + const bytes = new Uint8Array(await fetch(new URL(wheelName, import.meta.url)).then(r => r.arrayBuffer())); + pyodide.FS.writeFile(`/tmp/${wheelName}`, bytes); + pyodide.runPython(`import sys\nsys.path.insert(0, "/tmp/${wheelName}")`); + }; + const pkg = await fetch(new URL("package.json", import.meta.url)).then(r => r.json()); + const ownWheel = `${pkg.name.replace(/-/g, "_")}-${pkg.version}-py3-none-any.whl`; + await injectWheel("et_rest_client-0.1.0-py3-none-any.whl"); + await injectWheel(ownWheel); + const pydata1 = pyodide.pyimport("pydata1"); pyMod = { run: pydata1.run, @@ -46,22 +67,6 @@ export async function run() { const { WsClient, WsClientConfig } = wasmAgent; const client = new WsClient(new WsClientConfig(wsUrl)); - let responseResolvers = []; - client.set_on_message((raw) => { - try { - const msg = JSON.parse(raw); - if (msg.type === "response") { - for (const { prefix, resolve } of responseResolvers) { - if (msg.message.startsWith(prefix)) { - responseResolvers = responseResolvers.filter(r => r.resolve !== resolve); - resolve(msg.message); - return; - } - } - } - } catch { /* ignore */ } - }); - client.connect(); const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); @@ -80,51 +85,18 @@ export async function run() { if (i === 99) throw new Error("Timeout waiting for agent_id"); } - const wsSend = (msgStr) => { - // inject agent_id into fetch_file messages - const msg = JSON.parse(msgStr); - if (msg.type === "fetch_file") msg.agent_id = agentId; - client.send(JSON.stringify(msg)); - }; - - const waitForResponse = (prefix) => - new Promise((resolve, reject) => { - const timer = setTimeout(() => { - responseResolvers = responseResolvers.filter(r => r.resolve !== resolve); - reject(new Error(`Timeout waiting for response with prefix: ${prefix}`)); - }, 5000); - responseResolvers.push({ - prefix, - resolve: (val) => { - clearTimeout(timer); - resolve(val); - }, - }); - }); - - const putFile = async (url, content) => { - const resp = await fetch(url, { method: "PUT", mode: "cors", body: content }); - if (!resp.ok) throw new Error(`PUT failed: ${resp.status}`); - }; - - const getFile = async (url) => { - const resp = await fetch(url); - if (!resp.ok) throw new Error(`GET failed: ${resp.status}`); - return resp.text(); - }; - const log = (msg) => { console.log(msg); const el = document.getElementById("module-output"); if (el) el.value = (el.value ? el.value + "\n" : "") + msg; }; + // The Python side runs `Client(base_url=...)` against this origin and + // does PUT/GET itself via the generated client + pyodide-http patch. try { await pyMod.run( - pyodide.toPy(wsSend), - pyodide.toPy(waitForResponse), - pyodide.toPy(putFile), - pyodide.toPy(getFile), + agentId, + window.location.origin, pyodide.toPy(sleep), pyodide.toPy(log), pyodide.toPy(() => {}), diff --git a/services/ws-modules/pydata1/pydata1/__init__.py b/services/ws-modules/pydata1/pydata1/__init__.py index 3c6c617..647ad79 100644 --- a/services/ws-modules/pydata1/pydata1/__init__.py +++ b/services/ws-modules/pydata1/pydata1/__init__.py @@ -1,42 +1,43 @@ """pydata1: Python implementation of the data1 workflow.""" -import json from datetime import datetime, timezone +import pyodide_http +from et_rest_client import Client +from et_rest_client.api.et_storage_service import get_file, put_file +from et_rest_client.types import File + + +async def run(agent_id, base_url, sleep_ms, log, set_status) -> None: + """Execute the data1 workflow: store, fetch, verify.""" + # httpx ships its own transport stack; in Pyodide that stack has no + # network access. `pyodide_http.patch_all()` swaps httpx's transports + # for ones that dispatch through the browser's fetch(), letting the + # generated client work unmodified. + pyodide_http.patch_all() -async def run( - ws_send, wait_for_response, put_file, get_file, sleep_ms, log, set_status -) -> None: - """Execute the data1 workflow: connect, store, fetch, verify.""" log("pydata1: entered run()") filename = "test_data.txt" test_content = f"Hello from pydata1 at {datetime.now(timezone.utc).isoformat()}!" - # 1. Request Store URL - log("pydata1: requesting store URL") - ws_send(json.dumps({"type": "store_file", "filename": filename})) - store_response = await wait_for_response("PUT to ") - store_url = store_response.replace("PUT to ", "") - - # 2. Perform PUT - msg = f"pydata1: storing data to {store_url}" + msg = f"pydata1: storing data to /storage/{agent_id}/{filename}" log(msg) set_status(msg) - await put_file(store_url, test_content) - - # 3. Request Fetch URL - log("pydata1: requesting fetch URL") - ws_send(json.dumps({"type": "fetch_file", "filename": filename})) - fetch_response = await wait_for_response("GET from ") - fetch_url = fetch_response.replace("GET from ", "") - - # 4. Perform GET and Verify - msg = f"pydata1: fetching data from {fetch_url}" - log(msg) - set_status(msg) - retrieved = await get_file(fetch_url) + async with Client(base_url=base_url) as c: + await put_file.asyncio_detailed( + agent_id, + filename, + client=c, + body=File(payload=test_content.encode("utf-8")), + ) + + msg = f"pydata1: fetching data from /storage/{agent_id}/{filename}" + log(msg) + set_status(msg) + response = await get_file.asyncio_detailed(agent_id, filename, client=c) + retrieved = response.content.decode("utf-8") if retrieved == test_content: msg = "pydata1: VERIFICATION SUCCESS - data matches!" log(msg) diff --git a/services/ws-modules/pydata1/pyproject.toml b/services/ws-modules/pydata1/pyproject.toml index 79539a4..2118861 100644 --- a/services/ws-modules/pydata1/pyproject.toml +++ b/services/ws-modules/pydata1/pyproject.toml @@ -1,5 +1,10 @@ [project] -dependencies = [] +# `et-rest-client` is the generated REST client wheel; the build task copies +# it into pkg/ alongside this module's own wheel, and the browser-side JS +# shim sys.path-injects both (et-rest-client is deliberately unpublished, +# so micropip can't resolve it). `pyodide-http` is on PyPI and the shim +# micropip-installs it so httpx dispatches through the browser's fetch(). +dependencies = ["et-rest-client", "pyodide-http"] description = "Python data 1" license = "Apache-2.0 OR MIT" name = "et-ws-pydata1" diff --git a/services/ws-modules/pyface1/pkg/et_ws_pyface1.js b/services/ws-modules/pyface1/pkg/et_ws_pyface1.js index 7b191b8..20895f8 100644 --- a/services/ws-modules/pyface1/pkg/et_ws_pyface1.js +++ b/services/ws-modules/pyface1/pkg/et_ws_pyface1.js @@ -22,11 +22,28 @@ export default async function init() { } pyodide = await globalThis.loadPyodide({ indexURL: PYODIDE_BASE_PATH }); + + // Install pydantic via micropip; the generated et_ws wheel uses it for the + // typed WsClientEvent message. Pyodide ships pydantic v2 as a regular + // pip-installable package, so micropip resolves it without extra config. + await pyodide.loadPackage("micropip"); + const micropip = pyodide.pyimport("micropip"); + await micropip.install("pydantic"); + + // The pyface1 module ships two wheels in its pkg/: its own, and the + // generated et_ws wheel (copied in by the build task). + const installWheel = async (path) => { + const bytes = new Uint8Array(await fetch(new URL(path, import.meta.url)).then((r) => r.arrayBuffer())); + pyodide.FS.writeFile(`/tmp/${path}`, bytes); + pyodide.runPython(`import sys\nsys.path.insert(0, "/tmp/${path}")`); + }; + const pkg = await fetch(new URL("package.json", import.meta.url)).then((r) => r.json()); - const wheel = `${pkg.name.replace(/-/g, "_")}-${pkg.version}-py3-none-any.whl`; - const wheelBytes = new Uint8Array(await fetch(new URL(wheel, import.meta.url)).then((r) => r.arrayBuffer())); - pyodide.FS.writeFile(`/tmp/${wheel}`, wheelBytes); - pyodide.runPython(`import sys\nsys.path.insert(0, "/tmp/${wheel}")`); + const pyfaceWheel = `${pkg.name.replace(/-/g, "_")}-${pkg.version}-py3-none-any.whl`; + await installWheel(pyfaceWheel); + // The et_ws wheel ships with a stable name; bump the version in lock-step + // when generated/python-ws/pyproject.toml changes. + await installWheel("et_ws-0.1.0-py3-none-any.whl"); py = pyodide.pyimport("pyface1"); cfg = py.config().toJs({ dict_converter: Object.fromEntries }); } diff --git a/services/ws-modules/pyface1/pyface1/face_detection.py b/services/ws-modules/pyface1/pyface1/face_detection.py index cf9b637..43a025d 100644 --- a/services/ws-modules/pyface1/pyface1/face_detection.py +++ b/services/ws-modules/pyface1/pyface1/face_detection.py @@ -3,12 +3,13 @@ from __future__ import annotations import math -import json import time from datetime import datetime from functools import lru_cache from typing import Iterable, Sequence, TypedDict +from et_ws.messages import WsClientEvent + FACE_MODEL_PATH = "/modules/et-model-face1/video_cv.onnx" FACE_INPUT_WIDTH = 640 FACE_INPUT_HEIGHT = 608 @@ -163,28 +164,27 @@ def preprocess_geometry(source_width: float, source_height: float) -> dict[str, ) return { "resize_ratio": resize_ratio, - "resized_width": float( - int(clamp(round(source_width * resize_ratio), 1, FACE_INPUT_WIDTH)) - ), - "resized_height": float( - int(clamp(round(source_height * resize_ratio), 1, FACE_INPUT_HEIGHT)) - ), + "resized_width": float(int(clamp(round(source_width * resize_ratio), 1, FACE_INPUT_WIDTH))), + "resized_height": float(int(clamp(round(source_height * resize_ratio), 1, FACE_INPUT_HEIGHT))), } def detections_json(detections: list[Detection]) -> str: + import json + return json.dumps(detections) def client_event_json(details: dict[str, object]) -> str: - return json.dumps( - { - "type": "client_event", - "capability": "face_detection", - "action": "inference", - "details": details, - } - ) + # Use the generated `WsClientEvent` Pydantic model so the wire shape stays + # in lock-step with `edge_toolkit::ws::WsMessage::ClientEvent`. Regenerate + # via `mise run gen-python-ws` if the protocol changes. + return WsClientEvent( + type="et-client-event", + capability="face_detection", + action="inference", + details=details, + ).model_dump_json() def decode_outputs( @@ -205,11 +205,7 @@ def decode_outputs( landm = output_values(landm_values, "landm", 10) prior_count = len(loc) // 4 - if ( - prior_count == 0 - or len(conf) != prior_count * 2 - or len(landm) != prior_count * 10 - ): + if prior_count == 0 or len(conf) != prior_count * 2 or len(landm) != prior_count * 10: raise ValueError("RetinaFace outputs had unexpected shapes") priors = model_priors() @@ -256,9 +252,7 @@ def decode_outputs( } -def status_text( - input_name: str, output_names: Iterable[object], summary: DetectionSummary -) -> str: +def status_text(input_name: str, output_names: Iterable[object], summary: DetectionSummary) -> str: """Render the browser status text used by the face detection demo.""" outputs = ", ".join(str(name) for name in output_names) lines = [ diff --git a/services/ws-modules/pyface1/pyproject.toml b/services/ws-modules/pyface1/pyproject.toml index 0708aa7..94f245a 100644 --- a/services/ws-modules/pyface1/pyproject.toml +++ b/services/ws-modules/pyface1/pyproject.toml @@ -1,11 +1,14 @@ [project] -dependencies = [] +dependencies = ["et-ws"] description = "Python face detection" license = "Apache-2.0 OR MIT" name = "et-ws-pyface1" requires-python = ">=3.10" version = "0.1.0" +[dependency-groups] +dev = ["pytest"] + [build-system] build-backend = "uv_build" requires = ["uv_build>=0.10.2,<0.11.0"] @@ -14,6 +17,13 @@ requires = ["uv_build>=0.10.2,<0.11.0"] module-name = "pyface1" module-root = "" +# `et-ws` is the generated Pydantic client under `generated/python-ws/`. Wired +# as a uv source so `uv run pytest` resolves it locally; the wheel itself is +# bundled separately into pkg/ by the `build-ws-pyface1-module` mise task +# (alongside the pyface1 wheel) so Pyodide can micropip-install it at runtime. +[tool.uv.sources] +et-ws = { path = "../../../generated/python-ws" } + [tool.ws-module.dependencies] et-model-face1 = "*" onnxruntime-web = "*" diff --git a/services/ws-modules/wasi-comm1/src/lib.rs b/services/ws-modules/wasi-comm1/src/lib.rs index bc3114f..13e1fde 100644 --- a/services/ws-modules/wasi-comm1/src/lib.rs +++ b/services/ws-modules/wasi-comm1/src/lib.rs @@ -5,16 +5,14 @@ //! integration test only spins up a single runner, so the WASI port instead //! exercises the message round-trip with the server itself: //! 1. Connect and capture our agent_id. -//! 2. Send `list_agents`, recv a `list_agents_response`, assert the list +//! 2. Send `list-agents`, recv a `list-agents-response`, assert the list //! contains our agent_id (we're at least in our own roster). -//! 3. Send a `broadcast_message` (fire-and-forget when no peer is online). +//! 3. Send a `broadcast-message` (fire-and-forget when no peer is online). //! 4. Disconnect cleanly. //! -//! Wire-format messages are built with `serde_json::json!` and serialised -//! before going through `ws.send-text`; recv'd frames are parsed with -//! `serde_json::Value`. This mirrors the WsMessage enum in -//! `libs/edge-toolkit/src/ws.rs` but avoids depending on that crate (its -//! transitive deps don't all compile to wasm32-wasip2). +//! Messages cross the WIT boundary as typed `ws-message` variants from the +//! generated `et:ws-messages@0.1.0` package; opaque JSON payloads (the +//! `message` field on broadcast/direct messages) round-trip as strings. // Crate-level cfg gate: wit-bindgen's generated extern declarations only // resolve on `wasm32-wasip2`. Gating the whole module on `target_os = "wasi"` @@ -24,17 +22,17 @@ #![cfg(target_os = "wasi")] wit_bindgen::generate!({ - path: "../../ws-wasi-runner/wit", + path: "../../../generated/specs/wit", world: "module", generate_all, }); +use et::ws_messages::messages::{BroadcastMessagePayload, WsMessage}; use exports::et::ws_wasi::entry::Guest; -use serde_json::{Value, json}; use wasi::logging::logging::{self, Level}; const LOG_CONTEXT: &str = env!("CARGO_PKG_NAME"); -/// Total time we'll wait for a `list_agents_response`. The server replies +/// Total time we'll wait for a `list-agents-response`. The server replies /// immediately under normal load, but we leave headroom for the inbox queue. const LIST_AGENTS_TIMEOUT_MS: u32 = 2_000; @@ -52,32 +50,30 @@ impl Guest for Component { let agent_id = wait_for_agent_id().ok_or_else(|| "did not receive agent_id".to_string())?; info(&format!("websocket connected with agent_id={agent_id}")); - send_message(&json!({ "type": "list_agents" }))?; + et::ws_wasi::ws::send(&WsMessage::ListAgents).map_err(|e| format!("send list-agents: {e}"))?; - let response = wait_for_message_kind("list_agents_response", LIST_AGENTS_TIMEOUT_MS) - .ok_or_else(|| "no list_agents_response within timeout".to_string())?; - let agents = response - .get("agents") - .and_then(Value::as_array) - .ok_or_else(|| "list_agents_response missing `agents` array".to_string())?; - info(&format!("list_agents_response: {} agent(s) registered", agents.len())); + let response = wait_for_list_agents_response(LIST_AGENTS_TIMEOUT_MS) + .ok_or_else(|| "no list-agents-response within timeout".to_string())?; + info(&format!( + "list-agents-response: {} agent(s) registered", + response.agents.len() + )); - let self_listed = agents - .iter() - .any(|a| a.get("agent_id").and_then(Value::as_str) == Some(agent_id.as_str())); + let self_listed = response.agents.iter().any(|a| a.agent_id == agent_id); if !self_listed { - return Err(format!("own agent_id {agent_id} missing from list_agents_response")); + return Err(format!("own agent_id {agent_id} missing from list-agents-response")); } info("self present in roster"); - send_message(&json!({ - "type": "broadcast_message", - "message": { - "module": "wasi-comm1", - "from_agent_id": agent_id, - "message": "wasi-comm1 broadcast — likely peerless under the runner test", - } - }))?; + let body = serde_json::json!({ + "module": "wasi-comm1", + "from_agent_id": agent_id, + "message": "wasi-comm1 broadcast — likely peerless under the runner test", + }); + et::ws_wasi::ws::send(&WsMessage::BroadcastMessage(BroadcastMessagePayload { + message: serde_json::to_string(&body).map_err(|e| format!("serialize broadcast body: {e}"))?, + })) + .map_err(|e| format!("send broadcast: {e}"))?; info("broadcast sent"); et::ws_wasi::ws::disconnect(); @@ -86,26 +82,18 @@ impl Guest for Component { } } -fn send_message(value: &Value) -> Result<(), String> { - let text = serde_json::to_string(value).map_err(|e| format!("serialize message: {e}"))?; - et::ws_wasi::ws::send_text(&text).map_err(|e| format!("ws.send_text: {e}")) -} - -/// Drain the recv inbox until we see a message whose `type` matches `kind`. -/// Each `recv` call blocks for the remaining budget; we keep going until -/// either the budget is exhausted or the inbox runs dry. -fn wait_for_message_kind(kind: &str, total_timeout_ms: u32) -> Option { +/// Drain the recv inbox until we see a `list-agents-response`. Each `recv` +/// call blocks for the remaining budget; keep going until either the budget +/// is exhausted or we get the message we want. +fn wait_for_list_agents_response( + total_timeout_ms: u32, +) -> Option { let mut remaining = total_timeout_ms; while remaining > 0 { let chunk = remaining.min(200); match et::ws_wasi::ws::recv(chunk).ok()? { - Some(text) => { - if let Ok(value) = serde_json::from_str::(&text) - && value.get("type").and_then(Value::as_str) == Some(kind) - { - return Some(value); - } - } + Some(WsMessage::ListAgentsResponse(payload)) => return Some(payload), + Some(_) => {} None => {} } remaining = remaining.saturating_sub(chunk); diff --git a/services/ws-modules/wasi-data1/src/lib.rs b/services/ws-modules/wasi-data1/src/lib.rs index 9dc8128..5c20049 100644 --- a/services/ws-modules/wasi-data1/src/lib.rs +++ b/services/ws-modules/wasi-data1/src/lib.rs @@ -22,7 +22,7 @@ #![cfg(target_os = "wasi")] wit_bindgen::generate!({ - path: "../../ws-wasi-runner/wit", + path: "../../../generated/specs/wit", world: "module", generate_all, }); diff --git a/services/ws-modules/wasi-graphics-info/wasi_graphics_info/__init__.py b/services/ws-modules/wasi-graphics-info/wasi_graphics_info/__init__.py index 230bba1..6636822 100644 --- a/services/ws-modules/wasi-graphics-info/wasi_graphics_info/__init__.py +++ b/services/ws-modules/wasi-graphics-info/wasi_graphics_info/__init__.py @@ -23,7 +23,15 @@ import json import struct -from wit_world.imports import logging, monotonic_clock, poll, store, webgpu, ws +from wit_world.imports import ( + logging, + messages, + monotonic_clock, + poll, + store, + webgpu, + ws, +) from wit_world.imports.logging import Level from wit_world.imports import graph as nn_graph from wit_world.imports.graph import ExecutionTarget, GraphEncoding @@ -130,7 +138,15 @@ def _log(message: str) -> None: def _send_event(category: str, kind: str, body: dict) -> None: - ws.send_event(category, kind, json.dumps(body)) + ws.send( + messages.WsMessage_ClientEvent( + messages.ClientEventPayload( + capability=category, + action=kind, + details=json.dumps(body), + ) + ) + ) def _now_ms() -> int: @@ -178,9 +194,7 @@ def _entry(binding: int, read_only: bool) -> GpuBindGroupLayoutEntry: binding=binding, visibility=GpuShaderStage.compute(), buffer=GpuBufferBindingLayout( - type=GpuBufferBindingType.READ_ONLY_STORAGE - if read_only - else GpuBufferBindingType.STORAGE, + type=GpuBufferBindingType.READ_ONLY_STORAGE if read_only else GpuBufferBindingType.STORAGE, has_dynamic_offset=False, min_binding_size=None, ), @@ -280,15 +294,11 @@ def _run_matmul() -> dict: label="matmul-bgl", ) ) - pl = device.create_pipeline_layout( - GpuPipelineLayoutDescriptor(bind_group_layouts=[bgl], label="matmul-pl") - ) + pl = device.create_pipeline_layout(GpuPipelineLayoutDescriptor(bind_group_layouts=[bgl], label="matmul-pl")) pipeline = device.create_compute_pipeline( GpuComputePipelineDescriptor( - compute=GpuProgrammableStage( - module=shader, entry_point="main", constants=None - ), + compute=GpuProgrammableStage(module=shader, entry_point="main", constants=None), layout=GpuLayoutMode_Specific(value=pl), label="matmul-pipeline", ) @@ -325,9 +335,7 @@ def _run_matmul() -> dict: result_c00 = struct.unpack(" 1e-4: - raise RuntimeError( - f"wasi-webgpu: matmul produced C[0][0]={result_c00}, expected {EXPECTED_C00}" - ) + raise RuntimeError(f"wasi-webgpu: matmul produced C[0][0]={result_c00}, expected {EXPECTED_C00}") _log(f"wasi-webgpu matmul: C[0][0]={result_c00:.4f} in {elapsed_ms:.2f}ms") return { @@ -380,9 +388,7 @@ def _mnist_inference() -> dict: out_name, out_tensor = outputs[0] if out_name != MNIST_OUTPUT_NAME: - _log( - f"warning: output name {out_name!r} differs from expected {MNIST_OUTPUT_NAME!r}" - ) + _log(f"warning: output name {out_name!r} differs from expected {MNIST_OUTPUT_NAME!r}") raw = out_tensor.data() arr = array.array("f") @@ -396,9 +402,7 @@ def _mnist_inference() -> dict: _log(f"predicted class: {predicted}, logits: {[round(v, 3) for v in logits]}") if predicted != EXPECTED_MNIST_CLASS: - raise RuntimeError( - f"MNIST verification FAILED: predicted {predicted}, expected {EXPECTED_MNIST_CLASS}" - ) + raise RuntimeError(f"MNIST verification FAILED: predicted {predicted}, expected {EXPECTED_MNIST_CLASS}") _log("MNIST verification: ok") return { diff --git a/services/ws-modules/zig-data1/build.zig b/services/ws-modules/zig-data1/build.zig index 9761c19..68e9d0f 100644 --- a/services/ws-modules/zig-data1/build.zig +++ b/services/ws-modules/zig-data1/build.zig @@ -26,6 +26,16 @@ pub fn build(b: *std.Build) void { .optimize = optimize, }); + // Generated REST client (path-pinned, lives under generated/zig-rest/). + // The single `extern fn js_rest_request` it relies on is satisfied by + // the worker shim in pkg/. + const rest_module = b.createModule(.{ + .root_source_file = b.path("../../../generated/zig-rest/src/et_rest_client.zig"), + .target = target, + .optimize = optimize, + }); + root_module.addImport("et_rest_client", rest_module); + const lib = b.addExecutable(.{ .name = name, .root_module = root_module, diff --git a/services/ws-modules/zig-data1/pkg/et_ws_zig_data1.js b/services/ws-modules/zig-data1/pkg/et_ws_zig_data1.js index 45ab929..8b82fa3 100644 --- a/services/ws-modules/zig-data1/pkg/et_ws_zig_data1.js +++ b/services/ws-modules/zig-data1/pkg/et_ws_zig_data1.js @@ -1,12 +1,13 @@ // et_ws_zig_data1.js — zig-data1 WASM module -// Runs WASM in a Web Worker; main thread proxies WebSocket + fetch via SharedArrayBuffer. -// Shared memory layout (Int32 offsets): +// Runs WASM in a Web Worker; main thread proxies WebSocket + fetch via +// SharedArrayBuffer. Shared memory layout (Int32 offsets): // [0] signal: 0=idle, 1=request-pending -// [1] request type: 0=sleep, 1=ws_connect, 2=ws_send, 3=ws_get_state, 4=ws_get_agent_id, -// 5=ws_pop_response, 6=put_file, 7=get_file, 8=ws_disconnect, -// 9=log, 10=set_status, 11=get_ws_url, 12=get_iso_timestamp -// [2] payload length (also response length) -// [3] aux length (for put_file body) +// [1] request type: 0=sleep, 1=ws_connect, 2=ws_get_state, 3=ws_get_agent_id, +// 6=ws_disconnect, 7=log, 8=set_status, 9=get_ws_url, +// 10=get_iso_timestamp, 11=rest_request +// [2] payload length (also response length; -1 on rest_request failure) +// [3] aux length (binary request body for rest_request; UTF-8 ignored +// for other types) // Data area starts at byte offset 16. export default async function init() {} @@ -32,8 +33,21 @@ export async function run() { Atomics.notify(ctrl, 0); }; + const respondBytes = (bytes) => { + data.set(bytes); + Atomics.store(ctrl, 2, bytes.length); + Atomics.store(ctrl, 0, 0); + Atomics.notify(ctrl, 0); + }; + + const respondError = () => { + Atomics.store(ctrl, 2, -1); + Atomics.store(ctrl, 0, 0); + Atomics.notify(ctrl, 0); + }; + return new Promise((resolve, reject) => { - let ws = null, wsState = "disconnected", agentId = "", lastResponse = null; + let ws = null, wsState = "disconnected", agentId = ""; const poll = () => { if (Atomics.load(ctrl, 0) !== 1) { @@ -44,9 +58,7 @@ export async function run() { const type = Atomics.load(ctrl, 1); const plen = Atomics.load(ctrl, 2); const alen = Atomics.load(ctrl, 3); - const copy = (off, len) => dec.decode(Uint8Array.from(data.subarray(off, off + len))); - const payload = copy(0, plen); - const aux = alen ? copy(plen, alen) : ""; + const payload = dec.decode(Uint8Array.from(data.subarray(0, plen))); switch (type) { case 0: @@ -60,13 +72,12 @@ export async function run() { wsState = "connecting"; ws.onopen = () => { wsState = "connected"; - ws.send(JSON.stringify({ type: "connect" })); + ws.send(JSON.stringify({ type: "et-connect" })); }; ws.onmessage = (e) => { try { const msg = JSON.parse(e.data); - if (msg.type === "connect_ack" && msg.agent_id) agentId = msg.agent_id; - else if (msg.type === "response" && msg.message) lastResponse = msg.message; + if (msg.type === "et-connect-ack" && msg.agent_id) agentId = msg.agent_id; } catch {} }; ws.onclose = ws.onerror = () => { @@ -75,63 +86,59 @@ export async function run() { respond(); break; case 2: - ws?.send(payload); - respond(); - break; - case 3: respond(wsState); break; - case 4: + case 3: respond(agentId); break; - case 5: { - const r = lastResponse ?? ""; - lastResponse = null; - respond(r); - break; - } case 6: - fetch(payload, { method: "PUT", body: aux }) - .then(() => { - respond(); - poll(); - }).catch(() => { - respond(); - poll(); - }); - return; - case 7: - fetch(payload).then(r => r.text()) - .then(t => { - respond(t); - poll(); - }).catch(() => { - respond(); - poll(); - }); - return; - case 8: ws?.close(); wsState = "disconnected"; respond(); break; - case 9: + case 7: console.log(payload); appendOutput(payload); respond(); break; - case 10: + case 8: appendOutput(payload); respond(); break; - case 11: { + case 9: { const p = location.protocol === "https:" ? "wss:" : "ws:"; respond(`${p}//${location.host}/ws`); break; } - case 12: + case 10: respond(new Date().toISOString()); break; + case 11: { + // payload = "METHOD url", aux = binary body. Response is the raw + // body bytes; signal failures with respondError() so the Zig + // extern returns -1. + const spaceIdx = payload.indexOf(" "); + const method = payload.substring(0, spaceIdx); + const url = payload.substring(spaceIdx + 1); + const opts = { method }; + if (alen > 0) { + opts.body = new Uint8Array(data.subarray(plen, plen + alen)).slice(); + } + fetch(url, opts) + .then((r) => { + if (!r.ok) throw new Error(`HTTP ${r.status}`); + return r.arrayBuffer(); + }) + .then((buf) => { + respondBytes(new Uint8Array(buf)); + poll(); + }) + .catch(() => { + respondError(); + poll(); + }); + return; + } default: respond(); break; diff --git a/services/ws-modules/zig-data1/pkg/et_ws_zig_data1_worker.js b/services/ws-modules/zig-data1/pkg/et_ws_zig_data1_worker.js index 9b20392..5a05be3 100644 --- a/services/ws-modules/zig-data1/pkg/et_ws_zig_data1_worker.js +++ b/services/ws-modules/zig-data1/pkg/et_ws_zig_data1_worker.js @@ -3,12 +3,8 @@ const DATA_OFFSET = 16; let ctrl, data, wasmMemory; const enc = new TextEncoder(), dec = new TextDecoder(); const readStr = (ptr, len) => dec.decode(new Uint8Array(wasmMemory.buffer, ptr, len)); -const writeData = (str) => { - const b = enc.encode(str); - data.set(b); - return b.length; -}; +// String payload + string aux, response is a UTF-8 string (legacy ops). function call(type, payload = "", aux = "") { const pb = enc.encode(payload), ab = enc.encode(aux); data.set(pb); @@ -23,6 +19,30 @@ function call(type, payload = "", aux = "") { return dec.decode(Uint8Array.from(data.subarray(0, rlen))); } +// REST request: method + url in the string payload (space-separated), body +// is *binary* aux bytes, response is *binary* bytes copied out of the SAB. +// Type 11 lives alongside the string-only legacy ops (0-10) so the existing +// dispatch table doesn't have to be reshuffled. +function callRest(method, url, body) { + const pb = enc.encode(`${method} ${url}`); + const ab = body || new Uint8Array(0); + data.set(pb); + if (ab.length) data.set(ab, pb.length); + Atomics.store(ctrl, 3, ab.length); + Atomics.store(ctrl, 2, pb.length); + Atomics.store(ctrl, 1, 11); + Atomics.store(ctrl, 0, 1); + Atomics.notify(ctrl, 0); + Atomics.wait(ctrl, 0, 1); + const rlen = Atomics.load(ctrl, 2); + // Negative response length is the error sentinel — the main-thread + // dispatch encodes (max int32 + 1 - n) to signal failure. + if (rlen < 0) return null; + // Slice copies the bytes out of the SAB region so the wasm caller can + // overwrite the area on its next request. + return new Uint8Array(data.subarray(0, rlen)).slice(); +} + const writeBack = (r, buf, max) => { const b = enc.encode(r); const n = Math.min(b.length, max); @@ -32,22 +52,29 @@ const writeBack = (r, buf, max) => { const imports = { env: { - js_log: (p, l) => call(9, readStr(p, l)), - js_set_status: (p, l) => call(10, readStr(p, l)), + js_log: (p, l) => call(7, readStr(p, l)), + js_set_status: (p, l) => call(8, readStr(p, l)), js_ws_connect: (p, l) => call(1, readStr(p, l)), - js_ws_send: (p, l) => call(2, readStr(p, l)), - js_ws_disconnect: () => call(8), - js_ws_get_state: (buf, max) => writeBack(call(3), buf, max), - js_ws_get_agent_id: (buf, max) => writeBack(call(4), buf, max), - js_ws_pop_response: (buf, max) => { - const r = call(5); - return r ? writeBack(r, buf, max) : 0; + js_ws_disconnect: () => call(6), + js_ws_get_state: (buf, max) => writeBack(call(2), buf, max), + js_ws_get_agent_id: (buf, max) => writeBack(call(3), buf, max), + // Single HTTP entry point used by the generated et_rest_client. The Zig + // signature is: (method_ptr, method_len, url_ptr, url_len, body_ptr, + // body_len, response_buf, response_max) -> i32. Returns bytes written + // to response_buf, or -1 on failure. + js_rest_request: (mp, ml, up, ul, bp, bl, buf, max) => { + const method = readStr(mp, ml); + const url = readStr(up, ul); + const body = bl > 0 ? new Uint8Array(wasmMemory.buffer, bp, bl).slice() : null; + const response = callRest(method, url, body); + if (response === null) return -1; + const n = Math.min(response.length, max); + new Uint8Array(wasmMemory.buffer, buf, n).set(response.subarray(0, n)); + return n; }, - js_put_file: (up, ul, bp, bl) => call(6, readStr(up, ul), readStr(bp, bl)), - js_get_file: (up, ul, buf, max) => writeBack(call(7, readStr(up, ul)), buf, max), js_sleep_ms: (ms) => call(0, String(ms)), - js_get_ws_url: (buf, max) => writeBack(call(11), buf, max), - js_get_iso_timestamp: (buf, max) => writeBack(call(12), buf, max), + js_get_ws_url: (buf, max) => writeBack(call(9), buf, max), + js_get_iso_timestamp: (buf, max) => writeBack(call(10), buf, max), }, }; diff --git a/services/ws-modules/zig-data1/src/main.zig b/services/ws-modules/zig-data1/src/main.zig index e83f413..bd18fe1 100644 --- a/services/ws-modules/zig-data1/src/main.zig +++ b/services/ws-modules/zig-data1/src/main.zig @@ -1,18 +1,18 @@ // zig-data1: replicates data1 workflow in Zig compiled to WASM. // All browser I/O is provided by JS imports; Zig owns the workflow logic. +// HTTP goes through the generated `et_rest_client` typed client (which +// bottoms out in a single `extern fn js_rest_request` import implemented +// by the worker shim via SharedArrayBuffer + Atomics). const std = @import("std"); +const rest = @import("et_rest_client"); extern fn js_log(ptr: [*]const u8, len: usize) void; extern fn js_set_status(ptr: [*]const u8, len: usize) void; extern fn js_ws_connect(url_ptr: [*]const u8, url_len: usize) void; -extern fn js_ws_send(ptr: [*]const u8, len: usize) void; extern fn js_ws_disconnect() void; extern fn js_ws_get_state(buf: [*]u8, max: usize) usize; extern fn js_ws_get_agent_id(buf: [*]u8, max: usize) usize; -extern fn js_ws_pop_response(buf: [*]u8, max: usize) usize; -extern fn js_put_file(url_ptr: [*]const u8, url_len: usize, body_ptr: [*]const u8, body_len: usize) void; -extern fn js_get_file(url_ptr: [*]const u8, url_len: usize, buf: [*]u8, max: usize) usize; extern fn js_sleep_ms(ms: u32) void; extern fn js_get_ws_url(buf: [*]u8, max: usize) usize; extern fn js_get_iso_timestamp(buf: [*]u8, max: usize) usize; @@ -20,7 +20,9 @@ extern fn js_get_iso_timestamp(buf: [*]u8, max: usize) usize; // Declared in src/util.c extern fn byte_sum(buf: [*]const u8, len: usize) u8; -var heap: [64 * 1024]u8 = undefined; +// Bumped from 64K because the REST client allocates a 64K response buffer +// per request and the workflow runs several round-trips before completing. +var heap: [256 * 1024]u8 = undefined; var fba = std.heap.FixedBufferAllocator.init(&heap); const alloc = fba.allocator(); @@ -57,16 +59,6 @@ fn wait_agent_id(buf: []u8) usize { return 0; } -fn wait_response(prefix: []const u8, buf: []u8) usize { - var i: u32 = 0; - while (i < 50) : (i += 1) { - const n = js_ws_pop_response(buf.ptr, buf.len); - if (n > 0 and std.mem.startsWith(u8, buf[0..n], prefix)) return n; - js_sleep_ms(100); - } - return 0; -} - export fn run() i32 { var url_buf: [256]u8 = undefined; const url_len = js_get_ws_url(&url_buf, url_buf.len); @@ -104,47 +96,28 @@ export fn run() i32 { const cksum = byte_sum(content.ptr, content.len); log("content checksum (byte_sum from C): {d}", .{cksum}); - // 1. Request store URL - const store_msg = std.fmt.allocPrint(alloc, - \\{{"type":"store_file","filename":"{s}"}} - , .{filename}) catch return -1; - defer alloc.free(store_msg); - log("requesting store URL", .{}); - js_ws_send(store_msg.ptr, store_msg.len); - - var resp_buf: [512]u8 = undefined; - const store_resp_len = wait_response("PUT to ", &resp_buf); - if (store_resp_len == 0) { - log("timed out waiting for store URL", .{}); - return -1; - } - const store_url = resp_buf[7..store_resp_len]; // strip "PUT to " - log("storing data to {s}", .{store_url}); - set_status("zig-data1: storing data to {s}", .{store_url}); - js_put_file(store_url.ptr, store_url.len, content.ptr, content.len); - - // 2. Request fetch URL - const fetch_msg = std.fmt.allocPrint(alloc, - \\{{"type":"fetch_file","agent_id":"{s}","filename":"{s}"}} - , .{ agent_id, filename }) catch return -1; - defer alloc.free(fetch_msg); - log("requesting fetch URL", .{}); - js_ws_send(fetch_msg.ptr, fetch_msg.len); - - const fetch_resp_len = wait_response("GET from ", &resp_buf); - if (fetch_resp_len == 0) { - log("timed out waiting for fetch URL", .{}); + // The REST client targets the same origin we were served from, so an + // empty base_url leaves it with relative paths like `/storage/{id}/{f}` + // — the browser resolves those against the page origin via fetch(). + var client = rest.Client.init(alloc, undefined, ""); + defer client.deinit(); + + log("storing data to /storage/{s}/{s}", .{ agent_id, filename }); + set_status("zig-data1: storing data to /storage/{s}/{s}", .{ agent_id, filename }); + rest.put_file(&client, agent_id, filename, content) catch { + log("put_file failed", .{}); return -1; - } - const fetch_url = resp_buf[9..fetch_resp_len]; // strip "GET from " - log("fetching data from {s}", .{fetch_url}); - set_status("zig-data1: fetching data from {s}", .{fetch_url}); + }; - var get_buf: [512]u8 = undefined; - const got_len = js_get_file(fetch_url.ptr, fetch_url.len, &get_buf, get_buf.len); - const got = get_buf[0..got_len]; + log("fetching data from /storage/{s}/{s}", .{ agent_id, filename }); + set_status("zig-data1: fetching data from /storage/{s}/{s}", .{ agent_id, filename }); + var raw = rest.get_fileRaw(&client, agent_id, filename) catch { + log("get_file failed", .{}); + return -1; + }; + defer raw.deinit(); - if (std.mem.eql(u8, got, content)) { + if (std.mem.eql(u8, raw.body, content)) { log("VERIFICATION SUCCESS - data matches!", .{}); set_status("zig-data1: VERIFICATION SUCCESS - data matches!", .{}); } else { diff --git a/services/ws-server/Cargo.toml b/services/ws-server/Cargo.toml index 801369d..2a495bb 100644 --- a/services/ws-server/Cargo.toml +++ b/services/ws-server/Cargo.toml @@ -33,4 +33,5 @@ tokio = { version = "1", features = ["full"] } tracing.workspace = true tracing-actix-web.workspace = true tracing-subscriber.workspace = true +utoipa.workspace = true uuid.workspace = true diff --git a/services/ws-server/src/lib.rs b/services/ws-server/src/lib.rs index 2e8b9e4..63e1aa4 100644 --- a/services/ws-server/src/lib.rs +++ b/services/ws-server/src/lib.rs @@ -1,19 +1,33 @@ use actix_web::{HttpResponse, web}; pub use et_ws_service::{AgentSession, WsAgentRegistry}; +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; pub mod config; use crate::config::Config; +/// Server liveness probe response. Returned by `GET /health`. +#[derive(Clone, Debug, Deserialize, Serialize, ToSchema)] +pub struct HealthResponse { + pub status: String, + pub service: String, +} + pub async fn no_content() -> HttpResponse { HttpResponse::NoContent().finish() } +#[utoipa::path( + get, + path = "/health", + responses((status = 200, description = "Server is up", body = HealthResponse)) +)] pub async fn health() -> HttpResponse { - HttpResponse::Ok().json(serde_json::json!({ - "status": "healthy", - "service": "ws-server" - })) + HttpResponse::Ok().json(HealthResponse { + status: "healthy".to_string(), + service: "ws-server".to_string(), + }) } pub fn configure_app(cfg: &mut web::ServiceConfig, agent_registry: web::Data, config: &Config) { diff --git a/services/ws-server/static/app.js b/services/ws-server/static/app.js index 0a434ab..6b665e9 100644 --- a/services/ws-server/static/app.js +++ b/services/ws-server/static/app.js @@ -158,7 +158,7 @@ const handleProtocolMessage = (message) => { return; } - if (parsed?.type !== "connect_ack" || typeof parsed.agent_id !== "string") { + if (parsed?.type !== "et-connect-ack" || typeof parsed.agent_id !== "string") { return; } diff --git a/services/ws-test-server/Cargo.toml b/services/ws-test-server/Cargo.toml index 0c321f5..127e226 100644 --- a/services/ws-test-server/Cargo.toml +++ b/services/ws-test-server/Cargo.toml @@ -17,3 +17,10 @@ tempfile.workspace = true # Same TracingLogger setup as the real ws-server, so tests that init OTLP # in-process see server-side spans parented on the propagated traceparent. tracing-actix-web.workspace = true + +[dev-dependencies] +edge-toolkit.workspace = true +futures-util = "0.3" +serde_json.workspace = true +tokio = { version = "1", features = ["macros", "rt", "time"] } +tokio-tungstenite = { version = "0.24", default-features = false, features = ["connect"] } diff --git a/services/ws-test-server/tests/hub_forwarding.rs b/services/ws-test-server/tests/hub_forwarding.rs new file mode 100644 index 0000000..01febe3 --- /dev/null +++ b/services/ws-test-server/tests/hub_forwarding.rs @@ -0,0 +1,121 @@ +//! Verifies the ws service's hub-style fallback: any frame the server +//! can't parse as a known `WsMessage` is forwarded verbatim to every +//! other connected agent. Covers both text and binary payloads. + +use std::time::Duration; + +use edge_toolkit::ws::WsMessage; +use futures_util::{SinkExt, StreamExt}; +use tokio_tungstenite::{connect_async, tungstenite::Message}; + +/// Open a ws connection, send `et-connect`, and return `(stream, agent_id)` +/// once `et-connect-ack` has been observed. +async fn connect_agent( + ws_url: &str, +) -> ( + tokio_tungstenite::WebSocketStream>, + String, +) { + let (mut stream, _) = connect_async(ws_url).await.expect("ws connect"); + let connect_msg = serde_json::to_string(&WsMessage::Connect { agent_id: None }).unwrap(); + stream.send(Message::text(connect_msg)).await.expect("send connect"); + + while let Some(msg) = stream.next().await { + let msg = msg.expect("ws recv"); + let Message::Text(text) = msg else { + continue; + }; + if let Ok(WsMessage::ConnectAck { agent_id, .. }) = serde_json::from_str::(&text) { + return (stream, agent_id); + } + } + panic!("never received et-connect-ack"); +} + +/// Pull the next frame from `stream`, ignoring known protocol acks +/// (`et-message-status`, `et-connect-ack`, etc.) so callers see the +/// next "real" payload. +async fn next_payload( + stream: &mut tokio_tungstenite::WebSocketStream>, +) -> Message { + let deadline = tokio::time::Instant::now() + Duration::from_secs(5); + loop { + let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); + let next = tokio::time::timeout(remaining, stream.next()) + .await + .expect("timed out waiting for ws frame"); + let msg = next.expect("ws stream closed").expect("ws recv"); + match &msg { + Message::Text(text) => { + if let Ok(parsed) = serde_json::from_str::(text) + && matches!( + parsed, + WsMessage::ConnectAck { .. } | WsMessage::MessageStatus { .. } | WsMessage::Response { .. } + ) + { + continue; + } + return msg; + } + Message::Binary(_) => return msg, + Message::Ping(_) | Message::Pong(_) => continue, + other => panic!("unexpected control frame: {other:?}"), + } + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn unrecognised_text_is_broadcast_verbatim() { + let server = et_ws_test_server::start(); + + let (mut sender, _sender_id) = connect_agent(&server.ws_url).await; + let (mut receiver, _receiver_id) = connect_agent(&server.ws_url).await; + + // A frame the server can't parse as WsMessage — no `type` field, no + // recognisable shape. The hub fallback should forward it verbatim. + let raw = r#"{"hello":"world","nested":{"n":42}}"#; + sender.send(Message::text(raw)).await.expect("send unknown text"); + + let received = next_payload(&mut receiver).await; + let Message::Text(received_text) = received else { + panic!("expected text frame, got {received:?}"); + }; + assert_eq!( + received_text.as_str(), + raw, + "text payload must be forwarded byte-for-byte" + ); + + // Sender should not echo back to itself. + let echoed = tokio::time::timeout(Duration::from_millis(300), sender.next()).await; + assert!( + echoed.is_err() || matches!(&echoed, Ok(Some(Ok(Message::Ping(_) | Message::Pong(_))))), + "sender should not receive its own broadcast, got {echoed:?}" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn unrecognised_binary_is_broadcast_verbatim() { + let server = et_ws_test_server::start(); + + let (mut sender, _sender_id) = connect_agent(&server.ws_url).await; + let (mut receiver, _receiver_id) = connect_agent(&server.ws_url).await; + + // Arbitrary opaque bytes — the server has no way to interpret these, + // so the hub fallback must forward them as-is. + let payload: Vec = vec![0x00, 0x01, 0x02, 0xff, 0xfe, 0xfd, b'a', b'b', b'c']; + sender + .send(Message::binary(payload.clone())) + .await + .expect("send binary"); + + let received = next_payload(&mut receiver).await; + let Message::Binary(received_bytes) = received else { + panic!("expected binary frame, got {received:?}"); + }; + assert_eq!( + &received_bytes[..], + payload.as_slice(), + "binary payload must be forwarded byte-for-byte" + ); +} diff --git a/services/ws-wasi-runner/Cargo.toml b/services/ws-wasi-runner/Cargo.toml index 459b483..7ac0db3 100644 --- a/services/ws-wasi-runner/Cargo.toml +++ b/services/ws-wasi-runner/Cargo.toml @@ -32,11 +32,12 @@ async-trait = "0.1" bytemuck = { version = "1.16", features = ["derive"] } edge-toolkit.workspace = true et-otlp.workspace = true +et-rest-client = { path = "../../generated/rust-rest" } futures-util = "0.3" opentelemetry.workspace = true opentelemetry-http = "0.31" pollster = "0.4" -reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "stream"] } +reqwest = { version = "0.13", default-features = false, features = ["json", "rustls", "stream"] } serde.workspace = true serde-env.workspace = true serde_json.workspace = true diff --git a/services/ws-wasi-runner/src/bindings.rs b/services/ws-wasi-runner/src/bindings.rs new file mode 100644 index 0000000..0cb7a47 --- /dev/null +++ b/services/ws-wasi-runner/src/bindings.rs @@ -0,0 +1,40 @@ +//! wasmtime-bindgen output for the `runner` world declared in +//! `generated/specs/wit/world.wit`. Every WIT type defined in the world or +//! its dep packages is reachable through `crate::bindings::`. +//! +//! The `with:` map points wasi-webgpu's resource types at our wgpu-backed +//! payload structs (defined in `host::wasi_webgpu`); without it, the +//! bindgen-generated marker structs would be opaque and the resource_table +//! couldn't carry real wgpu objects. + +wasmtime::component::bindgen!({ + path: "../../generated/specs/wit", + world: "runner", + imports: { default: async }, + exports: { default: async }, + with: { + "wasi:keyvalue/store.bucket": super::host::wasi_keyvalue::Bucket, + "wasi:webgpu/webgpu.gpu": super::host::wasi_webgpu::Gpu, + "wasi:webgpu/webgpu.gpu-adapter": super::host::wasi_webgpu::GpuAdapter, + "wasi:webgpu/webgpu.gpu-adapter-info": super::host::wasi_webgpu::GpuAdapterInfo, + "wasi:webgpu/webgpu.gpu-supported-features": super::host::wasi_webgpu::GpuSupportedFeatures, + "wasi:webgpu/webgpu.gpu-supported-limits": super::host::wasi_webgpu::GpuSupportedLimits, + "wasi:webgpu/webgpu.gpu-device": super::host::wasi_webgpu::GpuDevice, + "wasi:webgpu/webgpu.gpu-queue": super::host::wasi_webgpu::GpuQueue, + "wasi:webgpu/webgpu.gpu-buffer": super::host::wasi_webgpu::GpuBuffer, + "wasi:webgpu/webgpu.gpu-buffer-usage": super::host::wasi_webgpu::GpuBufferUsage, + "wasi:webgpu/webgpu.gpu-map-mode": super::host::wasi_webgpu::GpuMapMode, + "wasi:webgpu/webgpu.gpu-shader-stage": super::host::wasi_webgpu::GpuShaderStage, + "wasi:webgpu/webgpu.gpu-bind-group-layout": super::host::wasi_webgpu::GpuBindGroupLayout, + "wasi:webgpu/webgpu.gpu-bind-group": super::host::wasi_webgpu::GpuBindGroup, + "wasi:webgpu/webgpu.gpu-pipeline-layout": super::host::wasi_webgpu::GpuPipelineLayout, + "wasi:webgpu/webgpu.gpu-shader-module": super::host::wasi_webgpu::GpuShaderModule, + "wasi:webgpu/webgpu.gpu-compute-pipeline": super::host::wasi_webgpu::GpuComputePipeline, + "wasi:webgpu/webgpu.gpu-command-encoder": super::host::wasi_webgpu::GpuCommandEncoder, + "wasi:webgpu/webgpu.gpu-compute-pass-encoder": super::host::wasi_webgpu::GpuComputePassEncoder, + "wasi:webgpu/webgpu.gpu-command-buffer": super::host::wasi_webgpu::GpuCommandBuffer, + "wasi:webgpu/webgpu.record-option-gpu-size64": super::host::wasi_webgpu::RecordOptionGpuSize64, + "wasi:webgpu/webgpu.record-gpu-pipeline-constant-value": + super::host::wasi_webgpu::RecordGpuPipelineConstantValue, + }, +}); diff --git a/services/ws-wasi-runner/src/host/mod.rs b/services/ws-wasi-runner/src/host/mod.rs index 6f71171..5eb1ca3 100644 --- a/services/ws-wasi-runner/src/host/mod.rs +++ b/services/ws-wasi-runner/src/host/mod.rs @@ -23,12 +23,12 @@ pub struct HostState { pub wasi_ctx: WasiCtx, pub resource_table: ResourceTable, - /// HTTP base of the ws-server (e.g. `http://localhost:8080`). - pub http_base: String, /// WebSocket URL of the ws-server (e.g. `ws://localhost:8080/ws`). pub ws_url: String, - pub http: reqwest::Client, + /// Typed REST client for the ws-server (modules + per-agent storage). + /// Generated by `cargo-progenitor` from `generated/specs/rest.yaml`. + pub rest: et_rest_client::Client, pub ws: Arc>>, /// wasi-nn context. Constructed once at startup so model loads + compute /// reuse the same `ort` session pool across calls. @@ -42,9 +42,8 @@ impl HostState { Self { wasi_ctx, resource_table: ResourceTable::new(), - http_base, ws_url, - http: reqwest::Client::new(), + rest: et_rest_client::Client::new(&http_base), ws: Arc::new(Mutex::new(None)), wasi_nn_ctx: wasi_nn::new_ctx(), } diff --git a/services/ws-wasi-runner/src/host/wasi_keyvalue.rs b/services/ws-wasi-runner/src/host/wasi_keyvalue.rs index 37f1602..d30802f 100644 --- a/services/ws-wasi-runner/src/host/wasi_keyvalue.rs +++ b/services/ws-wasi-runner/src/host/wasi_keyvalue.rs @@ -1,37 +1,31 @@ //! Implements `wasi:keyvalue/store` against the ws-server's storage and -//! modules services. The bucket identifier names a path-prefix: +//! modules services via the typed `et-rest-client`. The bucket identifier +//! names a namespace: //! -//! * `` → bucket prefix `/storage/{agent-uuid}/`. Reads work for -//! any agent's bucket (the server static-serves everything under -//! `/storage/`); writes only succeed when the runner's own agent owns the -//! bucket (server enforces `agent_id` is registered). -//! * `modules/` → bucket prefix `/modules//`. Used -//! by guests to fetch their own static assets bundled in `pkg/`. Writes -//! return `access-denied` since et-modules-service serves files static. -//! -//! The `Bucket` resource is just a thin owner of the prefix string; the -//! HTTP work happens in `get` / `set`. +//! * `` → per-agent storage bucket. Reads work for any agent's +//! bucket (server static-serves everything under `/storage/`); writes only +//! succeed when the runner's own agent owns the bucket (server enforces +//! `agent_id` is registered). +//! * `modules/` → module asset bucket. Used by guests to fetch +//! their own static assets bundled in `pkg/`. Writes return +//! `access-denied` since et-modules-service serves files static. +use futures_util::StreamExt; use wasmtime::component::Resource; use crate::HostState; use crate::bindings::wasi::keyvalue::store::{Error, Host, HostBucket, KeyResponse}; -pub struct Bucket { - /// URL path-prefix on the ws-server, including the leading slash and - /// trailing slash. Keys are appended verbatim. - prefix: String, - /// Whether this bucket accepts writes. False for `modules/...` buckets. - writable: bool, -} - -impl Bucket { - fn url(&self, http_base: &str, key: &str) -> String { - format!("{http_base}{}{key}", self.prefix) - } +/// Bucket-kind discriminator. The wire prefix on the ws-server is implied by +/// the variant; the typed REST client picks the right operation. +pub enum Bucket { + /// `/storage/{agent_id}/` — writable, owned by the named agent. + Storage { agent_id: String }, + /// `/modules/{module_name}/` — read-only static module assets. + Modules { module_name: String }, } -/// Map a `store.open` identifier to a bucket prefix and writability. +/// Map a `store.open` identifier to a bucket variant. fn bucket_from_identifier(identifier: &str) -> Result { if let Some(module_name) = identifier.strip_prefix("modules/") { if module_name.is_empty() || module_name.contains('/') { @@ -39,20 +33,29 @@ fn bucket_from_identifier(identifier: &str) -> Result { "invalid module bucket identifier: {identifier:?}" ))); } - return Ok(Bucket { - prefix: format!("/modules/{module_name}/"), - writable: false, + return Ok(Bucket::Modules { + module_name: module_name.to_string(), }); } if identifier.is_empty() || identifier.contains('/') { return Err(Error::Other(format!("invalid bucket identifier: {identifier:?}"))); } - Ok(Bucket { - prefix: format!("/storage/{identifier}/"), - writable: true, + Ok(Bucket::Storage { + agent_id: identifier.to_string(), }) } +/// Drain a progenitor `ByteStream` into a `Vec`. Used by both bucket +/// kinds since the wasi:keyvalue/store interface returns whole values. +async fn collect_stream(mut stream: et_rest_client::ByteStream) -> Result, Error> { + let mut out = Vec::new(); + while let Some(chunk) = stream.next().await { + let chunk = chunk.map_err(|e| Error::Other(format!("stream chunk: {e}")))?; + out.extend_from_slice(&chunk); + } + Ok(out) +} + impl Host for HostState { async fn open(&mut self, identifier: String) -> Result, Error> { let bucket = bucket_from_identifier(&identifier)?; @@ -70,24 +73,17 @@ impl HostBucket for HostState { .resource_table .get(&rep) .map_err(|e| Error::Other(format!("bucket handle: {e}")))?; - let url = bucket.url(&self.http_base, &key); - let resp = self - .http - .get(&url) - .send() - .await - .map_err(|e| Error::Other(format!("GET {url}: {e}")))?; - if resp.status() == reqwest::StatusCode::NOT_FOUND { - return Ok(None); - } - if !resp.status().is_success() { - return Err(Error::Other(format!("GET {url}: HTTP {}", resp.status()))); + let result = match bucket { + Bucket::Storage { agent_id } => self.rest.get_file(agent_id, &key).await, + Bucket::Modules { module_name } => self.rest.get_module_file(module_name, &key).await, + }; + match result { + Ok(response) => Ok(Some(collect_stream(response.into_inner()).await?)), + // The OpenAPI spec gives both endpoints a 404 variant, so progenitor + // surfaces "no such key" as `Error::ErrorResponse`. + Err(et_rest_client::Error::ErrorResponse(_)) => Ok(None), + Err(e) => Err(Error::Other(format!("GET {key}: {e}"))), } - let bytes = resp - .bytes() - .await - .map_err(|e| Error::Other(format!("GET {url} body: {e}")))?; - Ok(Some(bytes.to_vec())) } async fn set(&mut self, rep: Resource, key: String, value: Vec) -> Result<(), Error> { @@ -95,20 +91,14 @@ impl HostBucket for HostState { .resource_table .get(&rep) .map_err(|e| Error::Other(format!("bucket handle: {e}")))?; - if !bucket.writable { - return Err(Error::AccessDenied); - } - let url = bucket.url(&self.http_base, &key); - let resp = self - .http - .put(&url) - .body(value) - .send() + let agent_id = match bucket { + Bucket::Storage { agent_id } => agent_id.clone(), + Bucket::Modules { .. } => return Err(Error::AccessDenied), + }; + self.rest + .put_file(&agent_id, &key, value) .await - .map_err(|e| Error::Other(format!("PUT {url}: {e}")))?; - if !resp.status().is_success() { - return Err(Error::Other(format!("PUT {url}: HTTP {}", resp.status()))); - } + .map_err(|e| Error::Other(format!("PUT {key}: {e}")))?; Ok(()) } @@ -120,7 +110,7 @@ impl HostBucket for HostState { Err(Error::Other("exists not implemented".into())) } - async fn list_keys(&mut self, _rep: Resource, _cursor: Option) -> Result { + async fn list_keys(&mut self, _rep: Resource, _cursor: Option) -> Result { Err(Error::Other("list-keys not implemented".into())) } diff --git a/services/ws-wasi-runner/src/host/wasi_webgpu.rs b/services/ws-wasi-runner/src/host/wasi_webgpu.rs index 0cba6c3..b85112d 100644 --- a/services/ws-wasi-runner/src/host/wasi_webgpu.rs +++ b/services/ws-wasi-runner/src/host/wasi_webgpu.rs @@ -35,34 +35,12 @@ use crate::bindings::wasi::webgpu::webgpu::{ }; /// wgpu buffer-usage flags as the host wire-format. The WIT-side -/// `gpu-buffer-usage.STORAGE()` style accessors return these constants and -/// the guest ORs them into `gpu-buffer-descriptor.usage`. Matches the -/// WebGPU spec values so we can hand them directly to `wgpu::BufferUsages`. -mod usage { - pub const MAP_READ: u32 = 0x0001; - pub const MAP_WRITE: u32 = 0x0002; - pub const COPY_SRC: u32 = 0x0004; - pub const COPY_DST: u32 = 0x0008; - pub const INDEX: u32 = 0x0010; - pub const VERTEX: u32 = 0x0020; - pub const UNIFORM: u32 = 0x0040; - pub const STORAGE: u32 = 0x0080; - pub const INDIRECT: u32 = 0x0100; - pub const QUERY_RESOLVE: u32 = 0x0200; -} - -/// gpu-map-mode flag bits (WebGPU spec values). -mod map_mode { - pub const READ: u32 = 0x0001; - pub const WRITE: u32 = 0x0002; -} - -/// gpu-shader-stage flag bits (WebGPU spec values). -mod shader_stage { - pub const VERTEX: u32 = 0x1; - pub const FRAGMENT: u32 = 0x2; - pub const COMPUTE: u32 = 0x4; -} +// Flag-bit constants for the WebGPU enums live alongside this file as +// submodules; the inline mod-block form was deprecated by the +// `no-inline-mod` ast-grep rule. +mod map_mode; +mod shader_stage; +mod usage; /// Top-level handle: no per-instance state — `request-adapter` constructs a /// fresh `wgpu::Instance` each call rather than sharing one across guests. diff --git a/services/ws-wasi-runner/src/host/wasi_webgpu/map_mode.rs b/services/ws-wasi-runner/src/host/wasi_webgpu/map_mode.rs new file mode 100644 index 0000000..6e79b90 --- /dev/null +++ b/services/ws-wasi-runner/src/host/wasi_webgpu/map_mode.rs @@ -0,0 +1,4 @@ +//! `gpu-map-mode` flag bits (WebGPU spec values). + +pub const READ: u32 = 0x0001; +pub const WRITE: u32 = 0x0002; diff --git a/services/ws-wasi-runner/src/host/wasi_webgpu/shader_stage.rs b/services/ws-wasi-runner/src/host/wasi_webgpu/shader_stage.rs new file mode 100644 index 0000000..f95eefc --- /dev/null +++ b/services/ws-wasi-runner/src/host/wasi_webgpu/shader_stage.rs @@ -0,0 +1,5 @@ +//! `gpu-shader-stage` flag bits (WebGPU spec values). + +pub const VERTEX: u32 = 0x1; +pub const FRAGMENT: u32 = 0x2; +pub const COMPUTE: u32 = 0x4; diff --git a/services/ws-wasi-runner/src/host/wasi_webgpu/usage.rs b/services/ws-wasi-runner/src/host/wasi_webgpu/usage.rs new file mode 100644 index 0000000..1c3fba7 --- /dev/null +++ b/services/ws-wasi-runner/src/host/wasi_webgpu/usage.rs @@ -0,0 +1,15 @@ +//! `gpu-buffer-usage` flag bits (WebGPU spec values). `gpu-buffer-usage.STORAGE()` +//! style accessors return these constants and the guest ORs them into +//! `gpu-buffer-descriptor.usage`. Values match the WebGPU spec so they can be +//! handed directly to `wgpu::BufferUsages`. + +pub const MAP_READ: u32 = 0x0001; +pub const MAP_WRITE: u32 = 0x0002; +pub const COPY_SRC: u32 = 0x0004; +pub const COPY_DST: u32 = 0x0008; +pub const INDEX: u32 = 0x0010; +pub const VERTEX: u32 = 0x0020; +pub const UNIFORM: u32 = 0x0040; +pub const STORAGE: u32 = 0x0080; +pub const INDIRECT: u32 = 0x0100; +pub const QUERY_RESOLVE: u32 = 0x0200; diff --git a/services/ws-wasi-runner/src/host/ws.rs b/services/ws-wasi-runner/src/host/ws.rs index 581f93e..0ba66b8 100644 --- a/services/ws-wasi-runner/src/host/ws.rs +++ b/services/ws-wasi-runner/src/host/ws.rs @@ -4,13 +4,17 @@ //! and spawn a task that pumps inbound text messages into a channel. Inbound //! `connect_ack` messages capture our assigned `agent_id`. //! -//! `send-event` builds the same `WsMessage::ClientEvent` JSON shape the browser -//! `et-ws-wasm-agent` uses, so the server treats both client kinds identically. +//! Wire messages cross the WIT boundary as typed `et:ws-messages/messages.ws-message` +//! values. The host converts them to/from `edge_toolkit::ws::WsMessage` and serialises +//! to JSON for the actual websocket frame. Guests no longer hand-craft JSON. use std::sync::Arc; use std::time::Duration; -use edge_toolkit::ws::WsMessage; +use edge_toolkit::ws::{ + AgentConnectionState as EtAgentConnectionState, AgentSummary as EtAgentSummary, ConnectStatus as EtConnectStatus, + MessageDeliveryStatus as EtMessageDeliveryStatus, MessageScope as EtMessageScope, WsMessage, +}; use futures_util::SinkExt; use futures_util::stream::{SplitSink, StreamExt}; use tokio::net::TcpStream; @@ -19,8 +23,20 @@ use tokio::task::JoinHandle; use tokio_tungstenite::{MaybeTlsStream, WebSocketStream, tungstenite}; use crate::HostState; +use crate::bindings::et::ws_messages::messages::{ + AgentConnectionState as WitAgentConnectionState, AgentSummary as WitAgentSummary, AlivePayload, + BroadcastMessagePayload, ClientEventPayload, ConnectAckPayload, ConnectPayload, ConnectStatus as WitConnectStatus, + InvalidPayload, ListAgentsResponsePayload, MessageAckPayload, MessageDeliveryStatus as WitMessageDeliveryStatus, + MessageScope as WitMessageScope, MessageStatusPayload, ResponsePayload, SendAgentMessagePayload, + WsMessage as WitWsMessage, +}; use crate::bindings::et::ws_wasi::ws::{Host, State}; +// The `et:ws-messages/messages` interface only declares types — no functions. +// wasmtime-bindgen still requires a `Host` impl so the linker has somewhere +// to anchor the interface. The trait body is empty. +impl crate::bindings::et::ws_messages::messages::Host for HostState {} + type WsSink = SplitSink>, tungstenite::Message>; /// Live state for an open websocket connection. Owned by `HostState` behind a @@ -147,32 +163,31 @@ impl Host for HostState { } } - async fn send_event(&mut self, category: String, kind: String, body_json: String) -> Result<(), String> { - let body: serde_json::Value = - serde_json::from_str(&body_json).map_err(|e| format!("body-json is not valid JSON: {e}"))?; - let payload = serde_json::to_string(&WsMessage::ClientEvent { - capability: category, - action: kind, - details: body, - }) - .map_err(|e| format!("serialize client_event: {e}"))?; - self.send_text(payload).await - } - - async fn send_text(&mut self, text: String) -> Result<(), String> { + async fn send(&mut self, message: WitWsMessage) -> Result<(), String> { + let et_message = wit_to_et(message)?; + let payload = serde_json::to_string(&et_message).map_err(|e| format!("serialize ws-message: {e}"))?; let slot = self.ws.lock().await; let Some(backend) = slot.as_ref() else { return Err("not connected".into()); }; - backend.send_text(text).await + backend.send_text(payload).await } - async fn recv(&mut self, timeout_ms: u32) -> Result, String> { + async fn recv(&mut self, timeout_ms: u32) -> Result, String> { let slot = self.ws.lock().await; let Some(backend) = slot.as_ref() else { return Err("not connected".into()); }; - backend.recv(timeout_ms).await + let Some(text) = backend.recv(timeout_ms).await? else { + return Ok(None); + }; + // Unrecognised frames (server's hub-broadcast fallback) and binary + // frames don't parse as WsMessage. Skip them — guests will see `none` + // until the next typed frame arrives. + let Ok(parsed) = serde_json::from_str::(&text) else { + return Ok(None); + }; + Ok(Some(et_to_wit(parsed)?)) } async fn disconnect(&mut self) { @@ -184,3 +199,195 @@ impl Host for HostState { *slot = None; } } + +/// Convert a typed WIT message coming from the guest into the canonical Rust +/// wire-format type. Opaque JSON fields (sent as `string` over WIT) are parsed +/// here so the host always works with `serde_json::Value` payloads. +fn wit_to_et(msg: WitWsMessage) -> Result { + let parse_value = |s: String, label: &str| { + serde_json::from_str::(&s) + .map_err(|e| format!("{label}: opaque JSON payload is not valid JSON: {e}")) + }; + Ok(match msg { + WitWsMessage::Connect(p) => WsMessage::Connect { agent_id: p.agent_id }, + WitWsMessage::ConnectAck(p) => WsMessage::ConnectAck { + agent_id: p.agent_id, + status: wit_connect_status(p.status), + }, + WitWsMessage::Alive(p) => WsMessage::Alive { timestamp: p.timestamp }, + WitWsMessage::ListAgents => WsMessage::ListAgents, + WitWsMessage::ListAgentsResponse(p) => WsMessage::ListAgentsResponse { + agents: p.agents.into_iter().map(wit_agent_summary).collect(), + }, + WitWsMessage::SendAgentMessage(p) => WsMessage::SendAgentMessage { + to_agent_id: p.to_agent_id, + message: parse_value(p.message, "send-agent-message")?, + }, + WitWsMessage::BroadcastMessage(p) => WsMessage::BroadcastMessage { + message: parse_value(p.message, "broadcast-message")?, + }, + WitWsMessage::AgentMessage(p) => WsMessage::AgentMessage { + message_id: p.message_id, + from_agent_id: p.from_agent_id, + scope: wit_message_scope(p.scope), + server_received_at: p.server_received_at, + message: parse_value(p.message, "agent-message")?, + }, + WitWsMessage::MessageAck(p) => WsMessage::MessageAck { + message_id: p.message_id, + }, + WitWsMessage::MessageStatus(p) => WsMessage::MessageStatus { + message_id: p.message_id, + status: wit_delivery_status(p.status), + detail: p.detail, + }, + WitWsMessage::Invalid(p) => WsMessage::Invalid { + message_id: p.message_id, + detail: p.detail, + }, + WitWsMessage::ClientEvent(p) => WsMessage::ClientEvent { + capability: p.capability, + action: p.action, + details: parse_value(p.details, "client-event")?, + }, + WitWsMessage::Response(p) => WsMessage::Response { message: p.message }, + }) +} + +/// Reverse of `wit_to_et` — serialise opaque payloads back to JSON strings for +/// the WIT crossing. +fn et_to_wit(msg: WsMessage) -> Result { + let serialize = + |v: serde_json::Value| serde_json::to_string(&v).map_err(|e| format!("re-serialize opaque payload: {e}")); + Ok(match msg { + WsMessage::Connect { agent_id } => WitWsMessage::Connect(ConnectPayload { agent_id }), + WsMessage::ConnectAck { agent_id, status } => WitWsMessage::ConnectAck(ConnectAckPayload { + agent_id, + status: et_connect_status(status), + }), + WsMessage::Alive { timestamp } => WitWsMessage::Alive(AlivePayload { timestamp }), + WsMessage::ListAgents => WitWsMessage::ListAgents, + WsMessage::ListAgentsResponse { agents } => WitWsMessage::ListAgentsResponse(ListAgentsResponsePayload { + agents: agents.into_iter().map(et_agent_summary).collect(), + }), + WsMessage::SendAgentMessage { to_agent_id, message } => { + WitWsMessage::SendAgentMessage(SendAgentMessagePayload { + to_agent_id, + message: serialize(message)?, + }) + } + WsMessage::BroadcastMessage { message } => WitWsMessage::BroadcastMessage(BroadcastMessagePayload { + message: serialize(message)?, + }), + WsMessage::AgentMessage { + message_id, + from_agent_id, + scope, + server_received_at, + message, + } => WitWsMessage::AgentMessage(crate::bindings::et::ws_messages::messages::AgentMessagePayload { + message_id, + from_agent_id, + scope: et_message_scope(scope), + server_received_at, + message: serialize(message)?, + }), + WsMessage::MessageAck { message_id } => WitWsMessage::MessageAck(MessageAckPayload { message_id }), + WsMessage::MessageStatus { + message_id, + status, + detail, + } => WitWsMessage::MessageStatus(MessageStatusPayload { + message_id, + status: et_delivery_status(status), + detail, + }), + WsMessage::Invalid { message_id, detail } => WitWsMessage::Invalid(InvalidPayload { message_id, detail }), + WsMessage::ClientEvent { + capability, + action, + details, + } => WitWsMessage::ClientEvent(ClientEventPayload { + capability, + action, + details: serialize(details)?, + }), + WsMessage::Response { message } => WitWsMessage::Response(ResponsePayload { message }), + }) +} + +fn wit_connect_status(s: WitConnectStatus) -> EtConnectStatus { + match s { + WitConnectStatus::Assigned => EtConnectStatus::Assigned, + WitConnectStatus::Reconnected => EtConnectStatus::Reconnected, + } +} + +fn et_connect_status(s: EtConnectStatus) -> WitConnectStatus { + match s { + EtConnectStatus::Assigned => WitConnectStatus::Assigned, + EtConnectStatus::Reconnected => WitConnectStatus::Reconnected, + } +} + +fn wit_message_scope(s: WitMessageScope) -> EtMessageScope { + match s { + WitMessageScope::Direct => EtMessageScope::Direct, + WitMessageScope::Broadcast => EtMessageScope::Broadcast, + } +} + +fn et_message_scope(s: EtMessageScope) -> WitMessageScope { + match s { + EtMessageScope::Direct => WitMessageScope::Direct, + EtMessageScope::Broadcast => WitMessageScope::Broadcast, + } +} + +fn wit_delivery_status(s: WitMessageDeliveryStatus) -> EtMessageDeliveryStatus { + match s { + WitMessageDeliveryStatus::Delivered => EtMessageDeliveryStatus::Delivered, + WitMessageDeliveryStatus::Queued => EtMessageDeliveryStatus::Queued, + WitMessageDeliveryStatus::Acknowledged => EtMessageDeliveryStatus::Acknowledged, + WitMessageDeliveryStatus::Broadcast => EtMessageDeliveryStatus::Broadcast, + } +} + +fn et_delivery_status(s: EtMessageDeliveryStatus) -> WitMessageDeliveryStatus { + match s { + EtMessageDeliveryStatus::Delivered => WitMessageDeliveryStatus::Delivered, + EtMessageDeliveryStatus::Queued => WitMessageDeliveryStatus::Queued, + EtMessageDeliveryStatus::Acknowledged => WitMessageDeliveryStatus::Acknowledged, + EtMessageDeliveryStatus::Broadcast => WitMessageDeliveryStatus::Broadcast, + } +} + +fn wit_agent_connection_state(s: WitAgentConnectionState) -> EtAgentConnectionState { + match s { + WitAgentConnectionState::Connected => EtAgentConnectionState::Connected, + WitAgentConnectionState::Disconnected => EtAgentConnectionState::Disconnected, + } +} + +fn et_agent_connection_state(s: EtAgentConnectionState) -> WitAgentConnectionState { + match s { + EtAgentConnectionState::Connected => WitAgentConnectionState::Connected, + EtAgentConnectionState::Disconnected => WitAgentConnectionState::Disconnected, + } +} + +fn wit_agent_summary(s: WitAgentSummary) -> EtAgentSummary { + EtAgentSummary { + agent_id: s.agent_id, + state: wit_agent_connection_state(s.state), + last_known_ip: s.last_known_ip, + } +} + +fn et_agent_summary(s: EtAgentSummary) -> WitAgentSummary { + WitAgentSummary { + agent_id: s.agent_id, + state: et_agent_connection_state(s.state), + last_known_ip: s.last_known_ip, + } +} diff --git a/services/ws-wasi-runner/src/lib.rs b/services/ws-wasi-runner/src/lib.rs index f3dd0d2..8c9cde2 100644 --- a/services/ws-wasi-runner/src/lib.rs +++ b/services/ws-wasi-runner/src/lib.rs @@ -8,25 +8,25 @@ //! //! See `wit/world.wit` for the host/guest contract. -use opentelemetry_http::HeaderInjector; +use futures_util::StreamExt; use thiserror::Error; use tracing::Instrument; -use tracing_opentelemetry::OpenTelemetrySpanExt; use wasmtime::component::{Component, HasSelf, Linker}; use wasmtime::{Config, Engine, Store}; -/// Errors `run_module` can fail with. `reqwest::Error` is forwarded -/// transparently — it already carries the URL it failed on. wasmtime's -/// `Error` (an alias for `anyhow::Error` upstream) doesn't nest cleanly -/// through `std::error::Error`, so the `From` impl flattens it to its -/// formatted chain via `{err:#}`. +/// Errors `run_module` can fail with. The typed REST client returns +/// `et_rest_client::Error<()>`; we wrap that via a Display impl since the +/// `Error` type doesn't itself implement `std::error::Error`. #[derive(Debug, Error)] pub enum RunnerError { #[error("could not derive HTTP base from WS_SERVER_URL={ws_url}")] InvalidWsUrl { ws_url: String }, - #[error(transparent)] - Http(#[from] reqwest::Error), + #[error("ws-server REST call failed: {0}")] + Rest(String), + + #[error("module {module} package.json invalid JSON: {error}")] + PackageJsonInvalid { module: String, error: String }, #[error("module {module} package.json missing `main` field")] PackageJsonMissingMain { module: String }, @@ -38,68 +38,23 @@ pub enum RunnerError { Guest(String), } +impl From> for RunnerError { + fn from(err: et_rest_client::Error) -> Self { + RunnerError::Rest(format!("{err}")) + } +} + impl From for RunnerError { fn from(err: wasmtime::Error) -> Self { RunnerError::Wasm(format!("{err:#}")) } } -pub mod bindings { - wasmtime::component::bindgen!({ - path: "wit", - world: "runner", - imports: { default: async }, - exports: { default: async }, - // Map every wasi-webgpu resource to a payload type owned by us so - // resource_table operations work on real wgpu objects rather than - // bindgen-generated marker structs. The types live in - // `host::wasi_webgpu` and are wgpu-backed for the matmul subset. - with: { - "wasi:keyvalue/store.bucket": super::host::wasi_keyvalue::Bucket, - "wasi:webgpu/webgpu.gpu": super::host::wasi_webgpu::Gpu, - "wasi:webgpu/webgpu.gpu-adapter": super::host::wasi_webgpu::GpuAdapter, - "wasi:webgpu/webgpu.gpu-adapter-info": super::host::wasi_webgpu::GpuAdapterInfo, - "wasi:webgpu/webgpu.gpu-supported-features": super::host::wasi_webgpu::GpuSupportedFeatures, - "wasi:webgpu/webgpu.gpu-supported-limits": super::host::wasi_webgpu::GpuSupportedLimits, - "wasi:webgpu/webgpu.gpu-device": super::host::wasi_webgpu::GpuDevice, - "wasi:webgpu/webgpu.gpu-queue": super::host::wasi_webgpu::GpuQueue, - "wasi:webgpu/webgpu.gpu-buffer": super::host::wasi_webgpu::GpuBuffer, - "wasi:webgpu/webgpu.gpu-buffer-usage": super::host::wasi_webgpu::GpuBufferUsage, - "wasi:webgpu/webgpu.gpu-map-mode": super::host::wasi_webgpu::GpuMapMode, - "wasi:webgpu/webgpu.gpu-shader-stage": super::host::wasi_webgpu::GpuShaderStage, - "wasi:webgpu/webgpu.gpu-bind-group-layout": super::host::wasi_webgpu::GpuBindGroupLayout, - "wasi:webgpu/webgpu.gpu-bind-group": super::host::wasi_webgpu::GpuBindGroup, - "wasi:webgpu/webgpu.gpu-pipeline-layout": super::host::wasi_webgpu::GpuPipelineLayout, - "wasi:webgpu/webgpu.gpu-shader-module": super::host::wasi_webgpu::GpuShaderModule, - "wasi:webgpu/webgpu.gpu-compute-pipeline": super::host::wasi_webgpu::GpuComputePipeline, - "wasi:webgpu/webgpu.gpu-command-encoder": super::host::wasi_webgpu::GpuCommandEncoder, - "wasi:webgpu/webgpu.gpu-compute-pass-encoder": super::host::wasi_webgpu::GpuComputePassEncoder, - "wasi:webgpu/webgpu.gpu-command-buffer": super::host::wasi_webgpu::GpuCommandBuffer, - "wasi:webgpu/webgpu.record-option-gpu-size64": super::host::wasi_webgpu::RecordOptionGpuSize64, - "wasi:webgpu/webgpu.record-gpu-pipeline-constant-value": - super::host::wasi_webgpu::RecordGpuPipelineConstantValue, - }, - }); -} - +pub mod bindings; pub mod host; pub use host::HostState; -/// Inject the W3C `traceparent` (and any `tracestate`) for the current span -/// into `req`. Downstream HTTP servers running `tracing-actix-web`'s -/// `TracingLogger` (or any propagator-aware middleware) parent their -/// request span on the value, which is how a single trace id covers both -/// processes. -fn inject_traceparent(req: reqwest::RequestBuilder) -> reqwest::RequestBuilder { - let mut headers = reqwest::header::HeaderMap::new(); - let cx = tracing::Span::current().context(); - opentelemetry::global::get_text_map_propagator(|propagator| { - propagator.inject_context(&cx, &mut HeaderInjector(&mut headers)); - }); - req.headers(headers) -} - /// Convert a `ws://host[:port]/ws` URL to its `http://host[:port]` HTTP base /// (or `wss://` → `https://`). Returns `None` if `ws_url` is not a websocket /// URL. @@ -115,25 +70,34 @@ pub fn derive_http_base(ws_url: &str) -> Option { Some(format!("{scheme}://{host_port}")) } -/// Where to find the .wasm component for a given module. -/// -/// Resolved against `package.json`'s `main` field as served by the ws-server. -async fn resolve_component_url(http_base: &str, module_name: &str) -> Result { - let pkg_url = format!("{http_base}/modules/{module_name}/package.json"); - let pkg: serde_json::Value = inject_traceparent(reqwest::Client::new().get(&pkg_url)) - .send() - .instrument(tracing::info_span!("fetch_package_json", url = %pkg_url)) - .await? - .error_for_status()? - .json() +/// Drain a progenitor `ByteStream` into a `Vec`. +async fn collect_byte_stream(mut stream: et_rest_client::ByteStream) -> Result, RunnerError> { + let mut buf = Vec::new(); + while let Some(chunk) = stream.next().await { + let chunk = chunk.map_err(|e| RunnerError::Rest(format!("stream chunk: {e}")))?; + buf.extend_from_slice(&chunk); + } + Ok(buf) +} + +/// Read the module's `package.json` from the ws-server and extract its `main` +/// field, which names the WASI component binary. +async fn fetch_main_field(client: &et_rest_client::Client, module_name: &str) -> Result { + let response = client + .get_module_file(module_name, "package.json") + .instrument(tracing::info_span!("fetch_package_json", module = module_name)) .await?; - let main = pkg - .get("main") + let bytes = collect_byte_stream(response.into_inner()).await?; + let pkg: serde_json::Value = serde_json::from_slice(&bytes).map_err(|e| RunnerError::PackageJsonInvalid { + module: module_name.to_string(), + error: e.to_string(), + })?; + pkg.get("main") .and_then(|v| v.as_str()) + .map(str::to_string) .ok_or_else(|| RunnerError::PackageJsonMissingMain { module: module_name.to_string(), - })?; - Ok(format!("{http_base}/modules/{module_name}/{main}")) + }) } /// Download, link, and run the WASI component for `module_name`. Returns when @@ -153,15 +117,14 @@ async fn run_module_inner(module_name: &str, ws_url: &str) -> Result<(), RunnerE ws_url: ws_url.to_string(), })?; - let wasm_url = resolve_component_url(&http_base, module_name).await?; - tracing::info!(%wasm_url, "fetching WASI component"); - let wasm_bytes = inject_traceparent(reqwest::Client::new().get(&wasm_url)) - .send() - .instrument(tracing::info_span!("fetch_component", url = %wasm_url)) - .await? - .error_for_status()? - .bytes() + let rest = et_rest_client::Client::new(&http_base); + let main = fetch_main_field(&rest, module_name).await?; + tracing::info!(module = module_name, %main, "fetching WASI component"); + let response = rest + .get_module_file(module_name, &main) + .instrument(tracing::info_span!("fetch_component", module = module_name, file = %main)) .await?; + let wasm_bytes = collect_byte_stream(response.into_inner()).await?; let mut config = Config::new(); config.wasm_component_model(true); diff --git a/services/ws-wasi-runner/wit/deps/wasi-clocks/clocks.wit b/services/ws-wasi-runner/wit/deps/wasi-clocks/clocks.wit deleted file mode 100644 index d638f1a..0000000 --- a/services/ws-wasi-runner/wit/deps/wasi-clocks/clocks.wit +++ /dev/null @@ -1,157 +0,0 @@ -package wasi:clocks@0.2.6; - -/// WASI Monotonic Clock is a clock API intended to let users measure elapsed -/// time. -/// -/// It is intended to be portable at least between Unix-family platforms and -/// Windows. -/// -/// A monotonic clock is a clock which has an unspecified initial value, and -/// successive reads of the clock will produce non-decreasing values. -@since(version = 0.2.0) -interface monotonic-clock { - @since(version = 0.2.0) - use wasi:io/poll@0.2.6.{pollable}; - - /// An instant in time, in nanoseconds. An instant is relative to an - /// unspecified initial value, and can only be compared to instances from - /// the same monotonic-clock. - @since(version = 0.2.0) - type instant = u64; - - /// A duration of time, in nanoseconds. - @since(version = 0.2.0) - type duration = u64; - - /// Read the current value of the clock. - /// - /// The clock is monotonic, therefore calling this function repeatedly will - /// produce a sequence of non-decreasing values. - @since(version = 0.2.0) - now: func() -> instant; - - /// Query the resolution of the clock. Returns the duration of time - /// corresponding to a clock tick. - @since(version = 0.2.0) - resolution: func() -> duration; - - /// Create a `pollable` which will resolve once the specified instant - /// has occurred. - @since(version = 0.2.0) - subscribe-instant: func(when: instant) -> pollable; - - /// Create a `pollable` that will resolve after the specified duration has - /// elapsed from the time this function is invoked. - @since(version = 0.2.0) - subscribe-duration: func(when: duration) -> pollable; -} - -/// WASI Wall Clock is a clock API intended to let users query the current -/// time. The name "wall" makes an analogy to a "clock on the wall", which -/// is not necessarily monotonic as it may be reset. -/// -/// It is intended to be portable at least between Unix-family platforms and -/// Windows. -/// -/// A wall clock is a clock which measures the date and time according to -/// some external reference. -/// -/// External references may be reset, so this clock is not necessarily -/// monotonic, making it unsuitable for measuring elapsed time. -/// -/// It is intended for reporting the current date and time for humans. -@since(version = 0.2.0) -interface wall-clock { - /// A time and date in seconds plus nanoseconds. - @since(version = 0.2.0) - record datetime { - seconds: u64, - nanoseconds: u32, - } - - /// Read the current value of the clock. - /// - /// This clock is not monotonic, therefore calling this function repeatedly - /// will not necessarily produce a sequence of non-decreasing values. - /// - /// The returned timestamps represent the number of seconds since - /// 1970-01-01T00:00:00Z, also known as [POSIX's Seconds Since the Epoch], - /// also known as [Unix Time]. - /// - /// The nanoseconds field of the output is always less than 1000000000. - /// - /// [POSIX's Seconds Since the Epoch]: https://pubs.opengroup.org/onlinepubs/9699919799/xrat/V4_xbd_chap04.html#tag_21_04_16 - /// [Unix Time]: https://en.wikipedia.org/wiki/Unix_time - @since(version = 0.2.0) - now: func() -> datetime; - - /// Query the resolution of the clock. - /// - /// The nanoseconds field of the output is always less than 1000000000. - @since(version = 0.2.0) - resolution: func() -> datetime; -} - -@unstable(feature = clocks-timezone) -interface timezone { - @unstable(feature = clocks-timezone) - use wall-clock.{datetime}; - - /// Information useful for displaying the timezone of a specific `datetime`. - /// - /// This information may vary within a single `timezone` to reflect daylight - /// saving time adjustments. - @unstable(feature = clocks-timezone) - record timezone-display { - /// The number of seconds difference between UTC time and the local - /// time of the timezone. - /// - /// The returned value will always be less than 86400 which is the - /// number of seconds in a day (24*60*60). - /// - /// In implementations that do not expose an actual time zone, this - /// should return 0. - utc-offset: s32, - /// The abbreviated name of the timezone to display to a user. The name - /// `UTC` indicates Coordinated Universal Time. Otherwise, this should - /// reference local standards for the name of the time zone. - /// - /// In implementations that do not expose an actual time zone, this - /// should be the string `UTC`. - /// - /// In time zones that do not have an applicable name, a formatted - /// representation of the UTC offset may be returned, such as `-04:00`. - name: string, - /// Whether daylight saving time is active. - /// - /// In implementations that do not expose an actual time zone, this - /// should return false. - in-daylight-saving-time: bool, - } - - /// Return information needed to display the given `datetime`. This includes - /// the UTC offset, the time zone name, and a flag indicating whether - /// daylight saving time is active. - /// - /// If the timezone cannot be determined for the given `datetime`, return a - /// `timezone-display` for `UTC` with a `utc-offset` of 0 and no daylight - /// saving time. - @unstable(feature = clocks-timezone) - display: func(when: datetime) -> timezone-display; - - /// The same as `display`, but only return the UTC offset. - @unstable(feature = clocks-timezone) - utc-offset: func(when: datetime) -> s32; -} - -@since(version = 0.2.0) -world imports { - @since(version = 0.2.0) - import wasi:io/poll@0.2.6; - @since(version = 0.2.0) - import monotonic-clock; - @since(version = 0.2.0) - import wall-clock; - @unstable(feature = clocks-timezone) - import timezone; -} diff --git a/services/ws-wasi-runner/wit/deps/wasi-io/io.wit b/services/ws-wasi-runner/wit/deps/wasi-io/io.wit deleted file mode 100644 index 08ad78e..0000000 --- a/services/ws-wasi-runner/wit/deps/wasi-io/io.wit +++ /dev/null @@ -1,331 +0,0 @@ -package wasi:io@0.2.6; - -@since(version = 0.2.0) -interface error { - /// A resource which represents some error information. - /// - /// The only method provided by this resource is `to-debug-string`, - /// which provides some human-readable information about the error. - /// - /// In the `wasi:io` package, this resource is returned through the - /// `wasi:io/streams/stream-error` type. - /// - /// To provide more specific error information, other interfaces may - /// offer functions to "downcast" this error into more specific types. For example, - /// errors returned from streams derived from filesystem types can be described using - /// the filesystem's own error-code type. This is done using the function - /// `wasi:filesystem/types/filesystem-error-code`, which takes a `borrow` - /// parameter and returns an `option`. - /// - /// The set of functions which can "downcast" an `error` into a more - /// concrete type is open. - @since(version = 0.2.0) - resource error { - /// Returns a string that is suitable to assist humans in debugging - /// this error. - /// - /// WARNING: The returned string should not be consumed mechanically! - /// It may change across platforms, hosts, or other implementation - /// details. Parsing this string is a major platform-compatibility - /// hazard. - @since(version = 0.2.0) - to-debug-string: func() -> string; - } -} - -/// A poll API intended to let users wait for I/O events on multiple handles -/// at once. -@since(version = 0.2.0) -interface poll { - /// `pollable` represents a single I/O event which may be ready, or not. - @since(version = 0.2.0) - resource pollable { - /// Return the readiness of a pollable. This function never blocks. - /// - /// Returns `true` when the pollable is ready, and `false` otherwise. - @since(version = 0.2.0) - ready: func() -> bool; - /// `block` returns immediately if the pollable is ready, and otherwise - /// blocks until ready. - /// - /// This function is equivalent to calling `poll.poll` on a list - /// containing only this pollable. - @since(version = 0.2.0) - block: func(); - } - - /// Poll for completion on a set of pollables. - /// - /// This function takes a list of pollables, which identify I/O sources of - /// interest, and waits until one or more of the events is ready for I/O. - /// - /// The result `list` contains one or more indices of handles in the - /// argument list that is ready for I/O. - /// - /// This function traps if either: - /// - the list is empty, or: - /// - the list contains more elements than can be indexed with a `u32` value. - /// - /// A timeout can be implemented by adding a pollable from the - /// wasi-clocks API to the list. - /// - /// This function does not return a `result`; polling in itself does not - /// do any I/O so it doesn't fail. If any of the I/O sources identified by - /// the pollables has an error, it is indicated by marking the source as - /// being ready for I/O. - @since(version = 0.2.0) - poll: func(in: list>) -> list; -} - -/// WASI I/O is an I/O abstraction API which is currently focused on providing -/// stream types. -/// -/// In the future, the component model is expected to add built-in stream types; -/// when it does, they are expected to subsume this API. -@since(version = 0.2.0) -interface streams { - @since(version = 0.2.0) - use error.{error}; - @since(version = 0.2.0) - use poll.{pollable}; - - /// An error for input-stream and output-stream operations. - @since(version = 0.2.0) - variant stream-error { - /// The last operation (a write or flush) failed before completion. - /// - /// More information is available in the `error` payload. - /// - /// After this, the stream will be closed. All future operations return - /// `stream-error::closed`. - last-operation-failed(error), - /// The stream is closed: no more input will be accepted by the - /// stream. A closed output-stream will return this error on all - /// future operations. - closed, - } - - /// An input bytestream. - /// - /// `input-stream`s are *non-blocking* to the extent practical on underlying - /// platforms. I/O operations always return promptly; if fewer bytes are - /// promptly available than requested, they return the number of bytes promptly - /// available, which could even be zero. To wait for data to be available, - /// use the `subscribe` function to obtain a `pollable` which can be polled - /// for using `wasi:io/poll`. - @since(version = 0.2.0) - resource input-stream { - /// Perform a non-blocking read from the stream. - /// - /// When the source of a `read` is binary data, the bytes from the source - /// are returned verbatim. When the source of a `read` is known to the - /// implementation to be text, bytes containing the UTF-8 encoding of the - /// text are returned. - /// - /// This function returns a list of bytes containing the read data, - /// when successful. The returned list will contain up to `len` bytes; - /// it may return fewer than requested, but not more. The list is - /// empty when no bytes are available for reading at this time. The - /// pollable given by `subscribe` will be ready when more bytes are - /// available. - /// - /// This function fails with a `stream-error` when the operation - /// encounters an error, giving `last-operation-failed`, or when the - /// stream is closed, giving `closed`. - /// - /// When the caller gives a `len` of 0, it represents a request to - /// read 0 bytes. If the stream is still open, this call should - /// succeed and return an empty list, or otherwise fail with `closed`. - /// - /// The `len` parameter is a `u64`, which could represent a list of u8 which - /// is not possible to allocate in wasm32, or not desirable to allocate as - /// as a return value by the callee. The callee may return a list of bytes - /// less than `len` in size while more bytes are available for reading. - @since(version = 0.2.0) - read: func(len: u64) -> result, stream-error>; - /// Read bytes from a stream, after blocking until at least one byte can - /// be read. Except for blocking, behavior is identical to `read`. - @since(version = 0.2.0) - blocking-read: func(len: u64) -> result, stream-error>; - /// Skip bytes from a stream. Returns number of bytes skipped. - /// - /// Behaves identical to `read`, except instead of returning a list - /// of bytes, returns the number of bytes consumed from the stream. - @since(version = 0.2.0) - skip: func(len: u64) -> result; - /// Skip bytes from a stream, after blocking until at least one byte - /// can be skipped. Except for blocking behavior, identical to `skip`. - @since(version = 0.2.0) - blocking-skip: func(len: u64) -> result; - /// Create a `pollable` which will resolve once either the specified stream - /// has bytes available to read or the other end of the stream has been - /// closed. - /// The created `pollable` is a child resource of the `input-stream`. - /// Implementations may trap if the `input-stream` is dropped before - /// all derived `pollable`s created with this function are dropped. - @since(version = 0.2.0) - subscribe: func() -> pollable; - } - - /// An output bytestream. - /// - /// `output-stream`s are *non-blocking* to the extent practical on - /// underlying platforms. Except where specified otherwise, I/O operations also - /// always return promptly, after the number of bytes that can be written - /// promptly, which could even be zero. To wait for the stream to be ready to - /// accept data, the `subscribe` function to obtain a `pollable` which can be - /// polled for using `wasi:io/poll`. - /// - /// Dropping an `output-stream` while there's still an active write in - /// progress may result in the data being lost. Before dropping the stream, - /// be sure to fully flush your writes. - @since(version = 0.2.0) - resource output-stream { - /// Check readiness for writing. This function never blocks. - /// - /// Returns the number of bytes permitted for the next call to `write`, - /// or an error. Calling `write` with more bytes than this function has - /// permitted will trap. - /// - /// When this function returns 0 bytes, the `subscribe` pollable will - /// become ready when this function will report at least 1 byte, or an - /// error. - @since(version = 0.2.0) - check-write: func() -> result; - /// Perform a write. This function never blocks. - /// - /// When the destination of a `write` is binary data, the bytes from - /// `contents` are written verbatim. When the destination of a `write` is - /// known to the implementation to be text, the bytes of `contents` are - /// transcoded from UTF-8 into the encoding of the destination and then - /// written. - /// - /// Precondition: check-write gave permit of Ok(n) and contents has a - /// length of less than or equal to n. Otherwise, this function will trap. - /// - /// returns Err(closed) without writing if the stream has closed since - /// the last call to check-write provided a permit. - @since(version = 0.2.0) - write: func(contents: list) -> result<_, stream-error>; - /// Perform a write of up to 4096 bytes, and then flush the stream. Block - /// until all of these operations are complete, or an error occurs. - /// - /// This is a convenience wrapper around the use of `check-write`, - /// `subscribe`, `write`, and `flush`, and is implemented with the - /// following pseudo-code: - /// - /// ```text - /// let pollable = this.subscribe(); - /// while !contents.is_empty() { - /// // Wait for the stream to become writable - /// pollable.block(); - /// let Ok(n) = this.check-write(); // eliding error handling - /// let len = min(n, contents.len()); - /// let (chunk, rest) = contents.split_at(len); - /// this.write(chunk ); // eliding error handling - /// contents = rest; - /// } - /// this.flush(); - /// // Wait for completion of `flush` - /// pollable.block(); - /// // Check for any errors that arose during `flush` - /// let _ = this.check-write(); // eliding error handling - /// ``` - @since(version = 0.2.0) - blocking-write-and-flush: func(contents: list) -> result<_, stream-error>; - /// Request to flush buffered output. This function never blocks. - /// - /// This tells the output-stream that the caller intends any buffered - /// output to be flushed. the output which is expected to be flushed - /// is all that has been passed to `write` prior to this call. - /// - /// Upon calling this function, the `output-stream` will not accept any - /// writes (`check-write` will return `ok(0)`) until the flush has - /// completed. The `subscribe` pollable will become ready when the - /// flush has completed and the stream can accept more writes. - @since(version = 0.2.0) - flush: func() -> result<_, stream-error>; - /// Request to flush buffered output, and block until flush completes - /// and stream is ready for writing again. - @since(version = 0.2.0) - blocking-flush: func() -> result<_, stream-error>; - /// Create a `pollable` which will resolve once the output-stream - /// is ready for more writing, or an error has occurred. When this - /// pollable is ready, `check-write` will return `ok(n)` with n>0, or an - /// error. - /// - /// If the stream is closed, this pollable is always ready immediately. - /// - /// The created `pollable` is a child resource of the `output-stream`. - /// Implementations may trap if the `output-stream` is dropped before - /// all derived `pollable`s created with this function are dropped. - @since(version = 0.2.0) - subscribe: func() -> pollable; - /// Write zeroes to a stream. - /// - /// This should be used precisely like `write` with the exact same - /// preconditions (must use check-write first), but instead of - /// passing a list of bytes, you simply pass the number of zero-bytes - /// that should be written. - @since(version = 0.2.0) - write-zeroes: func(len: u64) -> result<_, stream-error>; - /// Perform a write of up to 4096 zeroes, and then flush the stream. - /// Block until all of these operations are complete, or an error - /// occurs. - /// - /// This is a convenience wrapper around the use of `check-write`, - /// `subscribe`, `write-zeroes`, and `flush`, and is implemented with - /// the following pseudo-code: - /// - /// ```text - /// let pollable = this.subscribe(); - /// while num_zeroes != 0 { - /// // Wait for the stream to become writable - /// pollable.block(); - /// let Ok(n) = this.check-write(); // eliding error handling - /// let len = min(n, num_zeroes); - /// this.write-zeroes(len); // eliding error handling - /// num_zeroes -= len; - /// } - /// this.flush(); - /// // Wait for completion of `flush` - /// pollable.block(); - /// // Check for any errors that arose during `flush` - /// let _ = this.check-write(); // eliding error handling - /// ``` - @since(version = 0.2.0) - blocking-write-zeroes-and-flush: func(len: u64) -> result<_, stream-error>; - /// Read from one stream and write to another. - /// - /// The behavior of splice is equivalent to: - /// 1. calling `check-write` on the `output-stream` - /// 2. calling `read` on the `input-stream` with the smaller of the - /// `check-write` permitted length and the `len` provided to `splice` - /// 3. calling `write` on the `output-stream` with that read data. - /// - /// Any error reported by the call to `check-write`, `read`, or - /// `write` ends the splice and reports that error. - /// - /// This function returns the number of bytes transferred; it may be less - /// than `len`. - @since(version = 0.2.0) - splice: func(src: borrow, len: u64) -> result; - /// Read from one stream and write to another, with blocking. - /// - /// This is similar to `splice`, except that it blocks until the - /// `output-stream` is ready for writing, and the `input-stream` - /// is ready for reading, before performing the `splice`. - @since(version = 0.2.0) - blocking-splice: func(src: borrow, len: u64) -> result; - } -} - -@since(version = 0.2.0) -world imports { - @since(version = 0.2.0) - import error; - @since(version = 0.2.0) - import poll; - @since(version = 0.2.0) - import streams; -} diff --git a/services/ws-wasi-runner/wit/deps/wasi-keyvalue/store.wit b/services/ws-wasi-runner/wit/deps/wasi-keyvalue/store.wit deleted file mode 100644 index 840c2aa..0000000 --- a/services/ws-wasi-runner/wit/deps/wasi-keyvalue/store.wit +++ /dev/null @@ -1,65 +0,0 @@ -// wasi:keyvalue (https://github.com/WebAssembly/wasi-keyvalue), 0.2.0-draft. -// Trimmed to the `store` interface (`open`, `bucket.get`, `bucket.set`, -// `bucket.delete`, `bucket.exists`, `bucket.list-keys`) — the atomics and -// batch interfaces from the upstream proposal are not currently wired. -// wasmtime-wasi does not ship a host impl; the runner implements it in -// `src/host/wasi_keyvalue.rs`, mapping bucket identifiers to URL prefixes -// on the ws-server. - -package wasi:keyvalue@0.2.0-draft; - -/// A keyvalue interface that provides simple get/set/delete operations. -interface store { - /// The set of errors which may be raised by functions in this package. - variant error { - /// The host does not recognize the store identifier requested. - no-such-store, - /// The requesting component does not have access to the specified store - /// (which may or may not exist). - access-denied, - /// Some implementation-specific error has occurred (e.g. I/O). - other(string), - } - - /// A bucket is a collection of key-value pairs. Each key-value pair is - /// stored as a discrete record. - resource bucket { - /// Get the value associated with the specified `key`. - /// - /// The value is returned as a list of bytes. The bytes are not - /// interpreted by the host so any serialization/deserialization is the - /// responsibility of the component. - /// - /// Returns `ok(none)` if the key does not exist. - get: func(key: string) -> result>, error>; - - /// Set the value associated with the key in the store. - /// - /// If the key already exists in the store, it is overwritten. - set: func(key: string, value: list) -> result<_, error>; - - /// Delete the key-value pair associated with the key in the store. - /// - /// If the key does not exist in the store, it is a no-op. - delete: func(key: string) -> result<_, error>; - - /// Check if the key exists in the store. - exists: func(key: string) -> result; - - /// Get all the keys in the store with an optional cursor (for - /// pagination). Returns a list of keys. - list-keys: func(cursor: option) -> result; - } - - /// A response to a `list-keys` operation. - record key-response { - /// The list of keys returned by the query. - keys: list, - /// The continuation token to use to fetch the next page of keys. If - /// this is null, then there are no more keys to fetch. - cursor: option, - } - - /// Get the bucket with the specified identifier. - open: func(identifier: string) -> result; -} diff --git a/services/ws-wasi-runner/wit/deps/wasi-logging/logging.wit b/services/ws-wasi-runner/wit/deps/wasi-logging/logging.wit deleted file mode 100644 index b89a18e..0000000 --- a/services/ws-wasi-runner/wit/deps/wasi-logging/logging.wit +++ /dev/null @@ -1,38 +0,0 @@ -// WASI Logging is a proposal for the standardised logging interface in WASI -// Preview 2. The proposal lives at https://github.com/WebAssembly/wasi-logging -// and is consumed by Spin, wasmCloud, and others. wasmtime-wasi does not ship -// a host impl, so the runner implements `logging.Host` itself in -// `src/host/log.rs`, routing levels through `tracing`. - -package wasi:logging@0.1.0-draft; - -/// WASI Logging is a logging API intended to let users emit log messages -/// with simple priority levels and context. -interface logging { - /// A log level, describing a kind of message. - enum level { - /// Describes messages about the values of variables and the flow of - /// control within a program. - trace, - /// Describes messages likely to be of interest to someone debugging a - /// program. - debug, - /// Describes messages likely to be of interest to someone monitoring a - /// program. - info, - /// Describes messages indicating hazardous situations. - warn, - /// Describes messages indicating serious errors. - error, - /// Describes messages indicating fatal errors. - critical, - } - - /// Emit a log message. - /// - /// A log message has a `level` describing what kind of message is being - /// sent, a `context`, which is an uninterpreted string meant to help - /// consumers group similar messages, and a string containing the message - /// text. - log: func(level: level, context: string, message: string); -} diff --git a/services/ws-wasi-runner/wit/deps/wasi-webgpu/imports.wit b/services/ws-wasi-runner/wit/deps/wasi-webgpu/imports.wit deleted file mode 100644 index 774b702..0000000 --- a/services/ws-wasi-runner/wit/deps/wasi-webgpu/imports.wit +++ /dev/null @@ -1,5 +0,0 @@ -package wasi:webgpu@0.0.1; - -world imports { - import webgpu; -} diff --git a/services/ws-wasi-runner/wit/world.wit b/services/ws-wasi-runner/wit/world.wit deleted file mode 100644 index 77c6c05..0000000 --- a/services/ws-wasi-runner/wit/world.wit +++ /dev/null @@ -1,61 +0,0 @@ -package et:ws-wasi@0.1.0; - -/// WebSocket client bound to ws-server. The host owns the socket; the guest -/// drives it through these calls. `recv` returns at most one inbound text -/// message per call, returning `none` after `timeout-ms` if nothing arrived. -interface ws { - type ws-error = string; - - enum state { - connecting, - connected, - closing, - closed, - } - - connect: func() -> result<_, ws-error>; - get-state: func() -> state; - agent-id: func() -> string; - send-event: func(category: string, kind: string, body-json: string) -> result<_, ws-error>; - send-text: func(text: string) -> result<_, ws-error>; - recv: func(timeout-ms: u32) -> result, ws-error>; - disconnect: func(); -} - -/// The export every WASI module must implement: a single async-equivalent -/// entry point. Returning `err` aborts the runner non-zero. -interface entry { - run: func() -> result<_, string>; -} - -/// What the runner's own `wasmtime::component::bindgen!` consumes. Note -/// `wasi:nn/*` and `wasi:clocks/*` + `wasi:io/poll` are *deliberately absent* -/// from this list: the wasi-nn impls come from `wasmtime-wasi-nn`'s own -/// `add_to_linker` (see `src/host/wasi_nn.rs`), and the clocks + io::poll -/// impls come from `wasmtime_wasi::p2::add_to_linker_async`. The trimmed -/// subset of WebAssembly/wasi-gfx (`wasi:webgpu`) lives under -/// `wit/deps/wasi-webgpu/`; host impls in `src/host/wasi_webgpu.rs` are -/// wgpu-backed for the matmul subset and trap on everything else. -world runner { - import wasi:logging/logging@0.1.0-draft; - import wasi:keyvalue/store@0.2.0-draft; - import ws; - import wasi:webgpu/webgpu@0.0.1; - export entry; -} - -/// What guest WASI modules running under `et-ws-wasi-runner` target. -/// Mirrors `runner` and additionally pulls in the standardised WASI Preview 2 -/// clocks + io::poll (wired by `wasmtime_wasi::p2::add_to_linker_async`) and -/// the wasi-nn interfaces (wired through `wasmtime-wasi-nn`). componentize-py -/// generates Python bindings for every import here. -world module { - include runner; - import wasi:clocks/wall-clock@0.2.6; - import wasi:clocks/monotonic-clock@0.2.6; - import wasi:io/poll@0.2.6; - import wasi:nn/tensor@0.2.0-rc-2024-10-28; - import wasi:nn/graph@0.2.0-rc-2024-10-28; - import wasi:nn/inference@0.2.0-rc-2024-10-28; - import wasi:nn/errors@0.2.0-rc-2024-10-28; -} diff --git a/services/ws-wasm-agent/src/lib.rs b/services/ws-wasm-agent/src/lib.rs index 264dffc..739e034 100644 --- a/services/ws-wasm-agent/src/lib.rs +++ b/services/ws-wasm-agent/src/lib.rs @@ -263,9 +263,6 @@ impl WsClient { WsMessage::Connect { .. } => { warn!("Unexpected connect message from server"); } - WsMessage::StoreFile { .. } | WsMessage::FetchFile { .. } => { - warn!("Unexpected file storage request from server"); - } } } // Notify callback if set diff --git a/services/ws/Cargo.toml b/services/ws/Cargo.toml index 2bcb301..a09d764 100644 --- a/services/ws/Cargo.toml +++ b/services/ws/Cargo.toml @@ -8,6 +8,7 @@ repository.workspace = true [dependencies] actix-web = "4" actix-ws = "0.3" +bytes.workspace = true chrono = { version = "0.4", features = ["serde"] } edge-toolkit.workspace = true futures-util = "0.3" diff --git a/services/ws/src/lib.rs b/services/ws/src/lib.rs index 3f5d783..7e9c2ab 100644 --- a/services/ws/src/lib.rs +++ b/services/ws/src/lib.rs @@ -4,6 +4,7 @@ use std::time::{Duration, Instant}; use actix_web::{Error, HttpRequest, HttpResponse, web}; use actix_ws::{AggregatedMessage, AggregatedMessageStream, CloseCode, CloseReason, Session}; +use bytes::Bytes; use chrono::Utc; use edge_toolkit::ws::{ConnectStatus, MessageDeliveryStatus, MessageScope, WsMessage}; use edge_toolkit::ws_server::{AgentRecord, AgentRegistry, PendingDirectMessage}; @@ -19,7 +20,25 @@ use uuid::Uuid; pub const CONNECTION_TIMEOUT: Duration = Duration::from_secs(15); pub const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(1); -pub type AgentSession = UnboundedSender; +/// Outbound envelope written to an agent's websocket session. +/// +/// `Json` is the normal path for protocol messages. `Text` and `Binary` carry +/// payloads the server forwards verbatim — used by the hub-style fallback +/// that broadcasts unrecognised frames to every other connected agent. +#[derive(Debug, Clone)] +pub enum SessionMessage { + Json(WsMessage), + Text(String), + Binary(Bytes), +} + +impl From for SessionMessage { + fn from(value: WsMessage) -> Self { + SessionMessage::Json(value) + } +} + +pub type AgentSession = UnboundedSender; pub type WsAgentRegistry = AgentRegistry; /// Load a registry from disk. Sessions are not persisted, so they are initialised to `None`. @@ -119,6 +138,18 @@ impl Connection { } } + async fn send_text(&mut self, text: String) { + if let Err(err) = self.session.text(text).await { + warn!("Failed to forward text to {}: {:?}", self.current_agent_id(), err); + } + } + + async fn send_binary(&mut self, bytes: Bytes) { + if let Err(err) = self.session.binary(bytes).await { + warn!("Failed to forward binary to {}: {:?}", self.current_agent_id(), err); + } + } + async fn send_status( &mut self, message_id: Option, @@ -183,13 +214,13 @@ impl Connection { "Direct message {} delivered from {} to {}", message_id, from_agent_id, to_agent_id ); - let _ = recipient.send(WsMessage::AgentMessage { + let _ = recipient.send(SessionMessage::Json(WsMessage::AgentMessage { message_id: message_id.clone(), from_agent_id, scope: MessageScope::Direct, server_received_at: pending.server_received_at, message: pending.message, - }); + })); self.send_status( Some(message_id), MessageDeliveryStatus::Delivered, @@ -211,6 +242,34 @@ impl Connection { span.end(); } + /// Hub-style fallback: forward raw text to every connected agent except + /// the sender. Used when a frame doesn't parse as a known `WsMessage`. + fn broadcast_raw_text(&self, from_agent_id: &str, text: &str) { + let recipients = self.registry.connected_sessions(from_agent_id); + info!( + "Broadcasting unrecognised text message from {} to {} agent(s)", + from_agent_id, + recipients.len() + ); + for (_, recipient) in recipients { + let _ = recipient.send(SessionMessage::Text(text.to_string())); + } + } + + /// Hub-style fallback for binary frames — same shape as the text path. + fn broadcast_raw_binary(&self, from_agent_id: &str, bytes: &Bytes) { + let recipients = self.registry.connected_sessions(from_agent_id); + info!( + "Broadcasting unrecognised binary message ({} bytes) from {} to {} agent(s)", + bytes.len(), + from_agent_id, + recipients.len() + ); + for (_, recipient) in recipients { + let _ = recipient.send(SessionMessage::Binary(bytes.clone())); + } + } + /// Returns `false` when the connection should terminate. async fn handle_inbound(&mut self, msg: AggregatedMessage) -> bool { match msg { @@ -221,8 +280,16 @@ impl Connection { AggregatedMessage::Pong(_) => { self.mark_activity(); } - AggregatedMessage::Binary(_) => { + AggregatedMessage::Binary(bytes) => { self.mark_activity(); + if let Some(from_agent_id) = self.assigned_agent_id().map(str::to_string) { + self.broadcast_raw_binary(&from_agent_id, &bytes); + } else { + warn!( + "Dropping binary frame from unassigned client {}: agent must connect first", + self.client_ip + ); + } } AggregatedMessage::Close(reason) => { self.mark_activity(); @@ -321,18 +388,20 @@ impl Connection { let recipients = self.registry.connected_sessions(&from_agent_id); let message_id = Uuid::now_v7().to_string(); let server_received_at = Utc::now().to_rfc3339(); - for (recipient_id, recipient) in &recipients { - info!( - "Broadcast message {} from {} to {}", - message_id, from_agent_id, recipient_id - ); - let _ = recipient.send(WsMessage::AgentMessage { + info!( + "Broadcast message {} from {} to {} agent(s)", + message_id, + from_agent_id, + recipients.len() + ); + for (_, recipient) in &recipients { + let _ = recipient.send(SessionMessage::Json(WsMessage::AgentMessage { message_id: message_id.clone(), from_agent_id: from_agent_id.clone(), scope: MessageScope::Broadcast, server_received_at: server_received_at.clone(), message: message.clone(), - }); + })); } self.send_status( Some(message_id), @@ -362,11 +431,11 @@ impl Connection { ) .await; if let Some(sender) = sender_session { - let _ = sender.send(WsMessage::MessageStatus { + let _ = sender.send(SessionMessage::Json(WsMessage::MessageStatus { message_id: Some(message_id), status: MessageDeliveryStatus::Acknowledged, detail: format!("agent {} acknowledged receipt", recipient_agent_id), - }); + })); } } Err(detail) => { @@ -406,32 +475,6 @@ impl Connection { details ); } - WsMessage::StoreFile { filename } => { - let Some(agent_id) = self.assigned_agent_id().map(str::to_string) else { - self.send_invalid(None, "agent must connect before storing files").await; - span.end(); - return true; - }; - let url = format!("/storage/{}/{}", agent_id, filename); - info!("Agent {} requested storage URL for {}: {}", agent_id, filename, url); - self.send_json(&WsMessage::Response { - message: format!("PUT to {}", url), - }) - .await; - } - WsMessage::FetchFile { agent_id, filename } => { - let url = format!("/storage/{}/{}", agent_id, filename); - info!( - "Agent {} requested fetch URL for {}/{}", - self.current_agent_id(), - agent_id, - filename - ); - self.send_json(&WsMessage::Response { - message: format!("GET from {}", url), - }) - .await; - } WsMessage::ConnectAck { .. } | WsMessage::ListAgentsResponse { .. } | WsMessage::AgentMessage { .. } @@ -444,11 +487,12 @@ impl Connection { ); } } + } else if let Some(from_agent_id) = self.assigned_agent_id().map(str::to_string) { + self.broadcast_raw_text(&from_agent_id, &text); } else { warn!( - "Received unrecognized message from client {}: {}", - self.current_agent_id(), - text + "Dropping unrecognised text from unassigned client {}: agent must connect first", + self.client_ip ); } span.end(); @@ -457,7 +501,7 @@ impl Connection { true } - async fn run(mut self, mut stream: AggregatedMessageStream, mut outbound: UnboundedReceiver) { + async fn run(mut self, mut stream: AggregatedMessageStream, mut outbound: UnboundedReceiver) { let tracer = global::tracer("ws-server"); let mut connect_span = tracer.start("ws.connect"); info!( @@ -489,7 +533,11 @@ impl Connection { } } Some(envelope) = outbound.recv() => { - self.send_json(&envelope).await; + match envelope { + SessionMessage::Json(message) => self.send_json(&message).await, + SessionMessage::Text(text) => self.send_text(text).await, + SessionMessage::Binary(bytes) => self.send_binary(bytes).await, + } } _ = heartbeat.tick() => { let idle_for = Instant::now().saturating_duration_since(self.last_activity); @@ -545,7 +593,7 @@ pub async fn ws_handler( let (response, session, msg_stream) = actix_ws::handle(&req, body)?; let stream = msg_stream.max_frame_size(64 * 1024).aggregate_continuations(); - let (tx, rx) = mpsc::unbounded_channel::(); + let (tx, rx) = mpsc::unbounded_channel::(); let conn = Connection::new(registry.get_ref().clone(), client_ip, session, tx); actix_web::rt::spawn(async move { diff --git a/utilities/cli/src/deployment_types/docker_compose.rs b/utilities/cli/src/deployment_types/docker_compose.rs index bdcf73b..016025e 100644 --- a/utilities/cli/src/deployment_types/docker_compose.rs +++ b/utilities/cli/src/deployment_types/docker_compose.rs @@ -1,14 +1,14 @@ use std::fs; use std::path::Path; -use anyhow::{Context, Result}; +use anyhow::Context; use edge_toolkit::input::ClusterInput; use crate::{ OutputType, absolute_from, cluster_module_names, module_registry, relative_path_from, resolve_module_paths, }; -pub fn generate_docker_compose_deployment(cluster: &ClusterInput, output_dir: &Path) -> Result<()> { +pub fn generate_docker_compose_deployment(cluster: &ClusterInput, output_dir: &Path) -> anyhow::Result<()> { let output_path = output_dir.join(OutputType::DockerCompose.output_file_name()); let workspace_root = std::env::current_dir().with_context(|| "Failed to resolve current working directory for compose services")?; @@ -96,7 +96,7 @@ pub fn generate_docker_compose_deployment(cluster: &ClusterInput, output_dir: &P Ok(()) } -pub fn docker_image_module_paths(module_names: &[String]) -> Result> { +pub fn docker_image_module_paths(module_names: &[String]) -> anyhow::Result> { let project_root = edge_toolkit::config::get_project_root(); let ws_server_dir = project_root.join("services/ws-server"); let mut paths = Vec::with_capacity(module_names.len() + 2); diff --git a/utilities/cli/src/deployment_types/mise.rs b/utilities/cli/src/deployment_types/mise.rs index 4009327..f7403c6 100644 --- a/utilities/cli/src/deployment_types/mise.rs +++ b/utilities/cli/src/deployment_types/mise.rs @@ -1,13 +1,13 @@ use std::fs; use std::path::Path; -use anyhow::{Context, Result}; +use anyhow::Context; use edge_toolkit::input::ClusterInput; use toml::{Table, Value}; use crate::{absolute_from, cluster_module_names, module_registry, relative_path_from, resolve_module_paths}; -pub fn generate_mise_deployment(cluster: &ClusterInput, output_dir: &Path) -> Result<()> { +pub fn generate_mise_deployment(cluster: &ClusterInput, output_dir: &Path) -> anyhow::Result<()> { let output_path = output_dir.join("mise.toml"); let workspace_root = std::env::current_dir().with_context(|| "Failed to resolve current working directory for mise tasks")?; @@ -87,7 +87,7 @@ pub fn generate_mise_deployment(cluster: &ClusterInput, output_dir: &Path) -> Re Ok(()) } -pub fn scenario_module_paths(ws_server_dir: &Path, module_names: &[String]) -> Result> { +pub fn scenario_module_paths(ws_server_dir: &Path, module_names: &[String]) -> anyhow::Result> { let project_root = edge_toolkit::config::get_project_root(); let mut paths = vec![ relative_path_from(ws_server_dir, &project_root.join("services/ws-server/static")) diff --git a/utilities/cli/src/lib.rs b/utilities/cli/src/lib.rs index 23615db..19c15fb 100644 --- a/utilities/cli/src/lib.rs +++ b/utilities/cli/src/lib.rs @@ -3,7 +3,7 @@ use std::ffi::OsString; use std::fs; use std::path::{Component, Path, PathBuf}; -use anyhow::{Context, Result, anyhow}; +use anyhow::{Context, anyhow}; use clap::ValueEnum; use edge_toolkit::input::ClusterInput; use serde::Deserialize; @@ -122,7 +122,7 @@ pub fn generate_deployment( input_file: &Path, output_dir: &Path, output_type: Option, -) -> Result { +) -> anyhow::Result { let cluster = load_cluster_input(input_file)?; let output_type = output_type .map(Ok) @@ -139,7 +139,7 @@ pub fn generate_deployment( )) } -pub fn load_cluster_input(input_file: &Path) -> Result { +pub fn load_cluster_input(input_file: &Path) -> anyhow::Result { let content = fs::read_to_string(input_file).with_context(|| format!("Failed to read input file: {:?}", input_file))?; @@ -149,7 +149,7 @@ pub fn load_cluster_input(input_file: &Path) -> Result { pub fn regenerate_verification( verification_root: &Path, output_type: Option, -) -> Result> { +) -> anyhow::Result> { let scenarios = discover_verification_scenarios(verification_root)?; let mut regenerated = Vec::with_capacity(scenarios.len()); @@ -181,7 +181,7 @@ pub fn regenerate_verification( Ok(regenerated) } -pub fn output_type_from_input(value: &str) -> Result { +pub fn output_type_from_input(value: &str) -> anyhow::Result { if value.eq_ignore_ascii_case("mise") { Ok(OutputType::Mise) } else if matches!(value.to_ascii_lowercase().as_str(), "docker-compose" | "docker_compose") { @@ -202,7 +202,11 @@ fn deployment_summary(cluster_name: String, agent_templates: usize, module_names } } -fn generate_deployment_outputs(cluster: &ClusterInput, output_dir: &Path, output_types: &[OutputType]) -> Result<()> { +fn generate_deployment_outputs( + cluster: &ClusterInput, + output_dir: &Path, + output_types: &[OutputType], +) -> anyhow::Result<()> { if !output_dir.exists() { fs::create_dir_all(output_dir) .with_context(|| format!("Failed to create output directory: {:?}", output_dir))?; @@ -223,7 +227,7 @@ fn generate_deployment_outputs(cluster: &ClusterInput, output_dir: &Path, output Ok(()) } -fn discover_verification_scenarios(verification_root: &Path) -> Result> { +fn discover_verification_scenarios(verification_root: &Path) -> anyhow::Result> { let mut scenarios = Vec::new(); let verification_sets = fs::read_dir(verification_root) .with_context(|| format!("Failed to read verification root directory: {:?}", verification_root))?; @@ -510,7 +514,7 @@ pub fn resolve_module_paths( registry: &BTreeMap, module_names: &[String], path_for: F, -) -> Result> +) -> anyhow::Result> where F: Fn(&ModuleRegistryEntry) -> String, { diff --git a/utilities/cli/src/main.rs b/utilities/cli/src/main.rs index 082c1bc..c8b0e6a 100644 --- a/utilities/cli/src/main.rs +++ b/utilities/cli/src/main.rs @@ -1,6 +1,5 @@ use std::path::PathBuf; -use anyhow::Result; use clap::{Parser, Subcommand}; use et_cli::{OutputType, generate_deployment, generate_module_package_json, regenerate_verification}; @@ -33,7 +32,7 @@ enum Commands { }, } -fn main() -> Result<()> { +fn main() -> anyhow::Result<()> { let cli = Cli::parse(); match &cli.command { diff --git a/utilities/cli/src/module_package_json/mod.rs b/utilities/cli/src/module_package_json/mod.rs index 3f9cb07..307a12e 100644 --- a/utilities/cli/src/module_package_json/mod.rs +++ b/utilities/cli/src/module_package_json/mod.rs @@ -2,7 +2,7 @@ use std::collections::BTreeMap; use std::fs; use std::path::{Path, PathBuf}; -use anyhow::{Context, Result, anyhow}; +use anyhow::{Context, anyhow}; use serde::Deserialize; use serde_json::{Map, Value, json}; @@ -86,7 +86,7 @@ enum MaybeInherited { }, } -pub fn generate_module_package_json(module_dir: &Path) -> Result { +pub fn generate_module_package_json(module_dir: &Path) -> anyhow::Result { let out_path = module_dir.join("pkg/package.json"); let package_json = if module_dir.join("pyproject.toml").is_file() { package_json_from_pyproject(module_dir)? @@ -110,7 +110,7 @@ pub fn generate_module_package_json(module_dir: &Path) -> Result { Ok(out_path) } -fn package_json_from_pyproject(module_dir: &Path) -> Result { +fn package_json_from_pyproject(module_dir: &Path) -> anyhow::Result { let pyproject_path = module_dir.join("pyproject.toml"); let pyproject: Pyproject = read_toml(&pyproject_path)?; let p = &pyproject.project; @@ -147,7 +147,7 @@ fn project_repository(urls: &BTreeMap) -> Option<&str> { .map(String::as_str) } -fn package_json_from_cargo(module_dir: &Path, out_path: &Path) -> Result { +fn package_json_from_cargo(module_dir: &Path, out_path: &Path) -> anyhow::Result { let cargo_toml_path = module_dir.join("Cargo.toml"); let cargo_toml_src = fs::read_to_string(&cargo_toml_path) .with_context(|| format!("Failed to read {}", cargo_toml_path.display()))?; @@ -172,15 +172,15 @@ fn package_json_from_cargo(module_dir: &Path, out_path: &Path) -> Result } let ws_version = workspace.as_ref().and_then(|w| w.version.as_deref()); let ws_repository = workspace.as_ref().and_then(|w| w.repository.as_deref()); - if !pkg.contains_key("version") { - if let Some(version) = resolve_inherited(package.version.as_ref(), ws_version) { - pkg.insert("version".to_string(), json!(version)); - } + if !pkg.contains_key("version") + && let Some(version) = resolve_inherited(package.version.as_ref(), ws_version) + { + pkg.insert("version".to_string(), json!(version)); } - if !pkg.contains_key("repository") { - if let Some(repo) = resolve_inherited(package.repository.as_ref(), ws_repository) { - pkg.insert("repository".to_string(), repository_json(&repo)); - } + if !pkg.contains_key("repository") + && let Some(repo) = resolve_inherited(package.repository.as_ref(), ws_repository) + { + pkg.insert("repository".to_string(), repository_json(&repo)); } let ws_module = package @@ -224,7 +224,7 @@ fn resolve_inherited(direct: Option<&MaybeInherited>, workspace: Option<&str>) - /// Walk parents of `start` looking for a Cargo.toml containing a /// `[workspace]` table; return its `[workspace.package]` if present. -fn find_workspace_package(start: &Path) -> Result> { +fn find_workspace_package(start: &Path) -> anyhow::Result> { for dir in start.ancestors().skip(1) { let cargo = dir.join("Cargo.toml"); if !cargo.is_file() { @@ -291,7 +291,7 @@ fn detect_cargo_kind(cargo_toml_src: &str) -> ModuleKind { /// is derived from `name` by trying both its `_` and `-` variants with the /// extension dictated by `kind` (`.wasm` for WASI, `.js` for browser/Pyodide). /// The resolved file must exist in `pkg_dir`; this errors otherwise. -fn resolve_main(pkg_dir: &Path, name: &str, kind: ModuleKind, main_override: Option<&str>) -> Result { +fn resolve_main(pkg_dir: &Path, name: &str, kind: ModuleKind, main_override: Option<&str>) -> anyhow::Result { if let Some(main) = main_override { if !pkg_dir.join(main).is_file() { return Err(anyhow!("main = {:?} does not exist in {}", main, pkg_dir.display())); @@ -318,7 +318,7 @@ fn resolve_main(pkg_dir: &Path, name: &str, kind: ModuleKind, main_override: Opt )) } -fn read_toml(path: &Path) -> Result +fn read_toml(path: &Path) -> anyhow::Result where T: for<'de> Deserialize<'de>, { @@ -326,7 +326,7 @@ where toml::from_str(&src).with_context(|| format!("Failed to parse {}", path.display())) } -fn read_package_json(path: &Path) -> Result>> { +fn read_package_json(path: &Path) -> anyhow::Result>> { let src = match fs::read_to_string(path) { Ok(src) => src, Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), diff --git a/utilities/int-gen/Cargo.toml b/utilities/int-gen/Cargo.toml new file mode 100644 index 0000000..9cfb649 --- /dev/null +++ b/utilities/int-gen/Cargo.toml @@ -0,0 +1,42 @@ +[package] +name = "et-int-gen" +description = "Internal repo-only code generator emitting artifacts under generated/ from Rust sources of truth" +version = "0.1.0" +edition.workspace = true +license.workspace = true +repository.workspace = true +default-run = "et-int-gen" + +# `int` = "internal" — this crate is for in-repo use only and isn't published +# alongside the public `et-*` libraries. +[[bin]] +name = "et-int-gen" +path = "src/bin/int-gen.rs" + +[dependencies] +asyncapi-rust.workspace = true +clap.workspace = true +edge-toolkit = { workspace = true, features = ["schema-export"] } +et-modules-service = { path = "../../services/modules" } +et-storage-service = { path = "../../services/storage" } +et-ws-server = { path = "../../services/ws-server" } +heck.workspace = true +kdl.workspace = true +openapiv3.workspace = true +pretty_yaml.workspace = true +prettyplease.workspace = true +progenitor.workspace = true +quote.workspace = true +schemars.workspace = true +semver = "1" +serde.workspace = true +serde_json.workspace = true +serde_yaml.workspace = true +syn.workspace = true +thiserror.workspace = true +tree-sitter.workspace = true +tree-sitter-zig.workspace = true +ureq.workspace = true +utoipa.workspace = true +wit-encoder = { workspace = true, features = ["from-parser"] } +wit-parser = "0.249" diff --git a/utilities/int-gen/src/bin/int-gen.rs b/utilities/int-gen/src/bin/int-gen.rs new file mode 100644 index 0000000..e9f1481 --- /dev/null +++ b/utilities/int-gen/src/bin/int-gen.rs @@ -0,0 +1,32 @@ +// See lib.rs — Error inherits ureq::Error's bulk; immaterial for a CLI. +#![allow(clippy::result_large_err)] + +//! CLI entrypoint for `et-int-gen`. All real work lives in the library +//! (`et_int_gen`); this file just parses arguments and dispatches. + +use clap::{Parser, Subcommand}; +use edge_toolkit::config::get_project_root; +use et_int_gen::{generate, wit::upstream}; + +#[derive(Parser)] +#[command(about = "Generate checked-in artifacts under generated/ from in-repo Rust sources of truth")] +struct Cli { + #[command(subcommand)] + command: Option, +} + +#[derive(Subcommand)] +enum Command { + /// Emit the AsyncAPI/OpenAPI YAML, WIT, KDL, and Rust REST client (default). + Generate, + /// Fetch upstream WASI WIT packages into generated/specs/wit/ at pinned versions. + FetchDeps, +} + +fn main() -> Result<(), et_int_gen::Error> { + let cli = Cli::parse(); + match cli.command.unwrap_or(Command::Generate) { + Command::Generate => generate(), + Command::FetchDeps => upstream::run(&get_project_root()), + } +} diff --git a/utilities/int-gen/src/kdl.rs b/utilities/int-gen/src/kdl.rs new file mode 100644 index 0000000..56b9e22 --- /dev/null +++ b/utilities/int-gen/src/kdl.rs @@ -0,0 +1,264 @@ +//! Emit a `dart-typegen`-flavoured KDL document from the `WsMessage` JSON +//! Schema. The KDL goes to `target/int-gen/ws.kdl` (build intermediate); +//! `dart-typegen generate -i target/int-gen/ws.kdl -o ...` consumes it to +//! produce `generated/dart-ws/lib/ws_messages.dart`. +//! +//! Why this layer exists: dart-typegen consumes KDL declaratively (classes, +//! enums, unions with `json-discriminant`), so we only have to bridge from +//! schemars' JSON Schema shape to that vocabulary. The hand-rolled Dart +//! emitter previously lived in `dart.rs`. + +use heck::{ToLowerCamelCase, ToPascalCase}; +use kdl::{KdlDocument, KdlEntry, KdlEntryFormat, KdlIdentifier, KdlNode, KdlValue}; +use schemars::Schema; + +use crate::Error; + +/// `dart-typegen` parses KDL v1 via knus 3.x, which is stricter than the +/// shipping KDL v1/v2 specs: identifier-shaped strings (`String`, `AgentSummary`) +/// must always appear quoted. The kdl crate's auto-formatter happily drops +/// those quotes, so every entry we emit explicitly pins its `value_repr` and +/// flips `autoformat_keep` to prevent that. +fn quoted_string_entry(s: &str) -> KdlEntry { + let mut entry = KdlEntry::new(KdlValue::String(s.into())); + let mut format = KdlEntryFormat::default(); + format.value_repr = format!("\"{s}\""); + format.leading = " ".to_string(); + format.autoformat_keep = true; + entry.set_format(format); + entry +} + +fn quoted_string_prop(key: &str, value: &str) -> KdlEntry { + let mut entry = KdlEntry::new_prop(KdlIdentifier::from(key), KdlValue::String(value.into())); + let mut format = KdlEntryFormat::default(); + format.value_repr = format!("\"{value}\""); + format.leading = " ".to_string(); + format.autoformat_keep = true; + entry.set_format(format); + entry +} + +pub fn render(root_schema: &Schema) -> Result { + let root = root_schema.as_value(); + let mut doc = KdlDocument::new(); + doc.nodes_mut().push(defaults_node()); + + if let Some(defs) = root.get("$defs").and_then(|v| v.as_object()) { + let mut names: Vec<&String> = defs.keys().collect(); + names.sort(); + for name in &names { + let def = &defs[*name]; + if def.get("enum").is_some() { + doc.nodes_mut().push(enum_node(name, def)?); + } + } + for name in &names { + let def = &defs[*name]; + if def.get("enum").is_none() && def.get("type").and_then(|t| t.as_str()) == Some("object") { + doc.nodes_mut().push(class_node(name, def, None)?); + } + } + } + + doc.nodes_mut().push(ws_message_union(root)?); + doc.autoformat(); + // `dart-typegen` parses KDL v1 (via knus); the kdl crate emits v2 syntax + // by default (`#true`/`#null`). Force v1 so booleans and null render as + // bare `true`/`null` tokens that knus understands. + doc.ensure_v1(); + Ok(format!("{doc}")) +} + +/// The `defaults` block tells dart-typegen to emit sealed unions keyed on +/// `"type"` and to convert camelCase Dart field names to snake_case JSON keys +/// — matching how the Rust serde tag/rename_all configuration writes the wire. +fn defaults_node() -> KdlNode { + let mut defaults = KdlNode::new("defaults"); + let mut children = KdlDocument::new(); + + let mut union_defaults = KdlNode::new("union"); + let mut union_children = KdlDocument::new(); + let mut sealed = KdlNode::new("sealed"); + sealed.push(KdlValue::Bool(true)); + union_children.nodes_mut().push(sealed); + let mut discriminant = KdlNode::new("json-discriminant"); + discriminant.push(quoted_string_entry("type")); + union_children.nodes_mut().push(discriminant); + union_defaults.set_children(union_children); + children.nodes_mut().push(union_defaults); + + let mut field_defaults = KdlNode::new("field"); + let mut field_children = KdlDocument::new(); + let mut key_case = KdlNode::new("json-key-case"); + key_case.push(quoted_string_entry("snake")); + field_children.nodes_mut().push(key_case); + field_defaults.set_children(field_children); + children.nodes_mut().push(field_defaults); + + defaults.set_children(children); + defaults +} + +fn enum_node(name: &str, def: &serde_json::Value) -> Result { + let values = def + .get("enum") + .and_then(|v| v.as_array()) + .ok_or(Error::SchemaMalformed("enum def missing `enum` array"))?; + let mut node = KdlNode::new("enum"); + node.push(quoted_string_entry(name)); + let mut children = KdlDocument::new(); + for value in values { + let raw = value + .as_str() + .ok_or_else(|| Error::EnumValueNotString(name.to_string()))?; + let mut variant = KdlNode::new("variant"); + variant.push(quoted_string_entry(raw)); + children.nodes_mut().push(variant); + } + node.set_children(children); + Ok(node) +} + +/// `discriminator` is set when the class belongs to a union — dart-typegen +/// then emits `json-discriminant-value "et-..."` inside the class body. +fn class_node(name: &str, schema: &serde_json::Value, discriminator: Option<&str>) -> Result { + let props = schema.get("properties").and_then(|v| v.as_object()); + let required: std::collections::HashSet<&str> = schema + .get("required") + .and_then(|v| v.as_array()) + .map(|a| a.iter().filter_map(|v| v.as_str()).collect()) + .unwrap_or_default(); + + let mut node = KdlNode::new("class"); + node.push(quoted_string_entry(name)); + + let mut children = KdlDocument::new(); + if let Some(tag) = discriminator { + let mut tag_node = KdlNode::new("json-discriminant-value"); + tag_node.push(quoted_string_entry(tag)); + children.nodes_mut().push(tag_node); + } + if let Some(props) = props { + let mut keys: Vec<&String> = props.keys().filter(|k| k.as_str() != "type").collect(); + keys.sort(); + for key in keys { + let prop_schema = &props[key]; + let optional = !required.contains(key.as_str()); + children.nodes_mut().push(field_node(key, prop_schema, optional)?); + } + } + if !children.is_empty() { + node.set_children(children); + } + Ok(node) +} + +fn field_node(key: &str, schema: &serde_json::Value, optional: bool) -> Result { + let mut node = KdlNode::new("field"); + node.push(quoted_string_entry(&key.to_lower_camel_case())); + let dart_type = dart_type_from(schema, optional)?; + node.push(quoted_string_prop("type", &dart_type)); + if optional || dart_type.ends_with('?') { + let mut children = KdlDocument::new(); + let mut default = KdlNode::new("defaults-to"); + default.push(KdlValue::Null); + children.nodes_mut().push(default); + node.set_children(children); + } + Ok(node) +} + +fn ws_message_union(root: &serde_json::Value) -> Result { + let variants = root + .get("oneOf") + .and_then(|v| v.as_array()) + .ok_or(Error::SchemaMalformed("WsMessage schema missing `oneOf`"))?; + + let mut node = KdlNode::new("union"); + node.push(quoted_string_entry("WsMessage")); + let mut children = KdlDocument::new(); + for variant in variants { + let tag = variant_tag(variant)?; + let class_name = format!("Ws{}", tag.strip_prefix("et-").unwrap_or(&tag).to_pascal_case()); + children.nodes_mut().push(class_node(&class_name, variant, Some(&tag))?); + } + node.set_children(children); + Ok(node) +} + +fn variant_tag(variant: &serde_json::Value) -> Result { + variant + .get("properties") + .and_then(|p| p.get("type")) + .and_then(|t| t.get("const")) + .and_then(|c| c.as_str()) + .map(str::to_string) + .ok_or(Error::SchemaMalformed("variant missing const `type` discriminator")) +} + +/// JSON Schema → Dart type expression understood by `dart-typegen`. Mirrors +/// the matching helper in the WIT emitter, but spells primitives the Dart way +/// (`String`, `int`, …) and uses `List` / `Map` for +/// collection / opaque-JSON shapes. +fn dart_type_from(schema: &serde_json::Value, force_optional: bool) -> Result { + if let Some(reference) = schema.get("$ref").and_then(|v| v.as_str()) { + let name = reference + .rsplit('/') + .next() + .ok_or(Error::SchemaMalformed("malformed $ref"))?; + return Ok(append_q(name, force_optional)); + } + if let Some(any_of) = schema.get("anyOf").and_then(|v| v.as_array()) { + let non_null: Vec<&serde_json::Value> = any_of + .iter() + .filter(|s| s.get("type").and_then(|t| t.as_str()) != Some("null")) + .collect(); + if non_null.len() == 1 { + let inner = dart_type_from(non_null[0], false)?; + return Ok(append_q_force(&inner)); + } + } + if let Some(types) = schema.get("type").and_then(|v| v.as_array()) { + let primary = types + .iter() + .find_map(|t| t.as_str().filter(|t| *t != "null")) + .ok_or(Error::SchemaMalformed("type array had no non-null entry"))?; + let nullable = types.iter().any(|t| t.as_str() == Some("null")); + return Ok(append_q(&primitive(primary, schema)?, force_optional || nullable)); + } + if let Some(t) = schema.get("type").and_then(|v| v.as_str()) { + return Ok(append_q(&primitive(t, schema)?, force_optional)); + } + Ok(append_q("Map", force_optional)) +} + +fn primitive(t: &str, schema: &serde_json::Value) -> Result { + Ok(match t { + "string" => "String".to_string(), + "integer" => "int".to_string(), + "number" => "double".to_string(), + "boolean" => "bool".to_string(), + "array" => { + let items = schema + .get("items") + .ok_or(Error::SchemaMalformed("array schema missing items"))?; + let inner = dart_type_from(items, false)?; + format!("List<{inner}>") + } + "object" => "Map".to_string(), + other => return Err(Error::UnsupportedSchemaType(other.to_string())), + }) +} + +fn append_q(t: &str, optional: bool) -> String { + if optional { append_q_force(t) } else { t.to_string() } +} + +fn append_q_force(t: &str) -> String { + if t.ends_with('?') { + t.to_string() + } else { + format!("{t}?") + } +} diff --git a/utilities/int-gen/src/lib.rs b/utilities/int-gen/src/lib.rs new file mode 100644 index 0000000..03ab6dc --- /dev/null +++ b/utilities/int-gen/src/lib.rs @@ -0,0 +1,297 @@ +// `Error` ends up ~272 bytes because `ureq::Error` is large and we wrap it +// inline via `#[from]`. Boxing it would shave the parent enum down but +// requires a manual `From` impl, since `#[from]` only generates +// `From>`. For a one-shot CLI the size doesn't matter. +#![allow(clippy::result_large_err)] + +//! Internal repo-only generator (`int` = internal). Emits every checked-in +//! artifact under `generated/` from the Rust sources of truth: +//! `edge_toolkit::ws::WsMessage` for the WS protocol, and the +//! `#[utoipa::path]`-annotated handlers in `services/*` for the REST surface. +//! Driven by `mise run gen-specs`; `mise run gen-specs-check` fails if the +//! regenerated tree drifts from what's committed. +//! +//! Outputs (see [`generate`]): +//! - `generated/specs/ws.yaml` AsyncAPI 3.0 description of +//! the WS protocol. +//! - `generated/specs/rest.yaml` OpenAPI 3.0 description of +//! the ws-server's REST surface. +//! - `generated/specs/wit/world.wit`, +//! `generated/specs/wit/deps/et-ws-messages/messages.wit` — the +//! `et:ws-wasi` world and the typed WIT mirror of `WsMessage` consumed +//! by `services/ws-wasi-runner` and every WASI ws-module. +//! - `generated/rust-rest/src/lib.rs` — typed Rust client for the REST +//! surface, produced via `progenitor::Generator` from the OpenAPI doc. +//! - `generated/dart-ws/lib/ws_messages.dart` — plain Dart 3 sealed classes. +//! Pipeline: JSON Schema → KDL (this crate's [`kdl`] module) → +//! `dart-typegen` CLI (driven by `mise run gen-dart-ws`). +//! - `generated/python-ws/et_ws/messages.py` — Pydantic v2 models, written +//! by `datamodel-codegen` (driven by `mise run gen-python-ws`). +//! - `target/int-gen/ws.schema.json`, +//! `target/int-gen/ws.kdl` Build intermediates (not +//! committed) — JSON Schema is the input to `datamodel-codegen`; KDL +//! is the input to `dart-typegen`. +//! +//! Hand-maintained metadata that lives under `generated/` for proximity to +//! the generated code (package descriptions, dependency declarations) is +//! catalogued in `generated/README.md`. Upstream WASI WIT packages under +//! `generated/specs/wit/deps/wasi-*/` are pulled via +//! `mise run fetch-wit-deps`, handled by the companion [`wit::upstream`] +//! module. + +use std::fs; +use std::path::Path; + +use asyncapi_rust::AsyncApi; +use edge_toolkit::config::get_project_root; +use edge_toolkit::ws::WsMessage; +use schemars::schema_for; + +pub mod kdl; +pub mod rest; +pub mod wit; +pub mod zig; + +/// Errors raised by `et-int-gen`. Every external error type that fallible +/// functions can produce is wrapped transparently via `#[from]`, so call +/// sites just use `?`. Domain errors (malformed schemas, missing AsyncAPI +/// nodes, etc.) sit alongside as non-transparent variants with static +/// messages. +#[allow(clippy::large_enum_variant)] +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error(transparent)] + Io(#[from] std::io::Error), + #[error(transparent)] + Json(#[from] serde_json::Error), + #[error(transparent)] + Yaml(#[from] serde_yaml::Error), + #[error(transparent)] + Http(#[from] ureq::Error), + #[error(transparent)] + Semver(#[from] semver::Error), + #[error(transparent)] + Fmt(#[from] std::fmt::Error), + + #[error("AsyncAPI spec missing required node: {0}")] + SpecNodeMissing(&'static str), + #[error("WsMessage JSON Schema malformed: {0}")] + SchemaMalformed(&'static str), + #[error("unsupported JSON Schema `type`: `{0}`")] + UnsupportedSchemaType(String), + #[error("enum value not a string in `{0}`")] + EnumValueNotString(String), + #[error("progenitor codegen: {0}")] + Progenitor(String), + #[error("zig codegen: {0}")] + ZigCodegen(String), +} + +/// AsyncAPI document for the ws-server's single `/ws` hub channel. The +/// `#[asyncapi_messages(WsMessage)]` attribute pulls every `WsMessage` variant +/// into `components.messages` automatically via the `ToAsyncApiMessage` impl +/// on the enum. +#[allow(clippy::duplicated_attributes)] +#[derive(AsyncApi)] +#[asyncapi( + title = "Edge Toolkit WebSocket Protocol", + version = "0.1.0", + description = "Hub-style WebSocket protocol. Generated from edge_toolkit::ws::WsMessage." +)] +#[asyncapi_server( + name = "local", + host = "localhost:8080", + protocol = "ws", + description = "Default ws-server bind address (mise run ws-server)" +)] +#[asyncapi_channel(name = "ws", address = "/ws")] +#[asyncapi_operation(name = "sendWsMessage", action = "send", channel = "ws")] +#[asyncapi_operation(name = "receiveWsMessage", action = "receive", channel = "ws")] +#[asyncapi_messages(WsMessage)] +struct WsApi; + +/// Emit every checked-in artifact under `generated/` from the `WsMessage` +/// definition: AsyncAPI YAML, `et:ws-messages` WIT, Dart client, and the +/// intermediate JSON Schema under `target/int-gen/`. +pub fn generate() -> Result<(), Error> { + let project_root = get_project_root(); + let specs_dir = project_root.join("generated/specs"); + + // asyncapi-rust 0.2 fills every component message with the whole + // `schema_for!(WsMessage)` payload (i.e. the full union), turning 13 + // 30-line messages into 13 220-line ones. We slim it down ourselves: + // hoist `$defs` into `components.schemas` and give each message just + // its matching `oneOf` variant. + let spec = WsApi::asyncapi_spec(); + let mut spec_value = serde_json::to_value(&spec)?; + slim_component_messages(&mut spec_value)?; + // serde_yaml's emitter quotes/indents differently than dprint's + // `pretty_yaml` plugin — pipe the output through `pretty_yaml` (the same + // engine dprint uses) so the committed YAML stays dprint-canonical and + // `dprint check` doesn't drift between regenerations. + let yaml = serde_yaml::to_string(&spec_value)?; + // serde_yaml always emits well-formed YAML, so pretty_yaml's parse step + // can't fail here — the only error variant is a syntax error. + let yaml = pretty_yaml::format_text(&yaml, &pretty_yaml::config::FormatOptions::default()) + .expect("serde_yaml output should always be well-formed"); + write_if_changed(&specs_dir.join("ws.yaml"), &yaml)?; + + // REST OpenAPI doc — emitted from utoipa annotations on actual handlers. + let rest_yaml = rest::render_yaml(); + let rest_yaml = pretty_yaml::format_text(&rest_yaml, &pretty_yaml::config::FormatOptions::default()) + .expect("utoipa output should always be well-formed YAML"); + write_if_changed(&specs_dir.join("rest.yaml"), &rest_yaml)?; + + // Typed Rust client for the REST surface — same `progenitor::Generator` + // engine that the retired `cargo-progenitor` CLI used, but driven + // in-process so the spec and client always reflect the same source. + let rust_client = rest::render_rust_client()?; + write_if_changed(&project_root.join("generated/rust-rest/src/lib.rs"), &rust_client)?; + + // Zig client: openapi2zig generates a fully typed client, et-int-gen + // post-processes it via tree-sitter-zig to swap the native HTTP + // transport for an extern JS-fetch import (browser wasm target). + let rest_json_path = project_root.join("target/int-gen/rest.json"); + write_if_changed(&rest_json_path, &rest::render_json())?; + let raw_zig_path = project_root.join("target/int-gen/raw_et_rest_client.zig"); + let zig_client = zig::render(&rest_json_path, &raw_zig_path)?; + write_if_changed( + &project_root.join("generated/zig-rest/src/et_rest_client.zig"), + &zig_client, + )?; + + // Build intermediates land in target/ — datamodel-codegen reads the JSON + // Schema for Python output, and dart-typegen reads the KDL for Dart. + let schema = schema_for!(WsMessage); + let schema_json = serde_json::to_string_pretty(&schema)?; + let schema_path = project_root.join("target/int-gen/ws.schema.json"); + write_if_changed(&schema_path, &format!("{}\n", schema_json))?; + + let kdl_source = kdl::render(&schema)?; + let kdl_path = project_root.join("target/int-gen/ws.kdl"); + write_if_changed(&kdl_path, &kdl_source)?; + + // The runner and all WASI guest crates point wit-bindgen / componentize-py + // at `generated/specs/wit/` directly; the layout (main world at the top, + // dep packages under `deps/`) follows the canonical wit-deps convention. + let wit_dir = project_root.join("generated/specs/wit"); + write_if_changed( + &wit_dir.join("deps/et-ws-messages/messages.wit"), + &wit::messages::render(&schema)?, + )?; + write_if_changed(&wit_dir.join("world.wit"), &wit::world::render())?; + + Ok(()) +} + +/// Replace each component message's payload with just its variant schema and +/// hoist the shared `$defs` into `components.schemas`. Mutates `spec` in place. +fn slim_component_messages(spec: &mut serde_json::Value) -> Result<(), Error> { + use serde_json::Value; + + // Pluck one variant payload off any message — they're all identical, so + // we use the first to harvest the `oneOf` array and `$defs`. + let components = spec + .get_mut("components") + .and_then(Value::as_object_mut) + .ok_or(Error::SpecNodeMissing("components"))?; + + let messages = components + .get_mut("messages") + .and_then(Value::as_object_mut) + .ok_or(Error::SpecNodeMissing("components.messages"))?; + + let any_payload = messages + .values() + .find_map(|m| m.get("payload").cloned()) + .ok_or(Error::SpecNodeMissing("any message payload"))?; + let one_of = any_payload + .get("oneOf") + .and_then(Value::as_array) + .ok_or(Error::SpecNodeMissing("payload.oneOf"))? + .clone(); + let defs = any_payload + .get("$defs") + .and_then(Value::as_object) + .cloned() + .unwrap_or_default(); + + // Index variants by their `type.const` discriminator so we can match each + // component message name (`et-connect`, …) to its slim schema. + let mut variants_by_tag: std::collections::HashMap = std::collections::HashMap::new(); + for variant in one_of { + let tag = variant + .get("properties") + .and_then(|p| p.get("type")) + .and_then(|t| t.get("const")) + .and_then(Value::as_str) + .map(str::to_string); + let Some(tag) = tag else { + continue; + }; + // Rewrite `$ref: "#/$defs/Foo"` → `"#/components/schemas/Foo"` so the + // hoisted defs land in the AsyncAPI-canonical location. + let mut variant = variant; + rewrite_refs(&mut variant); + variants_by_tag.insert(tag, variant); + } + + for (name, message) in messages.iter_mut() { + if let Some(variant) = variants_by_tag.get(name) + && let Some(obj) = message.as_object_mut() + { + obj.insert("payload".to_string(), variant.clone()); + } + } + + // Hoist `$defs` to `components.schemas`. Rewrite refs inside each def too. + let mut hoisted = serde_json::Map::new(); + for (name, mut value) in defs { + rewrite_refs(&mut value); + hoisted.insert(name, value); + } + if !hoisted.is_empty() { + components.insert("schemas".to_string(), Value::Object(hoisted)); + } + Ok(()) +} + +/// Recursively replace `$ref: "#/$defs/Foo"` with `"#/components/schemas/Foo"`. +fn rewrite_refs(value: &mut serde_json::Value) { + match value { + serde_json::Value::Object(map) => { + if let Some(reference) = map.get_mut("$ref") + && let Some(s) = reference.as_str() + && let Some(rest) = s.strip_prefix("#/$defs/") + { + *reference = serde_json::Value::String(format!("#/components/schemas/{rest}")); + } + for v in map.values_mut() { + rewrite_refs(v); + } + } + serde_json::Value::Array(items) => { + for v in items { + rewrite_refs(v); + } + } + _ => {} + } +} + +/// Write only when the contents differ — keeps `mise run check` quiet on +/// no-op regenerations. +pub(crate) fn write_if_changed(path: &Path, contents: &str) -> Result<(), Error> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + let unchanged = fs::read_to_string(path) + .map(|existing| existing == contents) + .unwrap_or(false); + if unchanged { + return Ok(()); + } + fs::write(path, contents)?; + println!("wrote {}", path.display()); + Ok(()) +} diff --git a/utilities/int-gen/src/rest.rs b/utilities/int-gen/src/rest.rs new file mode 100644 index 0000000..6b4ce9b --- /dev/null +++ b/utilities/int-gen/src/rest.rs @@ -0,0 +1,109 @@ +//! Emit `generated/specs/rest.yaml` and the typed Rust REST client at +//! `generated/rust-rest/src/lib.rs`. Paths and schemas are collected from +//! the actual handlers in `et-ws-server`, `et-storage-service`, and +//! `et-modules-service` via `utoipa`; the client is generated from the +//! resulting OpenAPI document via `progenitor::Generator`. Driving both +//! steps from one Rust call keeps the spec and the client guaranteed in +//! sync — no external CLI hop. + +use utoipa::OpenApi; + +use crate::Error; + +#[derive(OpenApi)] +#[openapi( + info( + title = "Edge Toolkit REST API", + version = "0.1.0", + description = "ws-server HTTP surface: health probe, module discovery, module assets, per-agent storage." + ), + servers( + (url = "http://localhost:8080", description = "Default ws-server bind address") + ), + paths( + et_ws_server::health, + et_modules_service::list_modules_handler, + et_modules_service::get_module_file, + et_storage_service::get_file, + et_storage_service::put_file::, + ), + components(schemas(et_ws_server::HealthResponse)) +)] +struct ApiDoc; + +/// Build the `openapiv3::OpenAPI` value once: both `rest.yaml` and the +/// progenitor-generated client are derived from this. utoipa unconditionally +/// emits `openapi: 3.1.0` and `license.identifier`, but progenitor 0.14 +/// only accepts 3.0.x and rejects the `identifier` field — downgrade those +/// before serializing. +fn build_spec() -> openapiv3::OpenAPI { + let mut doc = ApiDoc::openapi(); + doc.info.license = None; + let mut value = serde_json::to_value(&doc).expect("OpenApi is always JSON-serializable"); + if let Some(obj) = value.as_object_mut() { + obj.insert("openapi".into(), serde_json::Value::String("3.0.3".into())); + } + serde_json::from_value(value).expect("downgraded OpenApi is always openapiv3::OpenAPI-shaped") +} + +/// Serialize the OpenAPI document as YAML for `generated/specs/rest.yaml`. +pub fn render_yaml() -> String { + serde_yaml::to_string(&build_spec()).expect("openapiv3::OpenAPI is always YAML-serializable") +} + +/// Serialize the OpenAPI document as JSON. Build intermediate consumed by +/// `openapi2zig` (which doesn't accept YAML in v0.2.0). +pub fn render_json() -> String { + serde_json::to_string_pretty(&build_spec()).expect("openapiv3::OpenAPI is always JSON-serializable") +} + +/// Generate the Rust REST client (`generated/rust-rest/src/lib.rs`) from the +/// same OpenAPI document via `progenitor::Generator`. Same engine the +/// retired `cargo-progenitor` CLI used, just driven in-process so the only +/// install target is the workspace itself. +/// +/// The async pre-hook injects the W3C `traceparent` for the current tracing +/// span into every outgoing request, so the runner's span chain extends into +/// the server's `tracing-actix-web` request span end-to-end — distributed +/// tracing works without each call site repeating the boilerplate the old +/// `inject_traceparent` helper did. +pub fn render_rust_client() -> Result { + let spec = build_spec(); + + // progenitor splices `(#hook)(&mut request).await` into every generated + // method. We hand it a closure that mutates the request synchronously + // (cheap, no I/O) and then returns a trivially-Ok async block, + // sidestepping the still-unstable `async ||` closures. The OTel + // injection itself is `#[cfg(feature = "tracing")]`-gated so WASM + // consumers (e.g. the browser data1 module) can disable the feature + // and avoid pulling in the opentelemetry/tracing-opentelemetry deps, + // which don't compile on `wasm32-unknown-unknown`. + let trace_hook = quote::quote! { + |request: &mut ::reqwest::Request| { + #[cfg(feature = "tracing")] + { + let cx = <::tracing::Span as ::tracing_opentelemetry::OpenTelemetrySpanExt>::context( + &::tracing::Span::current(), + ); + ::opentelemetry::global::get_text_map_propagator(|propagator| { + propagator.inject_context( + &cx, + &mut ::opentelemetry_http::HeaderInjector(request.headers_mut()), + ); + }); + } + #[cfg(not(feature = "tracing"))] + let _ = request; + async { Ok::<(), ::std::convert::Infallible>(()) } + } + }; + + let mut settings = progenitor::GenerationSettings::default(); + settings.with_pre_hook_async(trace_hook); + let mut generator = progenitor::Generator::new(&settings); + let tokens = generator + .generate_tokens(&spec) + .map_err(|e| Error::Progenitor(e.to_string()))?; + let ast = syn::parse2(tokens).expect("progenitor always emits valid Rust"); + Ok(prettyplease::unparse(&ast)) +} diff --git a/utilities/int-gen/src/wit/messages.rs b/utilities/int-gen/src/wit/messages.rs new file mode 100644 index 0000000..f8e49ab --- /dev/null +++ b/utilities/int-gen/src/wit/messages.rs @@ -0,0 +1,255 @@ +//! Translates a `schemars` JSON Schema for `WsMessage` into the +//! `et:ws-messages@0.1.0` WIT package. Built with `wit-encoder` so the +//! output format is canonical and the construction is type-checked — +//! we never produce manual `writeln!` lines. +//! +//! Mapping rules: +//! * Variant rename `et-foo-bar` → variant case `foo-bar`. The `et-` +//! prefix is dropped because the WIT package namespace (`et:`) +//! already carries it. +//! * `serde_json::Value` fields → `string` (the host serializes the +//! opaque JSON when shipping to/from the guest). +//! * `Option` → `option`. `Vec` → `list`. `String` → +//! `string`. Integers → `s64` (the wire format never narrows). +//! * `#[serde(rename_all = "snake_case")]` enums map directly to WIT +//! `enum` with kebab-case case names. + +use std::collections::HashSet; + +use heck::ToKebabCase; +use schemars::Schema; +use wit_encoder::{EnumCase, Field, Ident, Interface, Package, PackageName, Type, TypeDef, VariantCase}; + +use crate::Error; + +/// Wire identifiers (`WsConnectAck`, `agent_id`, `et-connect-ack`, …) → +/// canonical WIT kebab-case (`ws-connect-ack`, `agent-id`, `connect-ack`). +/// The `et-` prefix is dropped because the `et:ws-messages` WIT package +/// namespace already carries it. +fn to_kebab(input: &str) -> String { + input.strip_prefix("et-").unwrap_or(input).to_kebab_case() +} + +type EnumSet = HashSet; + +pub fn render(root_schema: &Schema) -> Result { + let root = root_schema.as_value(); + let mut interface = Interface::new("messages"); + interface.set_docs(Some( + "Typed WS protocol messages — each `ws-message` case maps 1:1 to a Rust `WsMessage` variant on the wire.", + )); + + let enums = collect_enum_names(root); + emit_enum_defs(root, &mut interface)?; + emit_record_defs(root, &mut interface, &enums)?; + emit_variant_payloads(root, &mut interface, &enums)?; + emit_top_level_variant(root, &mut interface)?; + + let mut package = Package::new(PackageName::new( + "et", + "ws-messages", + Some(semver::Version::parse("0.1.0").expect("valid semver")), + )); + package.interface(interface); + + Ok(package.to_string()) +} + +fn collect_enum_names(root: &serde_json::Value) -> EnumSet { + root.get("$defs") + .and_then(|v| v.as_object()) + .map(|defs| { + defs.iter() + .filter(|(_, def)| def.get("enum").is_some()) + .map(|(name, _)| name.clone()) + .collect() + }) + .unwrap_or_default() +} + +fn emit_enum_defs(root: &serde_json::Value, interface: &mut Interface) -> Result<(), Error> { + let Some(defs) = root.get("$defs").and_then(|v| v.as_object()) else { + return Ok(()); + }; + let mut names: Vec<&String> = defs.keys().collect(); + names.sort(); + for name in names { + let def = &defs[name]; + let Some(values) = def.get("enum").and_then(|v| v.as_array()) else { + continue; + }; + let cases: Vec = values + .iter() + .map(|v| { + v.as_str() + .ok_or_else(|| Error::EnumValueNotString(name.clone())) + .map(|raw| EnumCase::new(to_kebab(raw))) + }) + .collect::>()?; + interface.type_def(TypeDef::enum_(to_kebab(name), cases)); + } + Ok(()) +} + +fn emit_record_defs(root: &serde_json::Value, interface: &mut Interface, enums: &EnumSet) -> Result<(), Error> { + let Some(defs) = root.get("$defs").and_then(|v| v.as_object()) else { + return Ok(()); + }; + let mut names: Vec<&String> = defs.keys().collect(); + names.sort(); + for name in names { + let def = &defs[name]; + if def.get("enum").is_some() || def.get("type").and_then(|t| t.as_str()) != Some("object") { + continue; + } + interface.type_def(build_record(name, def, enums, false)?); + } + Ok(()) +} + +fn emit_variant_payloads(root: &serde_json::Value, interface: &mut Interface, enums: &EnumSet) -> Result<(), Error> { + let variants = root + .get("oneOf") + .and_then(|v| v.as_array()) + .ok_or(Error::SchemaMalformed("WsMessage schema missing `oneOf`"))?; + for variant in variants { + if !variant_has_payload(variant) { + continue; + } + let tag = variant_tag(variant)?; + let record_name = format!("{}-payload", to_kebab(&tag)); + interface.type_def(build_record(&record_name, variant, enums, true)?); + } + Ok(()) +} + +fn emit_top_level_variant(root: &serde_json::Value, interface: &mut Interface) -> Result<(), Error> { + let variants = root + .get("oneOf") + .and_then(|v| v.as_array()) + .ok_or(Error::SchemaMalformed("WsMessage schema missing `oneOf`"))?; + let cases: Vec = variants + .iter() + .map(|variant| { + let tag = variant_tag(variant)?; + let case_name: Ident = to_kebab(&tag).into(); + if variant_has_payload(variant) { + let payload_name: Ident = format!("{}-payload", to_kebab(&tag)).into(); + Ok(VariantCase::value(case_name, Type::Named(payload_name))) + } else { + Ok(VariantCase::empty(case_name)) + } + }) + .collect::>()?; + let mut variant_def = TypeDef::variant("ws-message", cases); + variant_def.set_docs(Some("Tagged union covering every wire-format WS message.")); + interface.type_def(variant_def); + Ok(()) +} + +fn build_record( + name: &str, + schema: &serde_json::Value, + enums: &EnumSet, + skip_type_discriminator: bool, +) -> Result { + let props = schema.get("properties").and_then(|v| v.as_object()); + let required: HashSet<&str> = schema + .get("required") + .and_then(|v| v.as_array()) + .map(|a| a.iter().filter_map(|v| v.as_str()).collect()) + .unwrap_or_default(); + let mut fields: Vec = Vec::new(); + if let Some(props) = props { + let mut keys: Vec<&String> = props + .keys() + .filter(|k| !skip_type_discriminator || k.as_str() != "type") + .collect(); + keys.sort(); + for key in keys { + let prop_schema = &props[key]; + let optional = !required.contains(key.as_str()); + let ty = wit_type_from(prop_schema, optional, enums)?; + fields.push(Field::new(to_kebab(key), ty)); + } + } + Ok(TypeDef::record(to_kebab(name), fields)) +} + +fn variant_tag(variant: &serde_json::Value) -> Result { + variant + .get("properties") + .and_then(|p| p.get("type")) + .and_then(|t| t.get("const")) + .and_then(|c| c.as_str()) + .map(str::to_string) + .ok_or(Error::SchemaMalformed("variant missing const `type` discriminator")) +} + +fn variant_has_payload(variant: &serde_json::Value) -> bool { + variant + .get("properties") + .and_then(|p| p.as_object()) + .map(|p| p.keys().any(|k| k.as_str() != "type")) + .unwrap_or(false) +} + +fn wit_type_from(schema: &serde_json::Value, force_optional: bool, enums: &EnumSet) -> Result { + if let Some(reference) = schema.get("$ref").and_then(|v| v.as_str()) { + let name = reference + .rsplit('/') + .next() + .ok_or(Error::SchemaMalformed("malformed $ref"))?; + let _ = enums; // enums set tracked for future use (e.g. payload typing) + return Ok(wrap_optional(Type::Named(to_kebab(name).into()), force_optional)); + } + if let Some(any_of) = schema.get("anyOf").and_then(|v| v.as_array()) { + let non_null: Vec<&serde_json::Value> = any_of + .iter() + .filter(|s| s.get("type").and_then(|t| t.as_str()) != Some("null")) + .collect(); + if non_null.len() == 1 { + let inner = wit_type_from(non_null[0], false, enums)?; + return Ok(Type::option(inner)); + } + } + if let Some(types) = schema.get("type").and_then(|v| v.as_array()) { + let primary = types + .iter() + .find_map(|t| t.as_str().filter(|t| *t != "null")) + .ok_or(Error::SchemaMalformed("type array had no non-null entry"))?; + let nullable = types.iter().any(|t| t.as_str() == Some("null")); + let base = primitive(primary, schema, enums)?; + return Ok(wrap_optional(base, force_optional || nullable)); + } + if let Some(t) = schema.get("type").and_then(|v| v.as_str()) { + return Ok(wrap_optional(primitive(t, schema, enums)?, force_optional)); + } + // `serde_json::Value` fields hit this branch via the `any_json_schema` + // hook — no `type` keyword, just a description. Ship them as opaque + // JSON strings so the host can round-trip arbitrary payloads. + Ok(wrap_optional(Type::String, force_optional)) +} + +fn primitive(t: &str, schema: &serde_json::Value, enums: &EnumSet) -> Result { + Ok(match t { + "string" => Type::String, + "integer" => Type::S64, + "number" => Type::F64, + "boolean" => Type::Bool, + "array" => { + let items = schema + .get("items") + .ok_or(Error::SchemaMalformed("array schema missing items"))?; + let inner = wit_type_from(items, false, enums)?; + Type::list(inner) + } + // serde_json::Value-shaped opaque object → JSON string. + "object" => Type::String, + other => return Err(Error::UnsupportedSchemaType(other.to_string())), + }) +} + +fn wrap_optional(t: Type, optional: bool) -> Type { + if optional { Type::option(t) } else { t } +} diff --git a/utilities/int-gen/src/wit/mod.rs b/utilities/int-gen/src/wit/mod.rs new file mode 100644 index 0000000..7b7da61 --- /dev/null +++ b/utilities/int-gen/src/wit/mod.rs @@ -0,0 +1,14 @@ +//! Everything that produces or fetches a `.wit` file. +//! +//! * [`messages`] — emits `generated/specs/wit/deps/et-ws-messages/messages.wit` +//! from the schemars JSON Schema for `WsMessage`. +//! * [`world`] — emits `generated/specs/wit/world.wit` (the host-facing +//! `et:ws-wasi@0.1.0` package with the `ws`/`entry` interfaces and the +//! `runner`/`module` worlds). +//! * [`upstream`] — pulls upstream WASI WIT packages into +//! `generated/specs/wit/deps//` at pinned tags/SHAs, and emits the +//! hand-trimmed `wasi-webgpu` files (vendored in `src/wit/wasi-webgpu/`). + +pub mod messages; +pub mod upstream; +pub mod world; diff --git a/utilities/int-gen/src/wit/upstream.rs b/utilities/int-gen/src/wit/upstream.rs new file mode 100644 index 0000000..21c2da7 --- /dev/null +++ b/utilities/int-gen/src/wit/upstream.rs @@ -0,0 +1,416 @@ +//! Provenance for every WIT package under `generated/specs/wit/deps/` that +//! isn't generated from `WsMessage`. +//! +//! Five WASI packages (`wasi-clocks`, `wasi-io`, `wasi-keyvalue`, +//! `wasi-logging`, `wasi-nn`) are fetched verbatim from upstream +//! `WebAssembly/` at pinned tags or commit SHAs. +//! +//! `wasi-webgpu` is fetched from upstream `WebAssembly/wasi-gfx`, parsed +//! with `wit-parser`, and post-processed by [`strip_webgpu`] using the +//! allowlist [`WEBGPU_KEEP_NAMES`]: only those 72 top-level items survive, +//! and methods / fields / variant cases referencing other upstream names +//! are removed from inside them. The output is the compute-only subset the +//! wgpu-backed host impl in +//! `services/ws-wasi-runner/src/host/wasi_webgpu.rs` actually supports. +//! +//! `mise run fetch-wit-deps` re-runs everything; `gen-specs-check` flags +//! drift. + +use std::collections::HashSet; +use std::fs; +use std::path::Path; + +use wit_encoder::{Interface, InterfaceItem, PackageItem, ResourceFuncKind, Type, TypeDefKind}; + +use crate::Error; + +/// One file within an upstream package. +struct File { + name: &'static str, +} + +/// An upstream WASI WIT package pinned to a tag or commit SHA. +struct UpstreamPackage { + /// Directory name under `generated/specs/wit/deps/`. + local_dir: &'static str, + /// `WebAssembly/` (always under the WebAssembly org). + repo: &'static str, + /// Tag (e.g. `v0.2.6`) or commit SHA. wasi-logging has no releases, so + /// it pins by SHA; everything else by release tag. + git_ref: &'static str, + files: &'static [File], +} + +const PACKAGES: &[UpstreamPackage] = &[ + UpstreamPackage { + local_dir: "wasi-clocks", + repo: "wasi-clocks", + git_ref: "v0.2.6", + files: &[ + File { + name: "monotonic-clock.wit", + }, + File { name: "timezone.wit" }, + File { name: "wall-clock.wit" }, + File { name: "world.wit" }, + ], + }, + UpstreamPackage { + local_dir: "wasi-io", + repo: "wasi-io", + git_ref: "v0.2.6", + files: &[ + File { name: "error.wit" }, + File { name: "poll.wit" }, + File { name: "streams.wit" }, + File { name: "world.wit" }, + ], + }, + UpstreamPackage { + local_dir: "wasi-keyvalue", + repo: "wasi-keyvalue", + git_ref: "v0.2.0-draft", + files: &[ + File { name: "atomic.wit" }, + File { name: "batch.wit" }, + File { name: "store.wit" }, + File { name: "watch.wit" }, + File { name: "world.wit" }, + ], + }, + UpstreamPackage { + local_dir: "wasi-logging", + repo: "wasi-logging", + // No release tags exist; pinned to a known-good commit. + git_ref: "d31c41d0d9eed81aabe02333d0025d42acf3fb75", + files: &[File { name: "logging.wit" }, File { name: "world.wit" }], + }, + UpstreamPackage { + local_dir: "wasi-nn", + repo: "wasi-nn", + git_ref: "0.2.0-rc-2024-10-28", + files: &[File { name: "wasi-nn.wit" }], + }, +]; + +/// Pinned commit of `WebAssembly/wasi-gfx`. wasi-gfx is pre-publication so +/// it has no release tags; we anchor to a specific commit until upstream +/// stabilises. +const WEBGPU_GIT_REF: &str = "6c0d2244daf997cae7aed19cb1c2b38df011a41c"; + +/// Top-level items (`resource`, `record`, `variant`, `enum`, `type`, free +/// `func`) we keep from upstream `webgpu.wit`. Anything else is dropped. +/// 72 items — the compute-only subset the wgpu-backed host actually +/// supports. Anything new upstream adds is excluded by default; opt in by +/// listing it here. +const WEBGPU_KEEP_NAMES: &[&str] = &[ + "create-pipeline-error", + "create-pipeline-error-kind", + "get-gpu", + "get-mapped-range-error", + "get-mapped-range-error-kind", + "gpu", + "gpu-adapter", + "gpu-adapter-info", + "gpu-bind-group", + "gpu-bind-group-descriptor", + "gpu-bind-group-entry", + "gpu-bind-group-layout", + "gpu-bind-group-layout-descriptor", + "gpu-bind-group-layout-entry", + "gpu-binding-resource", + "gpu-buffer", + "gpu-buffer-binding", + "gpu-buffer-binding-layout", + "gpu-buffer-binding-type", + "gpu-buffer-descriptor", + "gpu-buffer-dynamic-offset", + "gpu-buffer-map-state", + "gpu-buffer-usage", + "gpu-buffer-usage-flags", + "gpu-command-buffer", + "gpu-command-buffer-descriptor", + "gpu-command-encoder", + "gpu-command-encoder-descriptor", + "gpu-compute-pass-descriptor", + "gpu-compute-pass-encoder", + "gpu-compute-pipeline", + "gpu-compute-pipeline-descriptor", + "gpu-device", + "gpu-device-descriptor", + "gpu-feature-name", + "gpu-flags-constant", + "gpu-index32", + "gpu-layout-mode", + "gpu-map-mode", + "gpu-map-mode-flags", + "gpu-pipeline-constant-value", + "gpu-pipeline-error-reason", + "gpu-pipeline-layout", + "gpu-pipeline-layout-descriptor", + "gpu-power-preference", + "gpu-programmable-stage", + "gpu-queue", + "gpu-queue-descriptor", + "gpu-request-adapter-options", + "gpu-shader-module", + "gpu-shader-module-compilation-hint", + "gpu-shader-module-descriptor", + "gpu-shader-stage", + "gpu-shader-stage-flags", + "gpu-size32", + "gpu-size32-out", + "gpu-size64", + "gpu-size64-out", + "gpu-supported-features", + "gpu-supported-limits", + "map-async-error", + "map-async-error-kind", + "record-gpu-pipeline-constant-value", + "record-option-gpu-size64", + "request-device-error", + "request-device-error-kind", + "set-bind-group-error", + "set-bind-group-error-kind", + "unmap-error", + "unmap-error-kind", + "write-buffer-error", + "write-buffer-error-kind", +]; + +/// Methods on kept resources whose signatures use only kept types yet still +/// sit outside our compute-only subset. Identified by method name so the +/// post-parse walker drops them by direct name match. +const WEBGPU_DROP_METHODS: &[&str] = &[ + "create-compute-pipeline-async", // sync compute creation is enough + "clear-buffer", // write-buffer-with-copy serves init + "push-debug-group", // debug markers unused + "pop-debug-group", + "insert-debug-marker", + "dispatch-workgroups-indirect", // no indirect buffers + "on-submitted-work-done", // no completion pollables +]; + +/// Minimal stub packages for the cross-package `use` clauses in upstream +/// `webgpu.wit`. wit-parser needs every referenced package available to +/// resolve; we don't actually ship these — the trim drops all methods that +/// reference these types, then we clear the `use` clauses entirely. +const WASI_IO_STUB: &str = concat!( + "package wasi:io@0.2.0;\n", + "interface poll {\n", + " resource pollable {}\n", + "}\n", +); + +const WASI_GRAPHICS_CONTEXT_STUB: &str = concat!( + "package wasi:graphics-context@0.0.1;\n", + "interface graphics-context {\n", + " resource context {}\n", + " resource abstract-buffer {}\n", + "}\n", +); + +/// Refresh every upstream WIT package and write them all under +/// `generated/specs/wit/deps//`. Triggered by `mise run fetch-wit-deps`. +pub fn run(project_root: &Path) -> Result<(), Error> { + let deps_root = project_root.join("generated/specs/wit/deps"); + for pkg in PACKAGES { + fetch_one(&deps_root, pkg)?; + } + fetch_and_trim_webgpu(&deps_root)?; + Ok(()) +} + +fn fetch_one(deps_root: &Path, pkg: &UpstreamPackage) -> Result<(), Error> { + let dest = deps_root.join(pkg.local_dir); + // Wipe the destination first — guards against orphan files left over + // from an old pin that the new pin no longer ships. + if dest.exists() { + fs::remove_dir_all(&dest)?; + } + fs::create_dir_all(&dest)?; + for file in pkg.files { + let url = format!( + "https://raw.githubusercontent.com/WebAssembly/{repo}/{git_ref}/wit/{file}", + repo = pkg.repo, + git_ref = pkg.git_ref, + file = file.name, + ); + let body = ureq::get(&url).call()?.into_string()?; + let target = dest.join(file.name); + fs::write(&target, body)?; + println!("wrote {}", target.display()); + } + Ok(()) +} + +fn fetch_and_trim_webgpu(deps_root: &Path) -> Result<(), Error> { + let url = format!("https://raw.githubusercontent.com/WebAssembly/wasi-gfx/{WEBGPU_GIT_REF}/webgpu/webgpu.wit"); + let raw = ureq::get(&url).call()?.into_string()?; + let stripped = strip_webgpu(&raw)?; + let dest = deps_root.join("wasi-webgpu"); + if dest.exists() { + fs::remove_dir_all(&dest)?; + } + fs::create_dir_all(&dest)?; + let target = dest.join("webgpu.wit"); + fs::write(&target, stripped)?; + println!("wrote {}", target.display()); + Ok(()) +} + +/// Parse the upstream `webgpu.wit` via `wit-parser`, filter the parsed AST +/// down to our compute-only subset, and re-emit using `wit-encoder`. +fn strip_webgpu(raw: &str) -> Result { + // wit-parser returns `anyhow::Error` which doesn't impl `std::error::Error` + // (coherence), so transparent thiserror propagation isn't possible. Panic + // on parse failure — this is an internal CLI; if the upstream WIT can't be + // parsed the bump should be reverted, not error-handled. + let mut resolve = wit_parser::Resolve::default(); + resolve.push_str("wasi-io-stub.wit", WASI_IO_STUB).unwrap(); + resolve + .push_str("wasi-graphics-context-stub.wit", WASI_GRAPHICS_CONTEXT_STUB) + .unwrap(); + resolve.push_str("webgpu.wit", raw).unwrap(); + + let mut webgpu = wit_encoder::packages_from_parsed(&resolve) + .into_iter() + .find(|pkg| pkg.name().namespace() == "wasi" && pkg.name().name().raw_name() == "webgpu") + .expect("upstream webgpu.wit declared a non-`wasi:webgpu` package"); + + let keep: HashSet<&str> = WEBGPU_KEEP_NAMES.iter().copied().collect(); + let drop_methods: HashSet<&str> = WEBGPU_DROP_METHODS.iter().copied().collect(); + + for package_item in webgpu.items_mut() { + if let PackageItem::Interface(iface) = package_item { + mutate_interface(iface, &keep, &drop_methods); + } + } + + Ok(webgpu.to_string()) +} + +fn mutate_interface(iface: &mut Interface, keep: &HashSet<&str>, drop_methods: &HashSet<&str>) { + // Drop all `use` clauses (wit-encoder doesn't expose a clearer for them) + // by rebuilding the interface from scratch. The kept items are moved over + // verbatim, so this is a no-op for any field we care about preserving. + // Cross-package types were either stubs we injected or are referenced + // only by methods we'll filter out below. + let items = std::mem::take(iface.items_mut()); + let mut rebuilt = Interface::new(iface.name().clone()); + rebuilt.set_docs(iface.docs().clone()); + for item in items { + rebuilt.items_mut().push(item); + } + *iface = rebuilt; + + // Drop top-level items not on the keep list. + iface.items_mut().retain(|item| match item { + InterfaceItem::TypeDef(td) => keep.contains(td.name().raw_name()), + InterfaceItem::Function(f) => keep.contains(f.name().raw_name()), + }); + + // For each kept TypeDef, drop methods / fields / variant cases that + // reference a dropped name (either by their own name for methods, or + // transitively through their Type signatures). + for item in iface.items_mut() { + let InterfaceItem::TypeDef(td) = item else { continue }; + match td.kind_mut() { + TypeDefKind::Resource(res) => { + res.funcs_mut() + .retain(|func| !should_drop_resource_func(func, keep, drop_methods)); + } + TypeDefKind::Record(record) => { + record + .fields_mut() + .retain(|field| !type_refs_dropped(field.type_(), keep)); + } + TypeDefKind::Variant(variant) => { + variant.cases_mut().retain(|case| match case.type_() { + Some(ty) => !type_refs_dropped(ty, keep), + None => true, + }); + } + _ => {} // enums, flags, type aliases: no inner refs to check + } + } +} + +fn should_drop_resource_func( + func: &wit_encoder::ResourceFunc, + keep: &HashSet<&str>, + drop_methods: &HashSet<&str>, +) -> bool { + // Drop if the method's own name is on the explicit drop list. + let func_name = match func.kind() { + ResourceFuncKind::Method(name, _, _) | ResourceFuncKind::Static(name, _, _) => Some(name.raw_name()), + ResourceFuncKind::Constructor(_) => None, + }; + if let Some(name) = func_name + && drop_methods.contains(name) + { + return true; + } + + // Drop if any parameter or return type references a non-kept identifier. + for (_pname, pty) in func.params().items() { + if type_refs_dropped(pty, keep) { + return true; + } + } + let ret = match func.kind() { + ResourceFuncKind::Method(_, _, r) | ResourceFuncKind::Static(_, _, r) => r.as_ref(), + ResourceFuncKind::Constructor(r) => r.as_ref(), + }; + if let Some(r) = ret + && type_refs_dropped(r, keep) + { + return true; + } + false +} + +/// `true` if walking `ty` finds any `Type::Named` / `Type::Borrow` whose +/// raw name is not in the keep set. Cross-package stub types +/// (`pollable`, `context`, `abstract-buffer`) and every upstream item we +/// stripped naturally land here because they're absent from +/// `WEBGPU_KEEP_NAMES`. +fn type_refs_dropped(ty: &Type, keep: &HashSet<&str>) -> bool { + let mut refs = HashSet::new(); + collect_type_refs(ty, &mut refs); + refs.iter().any(|r| !keep.contains(r.as_str())) +} + +fn collect_type_refs(ty: &Type, refs: &mut HashSet) { + match ty { + Type::Named(ident) | Type::Borrow(ident) => { + refs.insert(ident.raw_name().to_string()); + } + Type::Option(inner) | Type::List(inner) => collect_type_refs(inner, refs), + Type::FixedLengthList(inner, _) => collect_type_refs(inner, refs), + Type::Result(r) => { + if let Some(ok) = r.get_ok() { + collect_type_refs(ok, refs); + } + if let Some(err) = r.get_err() { + collect_type_refs(err, refs); + } + } + Type::Tuple(tup) => { + for item in tup.types() { + collect_type_refs(item, refs); + } + } + Type::Map(k, v) => { + collect_type_refs(k, refs); + collect_type_refs(v, refs); + } + Type::Future(inner) | Type::Stream(inner) => { + if let Some(t) = inner { + collect_type_refs(t, refs); + } + } + // primitives (Bool, U8/16/32/64, S8/16/32/64, F32/F64, Char, String, ErrorContext) + _ => {} + } +} diff --git a/utilities/int-gen/src/wit/world.rs b/utilities/int-gen/src/wit/world.rs new file mode 100644 index 0000000..f578f9e --- /dev/null +++ b/utilities/int-gen/src/wit/world.rs @@ -0,0 +1,136 @@ +//! Emits the `et:ws-wasi@0.1.0` package — the host-facing API the WASI +//! runner exposes to its guests. Two worlds (`runner`, `module`) and two +//! interfaces (`ws`, `entry`). Everything is statically declared; nothing +//! is derived from `WsMessage`. We use `wit-encoder` so this file mirrors +//! the `et-ws-messages` builder in `src/wit.rs` rather than being a giant +//! raw string. +//! +//! Design notes that previously sat as comments in `wit/world.wit`: +//! +//! * The `ws` interface is **not** a faithful mirror of `WsMessage`. It's a +//! thin host API: connection lifecycle (`connect` / `disconnect` / +//! `get-state` / `agent-id`) plus typed `send` and `recv` calls that +//! carry an `et:ws-messages.ws-message`. Raw out-of-band frames the +//! server might broadcast (the hub fallback added in ws-broadcast-fix) +//! surface to the guest as `recv → none`; we don't yet expose them +//! typed. +//! +//! * `interface entry { run: func() -> result<_, string>; }` is the single +//! export every WASI module under `et-ws-wasi-runner` must implement. +//! Returning `err` aborts the runner non-zero. +//! +//! * The `runner` world is what `et-ws-wasi-runner`'s own +//! `wasmtime::component::bindgen!` consumes. Notably **absent**: +//! - `wasi:nn/*` and `wasi:io/poll` + `wasi:clocks/*`. The wasi-nn +//! implementations come from `wasmtime-wasi-nn`'s own +//! `add_to_linker` (see `src/host/wasi_nn.rs`); clocks + io::poll +//! come from `wasmtime_wasi::p2::add_to_linker_async`. +//! - `wasi:webgpu` _is_ included here because the trimmed subset under +//! `deps/wasi-webgpu/` is wgpu-backed in +//! `src/host/wasi_webgpu.rs`; replace this whole tree once upstream +//! wasi-gfx publishes. +//! +//! * The `module` world is what guest WASI modules target. It mirrors +//! `runner` (`include runner`) and additionally pulls in the +//! standardised WASI Preview 2 clocks + io::poll (wired by +//! `wasmtime_wasi::p2::add_to_linker_async`) and the wasi-nn interfaces +//! (wired through `wasmtime-wasi-nn`). componentize-py generates Python +//! bindings for every import here. + +use wit_encoder::{ + EnumCase, Ident, Include, Interface, Package, PackageName, Params, StandaloneFunc, Type, TypeDef, World, + WorldNamedInterface, +}; + +pub fn render() -> String { + let mut package = Package::new(PackageName::new( + "et", + "ws-wasi", + Some(semver::Version::parse("0.1.0").expect("valid semver")), + )); + package.interface(build_ws_interface()); + package.interface(build_entry_interface()); + package.world(build_runner_world()); + package.world(build_module_world()); + package.to_string() +} + +/// `interface ws` — host-owned websocket: lifecycle calls plus typed +/// send/recv carrying `et:ws-messages.ws-message`. +fn build_ws_interface() -> Interface { + let mut iface = Interface::new("ws"); + iface.use_type("et:ws-messages/messages@0.1.0", "ws-message", None); + + iface.type_def(TypeDef::type_("ws-error", Type::String)); + + iface.type_def(TypeDef::enum_( + "state", + [ + EnumCase::new("connecting"), + EnumCase::new("connected"), + EnumCase::new("closing"), + EnumCase::new("closed"), + ], + )); + + let ws_error = Type::Named("ws-error".into()); + let ws_message = Type::Named("ws-message".into()); + + iface.function(plain_func("connect", &[], Some(Type::result_err(ws_error.clone())))); + iface.function(plain_func("get-state", &[], Some(Type::Named("state".into())))); + iface.function(plain_func("agent-id", &[], Some(Type::String))); + iface.function(plain_func( + "send", + &[("message", ws_message.clone())], + Some(Type::result_err(ws_error.clone())), + )); + iface.function(plain_func( + "recv", + &[("timeout-ms", Type::U32)], + Some(Type::result_both(Type::option(ws_message), ws_error)), + )); + iface.function(plain_func("disconnect", &[], None)); + iface +} + +/// `interface entry { run: func() -> result<_, string>; }` — the single +/// guest export the runner invokes. +fn build_entry_interface() -> Interface { + let mut iface = Interface::new("entry"); + iface.function(plain_func("run", &[], Some(Type::result_err(Type::String)))); + iface +} + +fn build_runner_world() -> World { + let mut world = World::new("runner"); + world.named_interface_import(WorldNamedInterface::new("wasi:logging/logging@0.1.0-draft")); + world.named_interface_import(WorldNamedInterface::new("wasi:keyvalue/store@0.2.0-draft")); + world.named_interface_import(WorldNamedInterface::new("ws")); + world.named_interface_import(WorldNamedInterface::new("wasi:webgpu/webgpu@0.0.1")); + world.named_interface_export(WorldNamedInterface::new("entry")); + world +} + +fn build_module_world() -> World { + let mut world = World::new("module"); + world.include(Include::new("runner")); + world.named_interface_import(WorldNamedInterface::new("wasi:clocks/wall-clock@0.2.6")); + world.named_interface_import(WorldNamedInterface::new("wasi:clocks/monotonic-clock@0.2.6")); + world.named_interface_import(WorldNamedInterface::new("wasi:io/poll@0.2.6")); + world.named_interface_import(WorldNamedInterface::new("wasi:nn/tensor@0.2.0-rc-2024-10-28")); + world.named_interface_import(WorldNamedInterface::new("wasi:nn/graph@0.2.0-rc-2024-10-28")); + world.named_interface_import(WorldNamedInterface::new("wasi:nn/inference@0.2.0-rc-2024-10-28")); + world.named_interface_import(WorldNamedInterface::new("wasi:nn/errors@0.2.0-rc-2024-10-28")); + world +} + +fn plain_func(name: &str, params: &[(&str, Type)], result: Option) -> StandaloneFunc { + let mut func = StandaloneFunc::new(Ident::new(name.to_string()), /*async_=*/ false); + let mut p = Params::empty(); + for (pname, pty) in params { + p.push(Ident::new(pname.to_string()), pty.clone()); + } + func.set_params(p); + func.set_result(result); + func +} diff --git a/utilities/int-gen/src/zig.rs b/utilities/int-gen/src/zig.rs new file mode 100644 index 0000000..c15d240 --- /dev/null +++ b/utilities/int-gen/src/zig.rs @@ -0,0 +1,214 @@ +//! Post-process `openapi2zig`'s output into a `wasm32-unknown-unknown`- +//! compatible Zig REST client. +//! +//! `openapi2zig` generates a fully typed client whose `requestRaw` calls +//! `std.http.Client.fetch` — which compiles for `wasm32-freestanding` but +//! can't actually reach the network from a browser sandbox. We swap the +//! body of `requestRaw` for one that delegates to a single +//! `extern fn js_rest_request(...)` import (host-implemented via `fetch()` +//! and SharedArrayBuffer in the JS shim), and append the extern +//! declaration. Everything else — schemas, `RawResponse`, `ApiResult`, +//! per-operation wrappers, SSE helpers — is left untouched; Zig's lazy +//! evaluation + dead-code elimination shake out the now-unused +//! `std.http.Client`/`std.Io` machinery (verified: the resulting wasm has +//! a single `env.js_rest_request` import and is ~6 KB at `-O ReleaseSmall`). +//! +//! We use `tree-sitter-zig` to find `requestRaw` by name rather than +//! string-matching its body — that way `openapi2zig` version bumps that +//! reshuffle the implementation don't break us. + +use std::fmt::Write; +use std::path::Path; +use std::process::Command; + +use tree_sitter::{Node, Parser}; + +use crate::Error; + +/// Invoke `openapi2zig` against the OpenAPI JSON intermediate, post-process +/// the result, and return the final Zig source. Subprocess errors are +/// flattened into `Error::ZigCodegen` since we don't model them more +/// precisely. +pub fn render(rest_json: &Path, raw_out: &Path) -> Result { + run_openapi2zig(rest_json, raw_out)?; + let raw = std::fs::read_to_string(raw_out)?; + rewrite(&raw) +} + +fn run_openapi2zig(rest_json: &Path, raw_out: &Path) -> Result<(), Error> { + if let Some(parent) = raw_out.parent() { + std::fs::create_dir_all(parent)?; + } + let status = Command::new("openapi2zig") + .args([ + "generate", + "--resource-wrappers", + "none", + "-i", + &rest_json.display().to_string(), + "-o", + &raw_out.display().to_string(), + ]) + .status() + .map_err(|e| Error::ZigCodegen(format!("could not spawn openapi2zig: {e}")))?; + if !status.success() { + return Err(Error::ZigCodegen(format!("openapi2zig exited with {status}"))); + } + Ok(()) +} + +/// Replace `requestRaw`'s body with our extern-backed implementation, fix +/// the JSON-encoding of binary request bodies (openapi2zig blindly applies +/// `std.json.Stringify.value` even when the OpenAPI content-type is +/// `application/octet-stream`), and append the `extern fn js_rest_request` +/// declaration. Everything else passes through verbatim. +fn rewrite(source: &str) -> Result { + let mut parser = Parser::new(); + parser + .set_language(&tree_sitter_zig::LANGUAGE.into()) + .map_err(|e| Error::ZigCodegen(format!("set Zig language: {e}")))?; + let tree = parser + .parse(source, None) + .ok_or_else(|| Error::ZigCodegen("tree-sitter parse returned None".into()))?; + + let request_raw = find_fn(tree.root_node(), source, "requestRaw") + .ok_or_else(|| Error::ZigCodegen("requestRaw function not found in openapi2zig output".into()))?; + let body = request_raw + .child_by_field_name("body") + .ok_or_else(|| Error::ZigCodegen("requestRaw has no body field".into()))?; + + let body_start = body.start_byte(); + let body_end = body.end_byte(); + + let mut out = String::with_capacity(source.len() + 1024); + out.push_str(&source[..body_start]); + write_replacement_body(&mut out)?; + out.push_str(&source[body_end..]); + out = fix_binary_request_body(&out)?; + write_extern_decl(&mut out)?; + Ok(out) +} + +/// openapi2zig generates this block for every operation that has a +/// `requestBody`, regardless of content-type: +/// +/// ```text +/// var str: std.Io.Writer.Allocating = .init(allocator); +/// defer str.deinit(); +/// try std.json.Stringify.value(requestBody, .{ .emit_null_optional_fields = false }, &str.writer); +/// const payload: ?[]const u8 = str.written(); +/// ``` +/// +/// For `application/octet-stream` endpoints (every body in our spec) this +/// corrupts the wire bytes — `"Hello"` ships as `"\"Hello\""`. Replace +/// the block with a direct `requestBody` pass-through. If openapi2zig +/// changes the pattern we fail loudly rather than silently emitting +/// JSON-encoded bodies. +/// +/// Upstream bug: +/// (emission sites: `src/generators/unified/api_generator.zig:611-617` and +/// `:1431-1436`, both unconditional on content-type). Drop this workaround +/// once the issue is fixed and we bump the pinned openapi2zig version. +fn fix_binary_request_body(source: &str) -> Result { + let bad = concat!( + " var str: std.Io.Writer.Allocating = .init(allocator);\n", + " defer str.deinit();\n", + " try std.json.Stringify.value(requestBody, .{ .emit_null_optional_fields = false }, &str.writer);\n", + " const payload: ?[]const u8 = str.written();", + ); + let good = " const payload: ?[]const u8 = requestBody;"; + let count = source.matches(bad).count(); + if count == 0 { + return Err(Error::ZigCodegen( + concat!( + "openapi2zig output no longer contains the std.json.Stringify(requestBody) pattern ", + "- its body-encoding may have changed; verify before relying on the binary fix-up", + ) + .into(), + )); + } + Ok(source.replace(bad, good)) +} + +fn write_replacement_body(out: &mut String) -> Result<(), Error> { + writeln!(out, "{{")?; + writeln!( + out, + " // Replaced by et-int-gen: dispatch via the host JS shim instead of" + )?; + writeln!( + out, + " // `std.http.Client.fetch`, which can't reach the network from" + )?; + writeln!(out, " // browser wasm. The JS side proxies to `fetch()` via")?; + writeln!( + out, + " // SharedArrayBuffer + Atomics so this stays synchronous in Zig." + )?; + writeln!(out, " const allocator = client.allocator;")?; + writeln!(out, " const method_str = @tagName(method);")?; + writeln!(out, " const body_slice = payload orelse \"\";")?; + writeln!(out, " const response_buf = try allocator.alloc(u8, 64 * 1024);")?; + writeln!(out, " const written = js_rest_request(")?; + writeln!(out, " method_str.ptr, method_str.len,")?; + writeln!(out, " url.ptr, url.len,")?; + writeln!(out, " body_slice.ptr, body_slice.len,")?; + writeln!(out, " response_buf.ptr, response_buf.len,")?; + writeln!(out, " );")?; + writeln!(out, " if (written < 0) {{")?; + writeln!(out, " allocator.free(response_buf);")?; + writeln!(out, " return error.RequestFailed;")?; + writeln!(out, " }}")?; + writeln!(out, " const n: usize = @intCast(written);")?; + writeln!(out, " const body = try allocator.realloc(response_buf, n);")?; + writeln!( + out, + " return .{{ .allocator = allocator, .status = .ok, .body = body }};" + )?; + write!(out, "}}")?; + Ok(()) +} + +fn write_extern_decl(out: &mut String) -> Result<(), Error> { + writeln!(out)?; + writeln!( + out, + "/// Host-provided HTTP transport. The JS shim implements this against" + )?; + writeln!( + out, + "/// browser `fetch()` (via SharedArrayBuffer + Atomics so this looks" + )?; + writeln!(out, "/// synchronous to Zig). Returns the number of bytes written to")?; + writeln!(out, "/// `response_buf`, or a negative value on transport failure /")?; + writeln!(out, "/// non-2xx status.")?; + writeln!(out, "extern fn js_rest_request(")?; + writeln!(out, " method_ptr: [*]const u8,")?; + writeln!(out, " method_len: usize,")?; + writeln!(out, " url_ptr: [*]const u8,")?; + writeln!(out, " url_len: usize,")?; + writeln!(out, " body_ptr: [*]const u8,")?; + writeln!(out, " body_len: usize,")?; + writeln!(out, " response_buf: [*]u8,")?; + writeln!(out, " response_max: usize,")?; + writeln!(out, ") i32;")?; + Ok(()) +} + +/// Recursive walk: return the first `function_declaration` whose `name` +/// child matches `wanted`. +fn find_fn<'a>(node: Node<'a>, source: &str, wanted: &str) -> Option> { + if node.kind() == "function_declaration" + && let Some(name) = node.child_by_field_name("name") + && &source[name.start_byte()..name.end_byte()] == wanted + { + return Some(node); + } + let mut cursor = node.walk(); + for child in node.children(&mut cursor) { + if let Some(found) = find_fn(child, source, wanted) { + return Some(found); + } + } + None +} From a9bd3640a9f57090f12b19cf42ce5482ee0f69fe Mon Sep 17 00:00:00 2001 From: John Vandenberg Date: Wed, 20 May 2026 20:17:20 +0800 Subject: [PATCH 02/13] sync dart sdk --- .mise.toml | 6 +- generated/dart-ws/lib/ws_messages.dart | 539 +++++++++++++------------ generated/dart-ws/pubspec.yaml | 6 +- 3 files changed, 282 insertions(+), 269 deletions(-) diff --git a/.mise.toml b/.mise.toml index 89781b2..fab59f4 100644 --- a/.mise.toml +++ b/.mise.toml @@ -9,7 +9,11 @@ cargo-binstall = "latest" "chromedriver" = "146" cmake = "latest" "conda:lld" = "latest" -dart = { version = "latest", url = "https://storage.googleapis.com/dart-archive/channels/stable/release/{{ version }}/sdk/dartsdk-{{ os() }}-{{ arch() }}-release.zip", version_expr = 'fromJSON(body).prefixes | filter({ # matches "^channels/stable/release/(\\d+\\.\\d+\\.\\d+)/$" }) | map({split(#, "/")[3]}) | sortVersions()', version_list_url = "https://storage.googleapis.com/storage/v1/b/dart-archive/o?prefix=channels/stable/release/&delimiter=/" } +# Major-version pin: `dart format`'s output drifts on minor releases, which +# turns dart-fmt-check into a flaky CI gate when `version = "latest"` resolves +# differently on each machine. "3" still drifts within the major (re-resolved +# per `mise install`), but lets us bump intentionally when 4.x ships. +dart = { version = "3", url = "https://storage.googleapis.com/dart-archive/channels/stable/release/{{ version }}/sdk/dartsdk-{{ os() }}-{{ arch() }}-release.zip", version_expr = 'fromJSON(body).prefixes | filter({ # matches "^channels/stable/release/(\\d+\\.\\d+\\.\\d+)/$" }) | map({split(#, "/")[3]}) | sortVersions()', version_list_url = "https://storage.googleapis.com/storage/v1/b/dart-archive/o?prefix=channels/stable/release/&delimiter=/" } dotnet = "latest" dotnet-core = "latest" "dotnet:roslynator.dotnet.cli" = "latest" diff --git a/generated/dart-ws/lib/ws_messages.dart b/generated/dart-ws/lib/ws_messages.dart index 6c14f69..21cf092 100644 --- a/generated/dart-ws/lib/ws_messages.dart +++ b/generated/dart-ws/lib/ws_messages.dart @@ -14,35 +14,35 @@ final class AgentSummary { required String agentId, String? lastKnownIp, required AgentConnectionState state, - }) => - AgentSummaryBuilder( - agentId: agentId, - lastKnownIp: lastKnownIp == null ? null : (lastKnownIp as String), - state: state, - ); + }) => AgentSummaryBuilder( + agentId: agentId, + lastKnownIp: lastKnownIp == null ? null : (lastKnownIp as String), + state: state, + ); AgentSummaryBuilder toBuilder() => AgentSummaryBuilder( - agentId: agentId, - lastKnownIp: lastKnownIp == null ? null : (lastKnownIp as String), - state: state, - ); + agentId: agentId, + lastKnownIp: lastKnownIp == null ? null : (lastKnownIp as String), + state: state, + ); Map toJson() => { - "agent_id": agentId, - "last_known_ip": lastKnownIp, - "state": state.toJson(), - }; + "agent_id": agentId, + "last_known_ip": lastKnownIp, + "state": state.toJson(), + }; factory AgentSummary.fromJson(Map json) => AgentSummary( - agentId: json["agent_id"] as String, - lastKnownIp: json["last_known_ip"] == null - ? null - : json["last_known_ip"] == null - ? null - : json["last_known_ip"] as String, - state: AgentConnectionState.fromJson(json["state"]), - ); + agentId: json["agent_id"] as String, + lastKnownIp: json["last_known_ip"] == null + ? null + : json["last_known_ip"] == null + ? null + : json["last_known_ip"] as String, + state: AgentConnectionState.fromJson(json["state"]), + ); @override - String toString() => "AgentSummary(" + String toString() => + "AgentSummary(" "agentId: $agentId, " "lastKnownIp: $lastKnownIp, " "state: $state" @@ -85,10 +85,10 @@ final class AgentSummaryBuilder { }); AgentSummary build() => AgentSummary( - agentId: agentId, - lastKnownIp: lastKnownIp == null ? null : (lastKnownIp as String), - state: state, - ); + agentId: agentId, + lastKnownIp: lastKnownIp == null ? null : (lastKnownIp as String), + state: state, + ); } sealed class WsMessage { @@ -133,15 +133,16 @@ final class WsConnect extends WsMessage { @override Map toJson() => {"agent_id": agentId, "type": "et-connect"}; factory WsConnect.fromJson(Map json) => WsConnect( - agentId: json["agent_id"] == null - ? null - : json["agent_id"] == null - ? null - : json["agent_id"] as String, - ); + agentId: json["agent_id"] == null + ? null + : json["agent_id"] == null + ? null + : json["agent_id"] as String, + ); @override - String toString() => "WsConnect(" + String toString() => + "WsConnect(" "agentId: $agentId" ")"; @override @@ -181,24 +182,24 @@ final class WsConnectAck extends WsMessage { static WsConnectAckBuilder builder({ required String agentId, required ConnectStatus status, - }) => - WsConnectAckBuilder(agentId: agentId, status: status); + }) => WsConnectAckBuilder(agentId: agentId, status: status); WsConnectAckBuilder toBuilder() => WsConnectAckBuilder(agentId: agentId, status: status); @override Map toJson() => { - "agent_id": agentId, - "status": status.toJson(), - "type": "et-connect-ack", - }; + "agent_id": agentId, + "status": status.toJson(), + "type": "et-connect-ack", + }; factory WsConnectAck.fromJson(Map json) => WsConnectAck( - agentId: json["agent_id"] as String, - status: ConnectStatus.fromJson(json["status"]), - ); + agentId: json["agent_id"] as String, + status: ConnectStatus.fromJson(json["status"]), + ); @override - String toString() => "WsConnectAck(" + String toString() => + "WsConnectAck(" "agentId: $agentId, " "status: $status" ")"; @@ -248,7 +249,8 @@ final class WsAlive extends WsMessage { WsAlive(timestamp: json["timestamp"] as String); @override - String toString() => "WsAlive(" + String toString() => + "WsAlive(" "timestamp: $timestamp" ")"; @override @@ -289,7 +291,8 @@ final class WsListAgents extends WsMessage { factory WsListAgents.fromJson(Map json) => WsListAgents(); @override - String toString() => "WsListAgents(" + String toString() => + "WsListAgents(" ")"; @override bool operator ==(Object other) { @@ -320,19 +323,18 @@ final class WsListAgentsResponse extends WsMessage { static WsListAgentsResponseBuilder builder({ required List agents, - }) => - WsListAgentsResponseBuilder( - agents: agents.map((elem) => elem.toBuilder()).toList(), - ); + }) => WsListAgentsResponseBuilder( + agents: agents.map((elem) => elem.toBuilder()).toList(), + ); WsListAgentsResponseBuilder toBuilder() => WsListAgentsResponseBuilder( - agents: agents.map((elem) => elem.toBuilder()).toList(), - ); + agents: agents.map((elem) => elem.toBuilder()).toList(), + ); @override Map toJson() => { - "agents": agents.map((inner) => inner.toJson()).toList(), - "type": "et-list-agents-response", - }; + "agents": agents.map((inner) => inner.toJson()).toList(), + "type": "et-list-agents-response", + }; factory WsListAgentsResponse.fromJson(Map json) => WsListAgentsResponse( agents: (json["agents"] as List) @@ -343,7 +345,8 @@ final class WsListAgentsResponse extends WsMessage { ); @override - String toString() => "WsListAgentsResponse(" + String toString() => + "WsListAgentsResponse(" "agents: $agents" ")"; @override @@ -385,27 +388,26 @@ final class WsSendAgentMessage extends WsMessage { final String toAgentId; const WsSendAgentMessage({required this.message, required this.toAgentId}) - : super(); + : super(); static WsSendAgentMessageBuilder builder({ required Map message, required String toAgentId, - }) => - WsSendAgentMessageBuilder( - message: message.map((key, value) => MapEntry(key, value)), - toAgentId: toAgentId, - ); + }) => WsSendAgentMessageBuilder( + message: message.map((key, value) => MapEntry(key, value)), + toAgentId: toAgentId, + ); WsSendAgentMessageBuilder toBuilder() => WsSendAgentMessageBuilder( - message: message.map((key, value) => MapEntry(key, value)), - toAgentId: toAgentId, - ); + message: message.map((key, value) => MapEntry(key, value)), + toAgentId: toAgentId, + ); @override Map toJson() => { - "message": message.map((key, value) => MapEntry(key, value)), - "to_agent_id": toAgentId, - "type": "et-send-agent-message", - }; + "message": message.map((key, value) => MapEntry(key, value)), + "to_agent_id": toAgentId, + "type": "et-send-agent-message", + }; factory WsSendAgentMessage.fromJson(Map json) => WsSendAgentMessage( message: (json["message"] as Map).map( @@ -415,7 +417,8 @@ final class WsSendAgentMessage extends WsMessage { ); @override - String toString() => "WsSendAgentMessage(" + String toString() => + "WsSendAgentMessage(" "message: $message, " "toAgentId: $toAgentId" ")"; @@ -443,11 +446,11 @@ final class WsSendAgentMessage extends WsMessage { @override int get hashCode => Object.hashAll([ - Object.hashAll( - message.entries.expand((entry) => [entry.key, entry.value.hashCode]), - ), - toAgentId.hashCode, - ]); + Object.hashAll( + message.entries.expand((entry) => [entry.key, entry.value.hashCode]), + ), + toAgentId.hashCode, + ]); } /// Builder class for [WsSendAgentMessage] @@ -456,12 +459,12 @@ final class WsSendAgentMessageBuilder extends WsMessageBuilder { String toAgentId; WsSendAgentMessageBuilder({required this.message, required this.toAgentId}) - : super(); + : super(); WsSendAgentMessage build() => WsSendAgentMessage( - message: message.map((key, value) => MapEntry(key, value)), - toAgentId: toAgentId, - ); + message: message.map((key, value) => MapEntry(key, value)), + toAgentId: toAgentId, + ); } final class WsBroadcastMessage extends WsMessage { @@ -471,19 +474,18 @@ final class WsBroadcastMessage extends WsMessage { static WsBroadcastMessageBuilder builder({ required Map message, - }) => - WsBroadcastMessageBuilder( - message: message.map((key, value) => MapEntry(key, value)), - ); + }) => WsBroadcastMessageBuilder( + message: message.map((key, value) => MapEntry(key, value)), + ); WsBroadcastMessageBuilder toBuilder() => WsBroadcastMessageBuilder( - message: message.map((key, value) => MapEntry(key, value)), - ); + message: message.map((key, value) => MapEntry(key, value)), + ); @override Map toJson() => { - "message": message.map((key, value) => MapEntry(key, value)), - "type": "et-broadcast-message", - }; + "message": message.map((key, value) => MapEntry(key, value)), + "type": "et-broadcast-message", + }; factory WsBroadcastMessage.fromJson(Map json) => WsBroadcastMessage( message: (json["message"] as Map).map( @@ -492,7 +494,8 @@ final class WsBroadcastMessage extends WsMessage { ); @override - String toString() => "WsBroadcastMessage(" + String toString() => + "WsBroadcastMessage(" "message: $message" ")"; @override @@ -516,10 +519,10 @@ final class WsBroadcastMessage extends WsMessage { @override int get hashCode => Object.hashAll([ - Object.hashAll( - message.entries.expand((entry) => [entry.key, entry.value.hashCode]), - ), - ]); + Object.hashAll( + message.entries.expand((entry) => [entry.key, entry.value.hashCode]), + ), + ]); } /// Builder class for [WsBroadcastMessage] @@ -529,8 +532,8 @@ final class WsBroadcastMessageBuilder extends WsMessageBuilder { WsBroadcastMessageBuilder({required this.message}) : super(); WsBroadcastMessage build() => WsBroadcastMessage( - message: message.map((key, value) => MapEntry(key, value)), - ); + message: message.map((key, value) => MapEntry(key, value)), + ); } final class WsAgentMessage extends WsMessage { @@ -554,43 +557,43 @@ final class WsAgentMessage extends WsMessage { required String messageId, required MessageScope scope, required String serverReceivedAt, - }) => - WsAgentMessageBuilder( - fromAgentId: fromAgentId, - message: message.map((key, value) => MapEntry(key, value)), - messageId: messageId, - scope: scope, - serverReceivedAt: serverReceivedAt, - ); + }) => WsAgentMessageBuilder( + fromAgentId: fromAgentId, + message: message.map((key, value) => MapEntry(key, value)), + messageId: messageId, + scope: scope, + serverReceivedAt: serverReceivedAt, + ); WsAgentMessageBuilder toBuilder() => WsAgentMessageBuilder( - fromAgentId: fromAgentId, - message: message.map((key, value) => MapEntry(key, value)), - messageId: messageId, - scope: scope, - serverReceivedAt: serverReceivedAt, - ); + fromAgentId: fromAgentId, + message: message.map((key, value) => MapEntry(key, value)), + messageId: messageId, + scope: scope, + serverReceivedAt: serverReceivedAt, + ); @override Map toJson() => { - "from_agent_id": fromAgentId, - "message": message.map((key, value) => MapEntry(key, value)), - "message_id": messageId, - "scope": scope.toJson(), - "server_received_at": serverReceivedAt, - "type": "et-agent-message", - }; + "from_agent_id": fromAgentId, + "message": message.map((key, value) => MapEntry(key, value)), + "message_id": messageId, + "scope": scope.toJson(), + "server_received_at": serverReceivedAt, + "type": "et-agent-message", + }; factory WsAgentMessage.fromJson(Map json) => WsAgentMessage( - fromAgentId: json["from_agent_id"] as String, - message: (json["message"] as Map).map( - (key, value) => MapEntry(key as String, value as dynamic), - ), - messageId: json["message_id"] as String, - scope: MessageScope.fromJson(json["scope"]), - serverReceivedAt: json["server_received_at"] as String, - ); + fromAgentId: json["from_agent_id"] as String, + message: (json["message"] as Map).map( + (key, value) => MapEntry(key as String, value as dynamic), + ), + messageId: json["message_id"] as String, + scope: MessageScope.fromJson(json["scope"]), + serverReceivedAt: json["server_received_at"] as String, + ); @override - String toString() => "WsAgentMessage(" + String toString() => + "WsAgentMessage(" "fromAgentId: $fromAgentId, " "message: $message, " "messageId: $messageId, " @@ -630,14 +633,14 @@ final class WsAgentMessage extends WsMessage { @override int get hashCode => Object.hashAll([ - fromAgentId.hashCode, - Object.hashAll( - message.entries.expand((entry) => [entry.key, entry.value.hashCode]), - ), - messageId.hashCode, - scope.hashCode, - serverReceivedAt.hashCode, - ]); + fromAgentId.hashCode, + Object.hashAll( + message.entries.expand((entry) => [entry.key, entry.value.hashCode]), + ), + messageId.hashCode, + scope.hashCode, + serverReceivedAt.hashCode, + ]); } /// Builder class for [WsAgentMessage] @@ -657,12 +660,12 @@ final class WsAgentMessageBuilder extends WsMessageBuilder { }) : super(); WsAgentMessage build() => WsAgentMessage( - fromAgentId: fromAgentId, - message: message.map((key, value) => MapEntry(key, value)), - messageId: messageId, - scope: scope, - serverReceivedAt: serverReceivedAt, - ); + fromAgentId: fromAgentId, + message: message.map((key, value) => MapEntry(key, value)), + messageId: messageId, + scope: scope, + serverReceivedAt: serverReceivedAt, + ); } final class WsMessageAck extends WsMessage { @@ -676,14 +679,15 @@ final class WsMessageAck extends WsMessage { @override Map toJson() => { - "message_id": messageId, - "type": "et-message-ack", - }; + "message_id": messageId, + "type": "et-message-ack", + }; factory WsMessageAck.fromJson(Map json) => WsMessageAck(messageId: json["message_id"] as String); @override - String toString() => "WsMessageAck(" + String toString() => + "WsMessageAck(" "messageId: $messageId" ")"; @override @@ -728,38 +732,38 @@ final class WsMessageStatus extends WsMessage { required String detail, String? messageId, required MessageDeliveryStatus status, - }) => - WsMessageStatusBuilder( - detail: detail, - messageId: messageId == null ? null : (messageId as String), - status: status, - ); + }) => WsMessageStatusBuilder( + detail: detail, + messageId: messageId == null ? null : (messageId as String), + status: status, + ); WsMessageStatusBuilder toBuilder() => WsMessageStatusBuilder( - detail: detail, - messageId: messageId == null ? null : (messageId as String), - status: status, - ); + detail: detail, + messageId: messageId == null ? null : (messageId as String), + status: status, + ); @override Map toJson() => { - "detail": detail, - "message_id": messageId, - "status": status.toJson(), - "type": "et-message-status", - }; + "detail": detail, + "message_id": messageId, + "status": status.toJson(), + "type": "et-message-status", + }; factory WsMessageStatus.fromJson(Map json) => WsMessageStatus( detail: json["detail"] as String, messageId: json["message_id"] == null ? null : json["message_id"] == null - ? null - : json["message_id"] as String, + ? null + : json["message_id"] as String, status: MessageDeliveryStatus.fromJson(json["status"]), ); @override - String toString() => "WsMessageStatus(" + String toString() => + "WsMessageStatus(" "detail: $detail, " "messageId: $messageId, " "status: $status" @@ -802,10 +806,10 @@ final class WsMessageStatusBuilder extends WsMessageBuilder { }) : super(); WsMessageStatus build() => WsMessageStatus( - detail: detail, - messageId: messageId == null ? null : (messageId as String), - status: status, - ); + detail: detail, + messageId: messageId == null ? null : (messageId as String), + status: status, + ); } final class WsInvalid extends WsMessage { @@ -817,33 +821,33 @@ final class WsInvalid extends WsMessage { static WsInvalidBuilder builder({ required String detail, String? messageId, - }) => - WsInvalidBuilder( - detail: detail, - messageId: messageId == null ? null : (messageId as String), - ); + }) => WsInvalidBuilder( + detail: detail, + messageId: messageId == null ? null : (messageId as String), + ); WsInvalidBuilder toBuilder() => WsInvalidBuilder( - detail: detail, - messageId: messageId == null ? null : (messageId as String), - ); + detail: detail, + messageId: messageId == null ? null : (messageId as String), + ); @override Map toJson() => { - "detail": detail, - "message_id": messageId, - "type": "et-invalid", - }; + "detail": detail, + "message_id": messageId, + "type": "et-invalid", + }; factory WsInvalid.fromJson(Map json) => WsInvalid( - detail: json["detail"] as String, - messageId: json["message_id"] == null - ? null - : json["message_id"] == null - ? null - : json["message_id"] as String, - ); + detail: json["detail"] as String, + messageId: json["message_id"] == null + ? null + : json["message_id"] == null + ? null + : json["message_id"] as String, + ); @override - String toString() => "WsInvalid(" + String toString() => + "WsInvalid(" "detail: $detail, " "messageId: $messageId" ")"; @@ -876,9 +880,9 @@ final class WsInvalidBuilder extends WsMessageBuilder { WsInvalidBuilder({required this.detail, required this.messageId}) : super(); WsInvalid build() => WsInvalid( - detail: detail, - messageId: messageId == null ? null : (messageId as String), - ); + detail: detail, + messageId: messageId == null ? null : (messageId as String), + ); } final class WsClientEvent extends WsMessage { @@ -896,35 +900,35 @@ final class WsClientEvent extends WsMessage { required String action, required String capability, required Map details, - }) => - WsClientEventBuilder( - action: action, - capability: capability, - details: details.map((key, value) => MapEntry(key, value)), - ); + }) => WsClientEventBuilder( + action: action, + capability: capability, + details: details.map((key, value) => MapEntry(key, value)), + ); WsClientEventBuilder toBuilder() => WsClientEventBuilder( - action: action, - capability: capability, - details: details.map((key, value) => MapEntry(key, value)), - ); + action: action, + capability: capability, + details: details.map((key, value) => MapEntry(key, value)), + ); @override Map toJson() => { - "action": action, - "capability": capability, - "details": details.map((key, value) => MapEntry(key, value)), - "type": "et-client-event", - }; + "action": action, + "capability": capability, + "details": details.map((key, value) => MapEntry(key, value)), + "type": "et-client-event", + }; factory WsClientEvent.fromJson(Map json) => WsClientEvent( - action: json["action"] as String, - capability: json["capability"] as String, - details: (json["details"] as Map).map( - (key, value) => MapEntry(key as String, value as dynamic), - ), - ); + action: json["action"] as String, + capability: json["capability"] as String, + details: (json["details"] as Map).map( + (key, value) => MapEntry(key as String, value as dynamic), + ), + ); @override - String toString() => "WsClientEvent(" + String toString() => + "WsClientEvent(" "action: $action, " "capability: $capability, " "details: $details" @@ -956,12 +960,12 @@ final class WsClientEvent extends WsMessage { @override int get hashCode => Object.hashAll([ - action.hashCode, - capability.hashCode, - Object.hashAll( - details.entries.expand((entry) => [entry.key, entry.value.hashCode]), - ), - ]); + action.hashCode, + capability.hashCode, + Object.hashAll( + details.entries.expand((entry) => [entry.key, entry.value.hashCode]), + ), + ]); } /// Builder class for [WsClientEvent] @@ -977,10 +981,10 @@ final class WsClientEventBuilder extends WsMessageBuilder { }) : super(); WsClientEvent build() => WsClientEvent( - action: action, - capability: capability, - details: details.map((key, value) => MapEntry(key, value)), - ); + action: action, + capability: capability, + details: details.map((key, value) => MapEntry(key, value)), + ); } final class WsResponse extends WsMessage { @@ -998,7 +1002,8 @@ final class WsResponse extends WsMessage { WsResponse(message: json["message"] as String); @override - String toString() => "WsResponse(" + String toString() => + "WsResponse(" "message: $message" ")"; @override @@ -1033,20 +1038,20 @@ enum AgentConnectionState { disconnected; factory AgentConnectionState.fromJson(dynamic json) => switch (json) { - "connected" => AgentConnectionState.connected, - "disconnected" => AgentConnectionState.disconnected, - final other => throw ArgumentError("Unknown variant: $other"), - }; + "connected" => AgentConnectionState.connected, + "disconnected" => AgentConnectionState.disconnected, + final other => throw ArgumentError("Unknown variant: $other"), + }; dynamic toJson() => switch (this) { - AgentConnectionState.connected => "connected", - AgentConnectionState.disconnected => "disconnected", - }; + AgentConnectionState.connected => "connected", + AgentConnectionState.disconnected => "disconnected", + }; @override String toString() => switch (this) { - AgentConnectionState.connected => "connected", - AgentConnectionState.disconnected => "disconnected", - }; + AgentConnectionState.connected => "connected", + AgentConnectionState.disconnected => "disconnected", + }; } enum ConnectStatus { @@ -1054,20 +1059,20 @@ enum ConnectStatus { reconnected; factory ConnectStatus.fromJson(dynamic json) => switch (json) { - "assigned" => ConnectStatus.assigned, - "reconnected" => ConnectStatus.reconnected, - final other => throw ArgumentError("Unknown variant: $other"), - }; + "assigned" => ConnectStatus.assigned, + "reconnected" => ConnectStatus.reconnected, + final other => throw ArgumentError("Unknown variant: $other"), + }; dynamic toJson() => switch (this) { - ConnectStatus.assigned => "assigned", - ConnectStatus.reconnected => "reconnected", - }; + ConnectStatus.assigned => "assigned", + ConnectStatus.reconnected => "reconnected", + }; @override String toString() => switch (this) { - ConnectStatus.assigned => "assigned", - ConnectStatus.reconnected => "reconnected", - }; + ConnectStatus.assigned => "assigned", + ConnectStatus.reconnected => "reconnected", + }; } enum MessageDeliveryStatus { @@ -1077,26 +1082,26 @@ enum MessageDeliveryStatus { broadcast; factory MessageDeliveryStatus.fromJson(dynamic json) => switch (json) { - "delivered" => MessageDeliveryStatus.delivered, - "queued" => MessageDeliveryStatus.queued, - "acknowledged" => MessageDeliveryStatus.acknowledged, - "broadcast" => MessageDeliveryStatus.broadcast, - final other => throw ArgumentError("Unknown variant: $other"), - }; + "delivered" => MessageDeliveryStatus.delivered, + "queued" => MessageDeliveryStatus.queued, + "acknowledged" => MessageDeliveryStatus.acknowledged, + "broadcast" => MessageDeliveryStatus.broadcast, + final other => throw ArgumentError("Unknown variant: $other"), + }; dynamic toJson() => switch (this) { - MessageDeliveryStatus.delivered => "delivered", - MessageDeliveryStatus.queued => "queued", - MessageDeliveryStatus.acknowledged => "acknowledged", - MessageDeliveryStatus.broadcast => "broadcast", - }; + MessageDeliveryStatus.delivered => "delivered", + MessageDeliveryStatus.queued => "queued", + MessageDeliveryStatus.acknowledged => "acknowledged", + MessageDeliveryStatus.broadcast => "broadcast", + }; @override String toString() => switch (this) { - MessageDeliveryStatus.delivered => "delivered", - MessageDeliveryStatus.queued => "queued", - MessageDeliveryStatus.acknowledged => "acknowledged", - MessageDeliveryStatus.broadcast => "broadcast", - }; + MessageDeliveryStatus.delivered => "delivered", + MessageDeliveryStatus.queued => "queued", + MessageDeliveryStatus.acknowledged => "acknowledged", + MessageDeliveryStatus.broadcast => "broadcast", + }; } enum MessageScope { @@ -1104,18 +1109,18 @@ enum MessageScope { broadcast; factory MessageScope.fromJson(dynamic json) => switch (json) { - "direct" => MessageScope.direct, - "broadcast" => MessageScope.broadcast, - final other => throw ArgumentError("Unknown variant: $other"), - }; + "direct" => MessageScope.direct, + "broadcast" => MessageScope.broadcast, + final other => throw ArgumentError("Unknown variant: $other"), + }; dynamic toJson() => switch (this) { - MessageScope.direct => "direct", - MessageScope.broadcast => "broadcast", - }; + MessageScope.direct => "direct", + MessageScope.broadcast => "broadcast", + }; @override String toString() => switch (this) { - MessageScope.direct => "direct", - MessageScope.broadcast => "broadcast", - }; + MessageScope.direct => "direct", + MessageScope.broadcast => "broadcast", + }; } diff --git a/generated/dart-ws/pubspec.yaml b/generated/dart-ws/pubspec.yaml index ae0f8aa..33c8d3c 100644 --- a/generated/dart-ws/pubspec.yaml +++ b/generated/dart-ws/pubspec.yaml @@ -4,4 +4,8 @@ publish_to: none version: 0.1.0 environment: - sdk: ^3.4.0 + # Must stay ≥ 3.7 so `dart format` consistently picks the post-3.7 "tall" + # style — relying on the older short style was flaky between macOS and + # Linux even on identical Dart SDK versions (dart-fmt-check found drift on + # the generated client when the constraint was ^3.4.0). + sdk: ^3.11.5 From 17e24af0c5d7879b6362689adcddceed64faf35e Mon Sep 17 00:00:00 2001 From: John Vandenberg Date: Wed, 20 May 2026 20:32:12 +0800 Subject: [PATCH 03/13] increase mise http timeout --- .github/workflows/check.yml | 4 ++++ .github/workflows/test.yml | 3 +++ 2 files changed, 7 insertions(+) diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index bf9ec1a..d30e7ec 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -38,6 +38,10 @@ jobs: mise install env: GITHUB_TOKEN: ${{ github.token }} + # GitHub release downloads occasionally take longer than mise's + # default 30s HTTP timeout; bump it so transient network slowness + # doesn't fail the whole `mise install` step. + MISE_HTTP_TIMEOUT: "120" - name: Run checkers run: | diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c0d7859..a697eef 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -36,6 +36,9 @@ jobs: mise install env: GITHUB_TOKEN: ${{ github.token }} + # Match check.yml: bump mise's 30s HTTP timeout so GitHub-release + # downloads don't fail the install on transient slowness. + MISE_HTTP_TIMEOUT: "120" - name: Fetch MNIST model run: mise run fetch-mnist-rclone From c3dcb298bec5197afd450f73ba1275f74b9e5c66 Mon Sep 17 00:00:00 2001 From: John Vandenberg Date: Wed, 20 May 2026 20:51:18 +0800 Subject: [PATCH 04/13] fix dart local deps --- .mise.toml | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/.mise.toml b/.mise.toml index fab59f4..0f0eedb 100644 --- a/.mise.toml +++ b/.mise.toml @@ -105,7 +105,10 @@ depends = ["cargo-clippy-fix", "cargo-fmt", "dart-fmt", "dotnet-fmt", "dprint-fm description = "Run repository formatters" [tasks.dart-check] -run = "dart analyze services/ws-modules/dart-comm1/ generated/dart-ws/" +run = """ +dart pub get --directory services/ws-modules/dart-comm1 +dart analyze services/ws-modules/dart-comm1/ generated/dart-ws/ +""" [tasks.dart-fmt] run = "dart format services/ws-modules/dart-comm1/ generated/dart-ws/" @@ -374,7 +377,10 @@ run = "wasm-pack build . --target web" [tasks.build-ws-dart-comm1-module] description = "Build the dart-comm1 workflow module" dir = "services/ws-modules/dart-comm1" -run = "dart compile js lib/dart_comm1.dart -o pkg/et_ws_dart_comm1_compiled.js --no-source-maps" +run = """ +dart pub get +dart compile js lib/dart_comm1.dart -o pkg/et_ws_dart_comm1_compiled.js --no-source-maps +""" [tasks.build-ws-dotnet-data1-module] description = "Build the dotnet-data1 C# WASM workflow module" From 4170144da5428139cf5c76ae766d123638b6a264 Mon Sep 17 00:00:00 2001 From: John Vandenberg Date: Wed, 20 May 2026 21:18:34 +0800 Subject: [PATCH 05/13] fix test-pyface1 --- .mise.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.mise.toml b/.mise.toml index 0f0eedb..59fbdfd 100644 --- a/.mise.toml +++ b/.mise.toml @@ -551,7 +551,7 @@ run = "cargo test --workspace" [tasks.test-pyface1] dir = "services/ws-modules/pyface1" -run = "uv run pytest" +run = "uv run --python cpython pytest" [tasks.test] depends = ["cargo-test", "test-pyface1"] From 693ae92281659233c6e96c9b850d2c852e2d7441 Mon Sep 17 00:00:00 2001 From: John Vandenberg Date: Wed, 20 May 2026 21:43:22 +0800 Subject: [PATCH 06/13] try pyface1 tests again --- .mise.toml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.mise.toml b/.mise.toml index 59fbdfd..adfee73 100644 --- a/.mise.toml +++ b/.mise.toml @@ -551,7 +551,10 @@ run = "cargo test --workspace" [tasks.test-pyface1] dir = "services/ws-modules/pyface1" -run = "uv run --python cpython pytest" +run = "uv run pytest" + +[tasks.test-pyface1.env] +UV_PYTHON = "cpython" [tasks.test] depends = ["cargo-test", "test-pyface1"] From 5e9768b0dce228f599b99eae82d8be0e57b911d5 Mon Sep 17 00:00:00 2001 From: John Vandenberg Date: Wed, 20 May 2026 22:04:26 +0800 Subject: [PATCH 07/13] still test-pyface1 --- .mise.toml | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/.mise.toml b/.mise.toml index adfee73..4cbf8c7 100644 --- a/.mise.toml +++ b/.mise.toml @@ -546,6 +546,22 @@ run = """ [tasks.download-models] depends = ["fetch-face1-rclone", "fetch-har-motion1-rclone", "fetch-mnist-rclone"] +[tasks.prefetch] +description = "Pre-download all dependencies and models (Rust crates, Dart packages, Python envs, Java/Maven, .NET, Node, WIT)" +depends = ["download-models"] +run = """ +cargo check --workspace +dart pub get --directory services/ws-modules/dart-comm1 +uv sync --directory services/ws-modules/pyface1 +uv sync --directory services/ws-modules/pydata1 +mvn dependency:resolve --quiet +dotnet restore +npm install --prefix services/ws-server/static +""" + +[tasks.prefetch.env] +UV_PYTHON = "cpython" + [tasks.cargo-test] run = "cargo test --workspace" @@ -554,7 +570,9 @@ dir = "services/ws-modules/pyface1" run = "uv run pytest" [tasks.test-pyface1.env] -UV_PYTHON = "cpython" +# Force uv to use its own managed CPython, ignoring the pyodide Python that +# mise puts on PATH (which also identifies as cpython but targets emscripten). +UV_PYTHON_PREFERENCE = "only-managed" [tasks.test] depends = ["cargo-test", "test-pyface1"] From c0518abeecd2d38bb1d2c6a2e4f8f8bdec504d72 Mon Sep 17 00:00:00 2001 From: John Vandenberg Date: Wed, 20 May 2026 22:26:28 +0800 Subject: [PATCH 08/13] rust this time --- .mise.toml | 5 ++--- generated/rust-rest/Cargo.toml | 5 +++++ 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/.mise.toml b/.mise.toml index 4cbf8c7..6b08ee8 100644 --- a/.mise.toml +++ b/.mise.toml @@ -43,6 +43,7 @@ node = "22" "http:pyodide" = { version = "0.29.3", url = "https://github.com/pyodide/pyodide/releases/download/{{ version }}/pyodide-{{ version }}.tar.bz2" } osv-scanner = "latest" pipx = "latest" +python = "3.13" "pipx:cmake" = "latest" "pipx:componentize-py" = "latest" "pipx:datamodel-code-generator" = "latest" @@ -570,9 +571,7 @@ dir = "services/ws-modules/pyface1" run = "uv run pytest" [tasks.test-pyface1.env] -# Force uv to use its own managed CPython, ignoring the pyodide Python that -# mise puts on PATH (which also identifies as cpython but targets emscripten). -UV_PYTHON_PREFERENCE = "only-managed" +UV_PYTHON = "{{exec(command='mise where python')}}/bin/python3" [tasks.test] depends = ["cargo-test", "test-pyface1"] diff --git a/generated/rust-rest/Cargo.toml b/generated/rust-rest/Cargo.toml index cc57cf6..ffd24cf 100644 --- a/generated/rust-rest/Cargo.toml +++ b/generated/rust-rest/Cargo.toml @@ -6,6 +6,11 @@ edition.workspace = true license.workspace = true repository.workspace = true +[lib] +# src/lib.rs is generated by progenitor; its /** */ doc comments contain +# markdown backtick prose that Rust's doctest runner tries to compile as code. +doctest = false + [features] default = ["tracing"] # OTel traceparent injection runs in the progenitor pre-hook. The hook body From 3034cdb8de17a67f79017f6614be4dd57fa67a9f Mon Sep 17 00:00:00 2001 From: John Vandenberg Date: Wed, 20 May 2026 22:43:47 +0800 Subject: [PATCH 09/13] add LICENSE-* --- LICENSE-APACHE | 202 +++++++++++++++++++++++++++++++++++++++++++++++++ LICENSE-MIT | 19 +++++ 2 files changed, 221 insertions(+) create mode 100644 LICENSE-APACHE create mode 100644 LICENSE-MIT diff --git a/LICENSE-APACHE b/LICENSE-APACHE new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/LICENSE-APACHE @@ -0,0 +1,202 @@ + + 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 reasonable and customary use in 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 Additional Liability. 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 additional liability. + + 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 [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/LICENSE-MIT b/LICENSE-MIT new file mode 100644 index 0000000..cfb1d92 --- /dev/null +++ b/LICENSE-MIT @@ -0,0 +1,19 @@ +Copyright 2026 Curtin University + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. From ae5136a5781bac03e88d4239350b2460382153df Mon Sep 17 00:00:00 2001 From: John Vandenberg Date: Wed, 20 May 2026 22:57:46 +0800 Subject: [PATCH 10/13] tidy --- generated/rust-rest/src/lib.rs | 179 +++++++++++++--------- generated/specs/rest.yaml | 14 +- generated/zig-rest/src/et_rest_client.zig | 31 ++-- services/modules/Cargo.toml | 6 +- services/modules/src/lib.rs | 18 ++- services/storage/Cargo.toml | 6 +- services/storage/src/lib.rs | 48 +++--- services/ws-server/Cargo.toml | 7 +- services/ws-server/src/lib.rs | 15 +- utilities/int-gen/Cargo.toml | 6 +- 10 files changed, 198 insertions(+), 132 deletions(-) diff --git a/generated/rust-rest/src/lib.rs b/generated/rust-rest/src/lib.rs index 0176da7..00c632f 100644 --- a/generated/rust-rest/src/lib.rs +++ b/generated/rust-rest/src/lib.rs @@ -1,7 +1,7 @@ #[allow(unused_imports)] pub use progenitor_client::{ByteStream, ClientInfo, Error, ResponseValue}; #[allow(unused_imports)] -use progenitor_client::{ClientHooks, OperationInfo, RequestBuilderExt, encode_path}; +use progenitor_client::{encode_path, ClientHooks, OperationInfo, RequestBuilderExt}; /// Types used as operation parameters and responses. #[allow(clippy::all)] pub mod types { @@ -11,12 +11,18 @@ pub mod types { pub struct ConversionError(::std::borrow::Cow<'static, str>); impl ::std::error::Error for ConversionError {} impl ::std::fmt::Display for ConversionError { - fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> Result<(), ::std::fmt::Error> { + fn fmt( + &self, + f: &mut ::std::fmt::Formatter<'_>, + ) -> Result<(), ::std::fmt::Error> { ::std::fmt::Display::fmt(&self.0, f) } } impl ::std::fmt::Debug for ConversionError { - fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> Result<(), ::std::fmt::Error> { + fn fmt( + &self, + f: &mut ::std::fmt::Formatter<'_>, + ) -> Result<(), ::std::fmt::Error> { ::std::fmt::Debug::fmt(&self.0, f) } } @@ -118,14 +124,17 @@ impl ClientHooks<()> for &Client {} impl Client { /**Sends a `GET` request to `/health` - */ - pub async fn health<'a>(&'a self) -> Result, Error<()>> { +*/ + pub async fn health<'a>( + &'a self, + ) -> Result, Error<()>> { let url = format!("{}/health", self.baseurl,); let mut header_map = ::reqwest::header::HeaderMap::with_capacity(1usize); - header_map.append( - ::reqwest::header::HeaderName::from_static("api-version"), - ::reqwest::header::HeaderValue::from_static(Self::api_version()), - ); + header_map + .append( + ::reqwest::header::HeaderName::from_static("api-version"), + ::reqwest::header::HeaderValue::from_static(Self::api_version()), + ); #[allow(unused_mut)] let mut request = self .client @@ -136,7 +145,9 @@ impl Client { ) .headers(header_map) .build()?; - let info = OperationInfo { operation_id: "health" }; + let info = OperationInfo { + operation_id: "health", + }; match (|request: &mut ::reqwest::Request| { #[cfg(feature = "tracing")] { @@ -144,14 +155,20 @@ impl Client { &::tracing::Span::current(), ); ::opentelemetry::global::get_text_map_propagator(|propagator| { - propagator.inject_context(&cx, &mut ::opentelemetry_http::HeaderInjector(request.headers_mut())); + propagator + .inject_context( + &cx, + &mut ::opentelemetry_http::HeaderInjector( + request.headers_mut(), + ), + ); }); } #[cfg(not(feature = "tracing"))] let _ = request; async { Ok::<(), ::std::convert::Infallible>(()) } })(&mut request) - .await + .await { Ok(_) => {} Err(e) => return Err(Error::Custom(e.to_string())), @@ -167,16 +184,17 @@ impl Client { } /**Sends a `GET` request to `/modules/` - */ +*/ pub async fn list_modules_handler<'a>( &'a self, ) -> Result>, Error<()>> { let url = format!("{}/modules/", self.baseurl,); let mut header_map = ::reqwest::header::HeaderMap::with_capacity(1usize); - header_map.append( - ::reqwest::header::HeaderName::from_static("api-version"), - ::reqwest::header::HeaderValue::from_static(Self::api_version()), - ); + header_map + .append( + ::reqwest::header::HeaderName::from_static("api-version"), + ::reqwest::header::HeaderValue::from_static(Self::api_version()), + ); #[allow(unused_mut)] let mut request = self .client @@ -197,14 +215,20 @@ impl Client { &::tracing::Span::current(), ); ::opentelemetry::global::get_text_map_propagator(|propagator| { - propagator.inject_context(&cx, &mut ::opentelemetry_http::HeaderInjector(request.headers_mut())); + propagator + .inject_context( + &cx, + &mut ::opentelemetry_http::HeaderInjector( + request.headers_mut(), + ), + ); }); } #[cfg(not(feature = "tracing"))] let _ = request; async { Ok::<(), ::std::convert::Infallible>(()) } })(&mut request) - .await + .await { Ok(_) => {} Err(e) => return Err(Error::Custom(e.to_string())), @@ -219,32 +243,31 @@ impl Client { } } /**OpenAPI placeholder for `GET /modules/{name}/{path}`. `Files::new(...)` in - `configure()` actually serves these requests, but utoipa needs a function - to attach `#[utoipa::path]` to. The fn name shapes the generated client's - method name (via the OpenAPI `operationId`), so call it what callers want +`configure()` actually serves these requests; this fn exists only so +utoipa has somewhere to attach `#[utoipa::path]`, gated by the +`openapi-spec` feature - Sends a `GET` request to `/modules/{name}/{path}` +Sends a `GET` request to `/modules/{name}/{path}` - Arguments: - - `name`: Module name - - `path`: Relative path of the file under the module's pkg/ dir - */ +Arguments: +- `name`: Module name +- `path`: Relative path of the file under the module's pkg/ dir +*/ pub async fn get_module_file<'a>( &'a self, name: &'a str, path: &'a str, ) -> Result, Error<()>> { let url = format!( - "{}/modules/{}/{}", - self.baseurl, - encode_path(&name.to_string()), - encode_path(&path.to_string()), + "{}/modules/{}/{}", self.baseurl, encode_path(& name.to_string()), + encode_path(& path.to_string()), ); let mut header_map = ::reqwest::header::HeaderMap::with_capacity(1usize); - header_map.append( - ::reqwest::header::HeaderName::from_static("api-version"), - ::reqwest::header::HeaderValue::from_static(Self::api_version()), - ); + header_map + .append( + ::reqwest::header::HeaderName::from_static("api-version"), + ::reqwest::header::HeaderValue::from_static(Self::api_version()), + ); #[allow(unused_mut)] let mut request = self.client.get(url).headers(header_map).build()?; let info = OperationInfo { @@ -257,14 +280,20 @@ impl Client { &::tracing::Span::current(), ); ::opentelemetry::global::get_text_map_propagator(|propagator| { - propagator.inject_context(&cx, &mut ::opentelemetry_http::HeaderInjector(request.headers_mut())); + propagator + .inject_context( + &cx, + &mut ::opentelemetry_http::HeaderInjector( + request.headers_mut(), + ), + ); }); } #[cfg(not(feature = "tracing"))] let _ = request; async { Ok::<(), ::std::convert::Infallible>(()) } })(&mut request) - .await + .await { Ok(_) => {} Err(e) => return Err(Error::Custom(e.to_string())), @@ -280,32 +309,31 @@ impl Client { } } /**OpenAPI placeholder for `GET /storage/{agent_id}/{filename}`. `Files::new(...)` - in `configure()` actually serves these requests; this fn exists only so - utoipa has somewhere to attach `#[utoipa::path]`. The fn name flows through - the OpenAPI `operationId` into the generated client's method name +in `configure()` actually serves these requests; this fn exists only so +utoipa has somewhere to attach `#[utoipa::path]`, so it's behind the +`openapi-spec` feature - Sends a `GET` request to `/storage/{agent_id}/{filename}` +Sends a `GET` request to `/storage/{agent_id}/{filename}` - Arguments: - - `agent_id`: Agent identifier - - `filename`: Stored filename - */ +Arguments: +- `agent_id`: Agent identifier +- `filename`: Stored filename +*/ pub async fn get_file<'a>( &'a self, agent_id: &'a str, filename: &'a str, ) -> Result, Error<()>> { let url = format!( - "{}/storage/{}/{}", - self.baseurl, - encode_path(&agent_id.to_string()), - encode_path(&filename.to_string()), + "{}/storage/{}/{}", self.baseurl, encode_path(& agent_id.to_string()), + encode_path(& filename.to_string()), ); let mut header_map = ::reqwest::header::HeaderMap::with_capacity(1usize); - header_map.append( - ::reqwest::header::HeaderName::from_static("api-version"), - ::reqwest::header::HeaderValue::from_static(Self::api_version()), - ); + header_map + .append( + ::reqwest::header::HeaderName::from_static("api-version"), + ::reqwest::header::HeaderValue::from_static(Self::api_version()), + ); #[allow(unused_mut)] let mut request = self.client.get(url).headers(header_map).build()?; let info = OperationInfo { @@ -318,14 +346,20 @@ impl Client { &::tracing::Span::current(), ); ::opentelemetry::global::get_text_map_propagator(|propagator| { - propagator.inject_context(&cx, &mut ::opentelemetry_http::HeaderInjector(request.headers_mut())); + propagator + .inject_context( + &cx, + &mut ::opentelemetry_http::HeaderInjector( + request.headers_mut(), + ), + ); }); } #[cfg(not(feature = "tracing"))] let _ = request; async { Ok::<(), ::std::convert::Infallible>(()) } })(&mut request) - .await + .await { Ok(_) => {} Err(e) => return Err(Error::Custom(e.to_string())), @@ -342,11 +376,11 @@ impl Client { } /**Sends a `PUT` request to `/storage/{agent_id}/{filename}` - Arguments: - - `agent_id`: Agent identifier (must be a connected agent) - - `filename`: Single-segment filename to write - - `body`: Raw file bytes - */ +Arguments: +- `agent_id`: Agent identifier (must be a connected agent) +- `filename`: Single-segment filename to write +- `body`: Raw file bytes +*/ pub async fn put_file<'a, B: Into>( &'a self, agent_id: &'a str, @@ -354,16 +388,15 @@ impl Client { body: B, ) -> Result, Error<()>> { let url = format!( - "{}/storage/{}/{}", - self.baseurl, - encode_path(&agent_id.to_string()), - encode_path(&filename.to_string()), + "{}/storage/{}/{}", self.baseurl, encode_path(& agent_id.to_string()), + encode_path(& filename.to_string()), ); let mut header_map = ::reqwest::header::HeaderMap::with_capacity(1usize); - header_map.append( - ::reqwest::header::HeaderName::from_static("api-version"), - ::reqwest::header::HeaderValue::from_static(Self::api_version()), - ); + header_map + .append( + ::reqwest::header::HeaderName::from_static("api-version"), + ::reqwest::header::HeaderValue::from_static(Self::api_version()), + ); #[allow(unused_mut)] let mut request = self .client @@ -385,14 +418,20 @@ impl Client { &::tracing::Span::current(), ); ::opentelemetry::global::get_text_map_propagator(|propagator| { - propagator.inject_context(&cx, &mut ::opentelemetry_http::HeaderInjector(request.headers_mut())); + propagator + .inject_context( + &cx, + &mut ::opentelemetry_http::HeaderInjector( + request.headers_mut(), + ), + ); }); } #[cfg(not(feature = "tracing"))] let _ = request; async { Ok::<(), ::std::convert::Infallible>(()) } })(&mut request) - .await + .await { Ok(_) => {} Err(e) => return Err(Error::Custom(e.to_string())), diff --git a/generated/specs/rest.yaml b/generated/specs/rest.yaml index 2d7c60c..f11421a 100644 --- a/generated/specs/rest.yaml +++ b/generated/specs/rest.yaml @@ -39,9 +39,9 @@ paths: - et_modules_service summary: |- OpenAPI placeholder for `GET /modules/{name}/{path}`. `Files::new(...)` in - `configure()` actually serves these requests, but utoipa needs a function - to attach `#[utoipa::path]` to. The fn name shapes the generated client's - method name (via the OpenAPI `operationId`), so call it what callers want. + `configure()` actually serves these requests; this fn exists only so + utoipa has somewhere to attach `#[utoipa::path]`, gated by the + `openapi-spec` feature. operationId: get_module_file parameters: - in: path @@ -72,8 +72,8 @@ paths: summary: |- OpenAPI placeholder for `GET /storage/{agent_id}/{filename}`. `Files::new(...)` in `configure()` actually serves these requests; this fn exists only so - utoipa has somewhere to attach `#[utoipa::path]`. The fn name flows through - the OpenAPI `operationId` into the generated client's method name. + utoipa has somewhere to attach `#[utoipa::path]`, so it's behind the + `openapi-spec` feature. operationId: get_file parameters: - in: path @@ -123,7 +123,9 @@ paths: schema: description: |- Phantom type used to label binary request/response bodies as - `string`/`binary` in the OpenAPI document. Never constructed at runtime. + `string`/`binary` in the OpenAPI document. Never constructed at runtime; + only exists under the `openapi-spec` feature so the `utoipa::ToSchema` + derive has something to attach to. type: string format: binary required: true diff --git a/generated/zig-rest/src/et_rest_client.zig b/generated/zig-rest/src/et_rest_client.zig index 8d79e7b..d6d69ae 100644 --- a/generated/zig-rest/src/et_rest_client.zig +++ b/generated/zig-rest/src/et_rest_client.zig @@ -9,10 +9,12 @@ pub const HealthResponse = struct { service: []const u8, }; + /////////////////////////////////////////// // Generated Zig API client from OpenAPI /////////////////////////////////////////// + pub fn Owned(comptime T: type) type { return struct { allocator: std.mem.Allocator, @@ -138,14 +140,10 @@ pub fn requestRaw(client: *Client, method: std.http.Method, url: []const u8, pay const body_slice = payload orelse ""; const response_buf = try allocator.alloc(u8, 64 * 1024); const written = js_rest_request( - method_str.ptr, - method_str.len, - url.ptr, - url.len, - body_slice.ptr, - body_slice.len, - response_buf.ptr, - response_buf.len, + method_str.ptr, method_str.len, + url.ptr, url.len, + body_slice.ptr, body_slice.len, + response_buf.ptr, response_buf.len, ); if (written < 0) { allocator.free(response_buf); @@ -432,8 +430,8 @@ pub fn healthResult(client: *Client) !ApiResult(HealthResponse) { // Summary: // OpenAPI placeholder for `GET /storage/{agent_id}/{filename}`. `Files::new(...)` // in `configure()` actually serves these requests; this fn exists only so -// utoipa has somewhere to attach `#[utoipa::path]`. The fn name flows through -// the OpenAPI `operationId` into the generated client's method name. +// utoipa has somewhere to attach `#[utoipa::path]`, so it's behind the +// `openapi-spec` feature. // pub fn get_file(client: *Client, agent_id: []const u8, filename: []const u8) !void { var raw = try get_fileRaw(client, agent_id, filename); @@ -445,7 +443,7 @@ pub fn get_fileRaw(client: *Client, agent_id: []const u8, filename: []const u8) const allocator = client.allocator; var uri_buf: std.Io.Writer.Allocating = .init(allocator); defer uri_buf.deinit(); - try uri_buf.writer.print("{s}/storage/{s}/{s}", .{ client.base_url, agent_id, filename }); + try uri_buf.writer.print("{s}/storage/{s}/{s}", .{client.base_url, agent_id, filename}); const payload: ?[]const u8 = null; return requestRaw(client, std.http.Method.GET, uri_buf.written(), payload); @@ -461,7 +459,7 @@ pub fn put_fileRaw(client: *Client, agent_id: []const u8, filename: []const u8, const allocator = client.allocator; var uri_buf: std.Io.Writer.Allocating = .init(allocator); defer uri_buf.deinit(); - try uri_buf.writer.print("{s}/storage/{s}/{s}", .{ client.base_url, agent_id, filename }); + try uri_buf.writer.print("{s}/storage/{s}/{s}", .{client.base_url, agent_id, filename}); const payload: ?[]const u8 = requestBody; @@ -471,9 +469,9 @@ pub fn put_fileRaw(client: *Client, agent_id: []const u8, filename: []const u8, ///////////////// // Summary: // OpenAPI placeholder for `GET /modules/{name}/{path}`. `Files::new(...)` in -// `configure()` actually serves these requests, but utoipa needs a function -// to attach `#[utoipa::path]` to. The fn name shapes the generated client's -// method name (via the OpenAPI `operationId`), so call it what callers want. +// `configure()` actually serves these requests; this fn exists only so +// utoipa has somewhere to attach `#[utoipa::path]`, gated by the +// `openapi-spec` feature. // pub fn get_module_file(client: *Client, name: []const u8, path: []const u8) !void { var raw = try get_module_fileRaw(client, name, path); @@ -485,12 +483,13 @@ pub fn get_module_fileRaw(client: *Client, name: []const u8, path: []const u8) ! const allocator = client.allocator; var uri_buf: std.Io.Writer.Allocating = .init(allocator); defer uri_buf.deinit(); - try uri_buf.writer.print("{s}/modules/{s}/{s}", .{ client.base_url, name, path }); + try uri_buf.writer.print("{s}/modules/{s}/{s}", .{client.base_url, name, path}); const payload: ?[]const u8 = null; return requestRaw(client, std.http.Method.GET, uri_buf.written(), payload); } + /// Host-provided HTTP transport. The JS shim implements this against /// browser `fetch()` (via SharedArrayBuffer + Atomics so this looks /// synchronous to Zig). Returns the number of bytes written to diff --git a/services/modules/Cargo.toml b/services/modules/Cargo.toml index 500eee6..4d903f7 100644 --- a/services/modules/Cargo.toml +++ b/services/modules/Cargo.toml @@ -5,6 +5,10 @@ edition.workspace = true license.workspace = true repository.workspace = true +[features] +# See ws-server's openapi-spec feature for the rationale. +openapi-spec = ["dep:utoipa"] + [dependencies] actix-files = "0.6" actix-web = "4" @@ -14,7 +18,7 @@ serde-inline-default.workspace = true serde_default.workspace = true serde_json.workspace = true tracing.workspace = true -utoipa.workspace = true +utoipa = { workspace = true, optional = true } [dev-dependencies] actix-rt = "2" diff --git a/services/modules/src/lib.rs b/services/modules/src/lib.rs index f98b5e8..68cb9b1 100644 --- a/services/modules/src/lib.rs +++ b/services/modules/src/lib.rs @@ -69,10 +69,13 @@ pub fn list_modules(config: &ModulesConfig) -> Vec<(String, PathBuf)> { modules } -#[utoipa::path( - get, - path = "/modules/", - responses((status = 200, description = "Names of available modules", body = Vec)) +#[cfg_attr( + feature = "openapi-spec", + utoipa::path( + get, + path = "/modules/", + responses((status = 200, description = "Names of available modules", body = Vec)) + ) )] pub async fn list_modules_handler(config: web::Data) -> HttpResponse { let names: Vec = list_modules(&config).into_iter().map(|(name, _)| name).collect(); @@ -80,9 +83,10 @@ pub async fn list_modules_handler(config: web::Data) -> HttpRespo } /// OpenAPI placeholder for `GET /modules/{name}/{path}`. `Files::new(...)` in -/// `configure()` actually serves these requests, but utoipa needs a function -/// to attach `#[utoipa::path]` to. The fn name shapes the generated client's -/// method name (via the OpenAPI `operationId`), so call it what callers want. +/// `configure()` actually serves these requests; this fn exists only so +/// utoipa has somewhere to attach `#[utoipa::path]`, gated by the +/// `openapi-spec` feature. +#[cfg(feature = "openapi-spec")] #[utoipa::path( get, path = "/modules/{name}/{path}", diff --git a/services/storage/Cargo.toml b/services/storage/Cargo.toml index dc5a44f..72c822b 100644 --- a/services/storage/Cargo.toml +++ b/services/storage/Cargo.toml @@ -5,6 +5,10 @@ edition.workspace = true license.workspace = true repository.workspace = true +[features] +# See ws-server's openapi-spec feature for the rationale. +openapi-spec = ["dep:utoipa"] + [dependencies] actix-files = "0.6" actix-web = "4" @@ -15,4 +19,4 @@ serde-inline-default.workspace = true serde_default.workspace = true tokio = { version = "1", features = ["full"] } tracing.workspace = true -utoipa.workspace = true +utoipa = { workspace = true, optional = true } diff --git a/services/storage/src/lib.rs b/services/storage/src/lib.rs index 5c0e7e3..2b67c67 100644 --- a/services/storage/src/lib.rs +++ b/services/storage/src/lib.rs @@ -7,11 +7,13 @@ use futures_util::StreamExt; use serde::Deserialize; use serde_default::DefaultFromSerde; use tracing::info; -use utoipa::ToSchema; /// Phantom type used to label binary request/response bodies as -/// `string`/`binary` in the OpenAPI document. Never constructed at runtime. -#[derive(ToSchema)] +/// `string`/`binary` in the OpenAPI document. Never constructed at runtime; +/// only exists under the `openapi-spec` feature so the `utoipa::ToSchema` +/// derive has something to attach to. +#[cfg(feature = "openapi-spec")] +#[derive(utoipa::ToSchema)] #[schema(value_type = String, format = Binary)] pub struct BinaryBlob(#[allow(dead_code)] Vec); @@ -29,22 +31,25 @@ pub struct StorageConfig { pub path: PathBuf, } -#[utoipa::path( - put, - path = "/storage/{agent_id}/{filename}", - params( - ("agent_id" = String, Path, description = "Agent identifier (must be a connected agent)"), - ("filename" = String, Path, description = "Single-segment filename to write") - ), - request_body( - content = inline(BinaryBlob), - content_type = "application/octet-stream", - description = "Raw file bytes" - ), - responses( - (status = 200, description = "File stored"), - (status = 400, description = "Invalid filename"), - (status = 404, description = "Agent not found") +#[cfg_attr( + feature = "openapi-spec", + utoipa::path( + put, + path = "/storage/{agent_id}/{filename}", + params( + ("agent_id" = String, Path, description = "Agent identifier (must be a connected agent)"), + ("filename" = String, Path, description = "Single-segment filename to write") + ), + request_body( + content = inline(BinaryBlob), + content_type = "application/octet-stream", + description = "Raw file bytes" + ), + responses( + (status = 200, description = "File stored"), + (status = 400, description = "Invalid filename"), + (status = 404, description = "Agent not found") + ) ) )] pub async fn put_file( @@ -89,8 +94,9 @@ pub async fn put_file( /// OpenAPI placeholder for `GET /storage/{agent_id}/{filename}`. `Files::new(...)` /// in `configure()` actually serves these requests; this fn exists only so -/// utoipa has somewhere to attach `#[utoipa::path]`. The fn name flows through -/// the OpenAPI `operationId` into the generated client's method name. +/// utoipa has somewhere to attach `#[utoipa::path]`, so it's behind the +/// `openapi-spec` feature. +#[cfg(feature = "openapi-spec")] #[utoipa::path( get, path = "/storage/{agent_id}/{filename}", diff --git a/services/ws-server/Cargo.toml b/services/ws-server/Cargo.toml index 2a495bb..e64246c 100644 --- a/services/ws-server/Cargo.toml +++ b/services/ws-server/Cargo.toml @@ -5,6 +5,11 @@ edition.workspace = true license.workspace = true repository.workspace = true +[features] +# OpenAPI metadata for `et-int-gen` to harvest into generated/specs/rest.yaml. +# Production binaries don't need it; enable only when generating the spec. +openapi-spec = ["dep:utoipa", "et-modules-service/openapi-spec", "et-storage-service/openapi-spec"] + [dependencies] actix-rt.workspace = true actix-web.workspace = true @@ -33,5 +38,5 @@ tokio = { version = "1", features = ["full"] } tracing.workspace = true tracing-actix-web.workspace = true tracing-subscriber.workspace = true -utoipa.workspace = true +utoipa = { workspace = true, optional = true } uuid.workspace = true diff --git a/services/ws-server/src/lib.rs b/services/ws-server/src/lib.rs index 63e1aa4..e8e8d21 100644 --- a/services/ws-server/src/lib.rs +++ b/services/ws-server/src/lib.rs @@ -1,14 +1,14 @@ use actix_web::{HttpResponse, web}; pub use et_ws_service::{AgentSession, WsAgentRegistry}; use serde::{Deserialize, Serialize}; -use utoipa::ToSchema; pub mod config; use crate::config::Config; /// Server liveness probe response. Returned by `GET /health`. -#[derive(Clone, Debug, Deserialize, Serialize, ToSchema)] +#[derive(Clone, Debug, Deserialize, Serialize)] +#[cfg_attr(feature = "openapi-spec", derive(utoipa::ToSchema))] pub struct HealthResponse { pub status: String, pub service: String, @@ -18,10 +18,13 @@ pub async fn no_content() -> HttpResponse { HttpResponse::NoContent().finish() } -#[utoipa::path( - get, - path = "/health", - responses((status = 200, description = "Server is up", body = HealthResponse)) +#[cfg_attr( + feature = "openapi-spec", + utoipa::path( + get, + path = "/health", + responses((status = 200, description = "Server is up", body = HealthResponse)) + ) )] pub async fn health() -> HttpResponse { HttpResponse::Ok().json(HealthResponse { diff --git a/utilities/int-gen/Cargo.toml b/utilities/int-gen/Cargo.toml index 9cfb649..722db48 100644 --- a/utilities/int-gen/Cargo.toml +++ b/utilities/int-gen/Cargo.toml @@ -17,9 +17,9 @@ path = "src/bin/int-gen.rs" asyncapi-rust.workspace = true clap.workspace = true edge-toolkit = { workspace = true, features = ["schema-export"] } -et-modules-service = { path = "../../services/modules" } -et-storage-service = { path = "../../services/storage" } -et-ws-server = { path = "../../services/ws-server" } +et-modules-service = { path = "../../services/modules", features = ["openapi-spec"] } +et-storage-service = { path = "../../services/storage", features = ["openapi-spec"] } +et-ws-server = { path = "../../services/ws-server", features = ["openapi-spec"] } heck.workspace = true kdl.workspace = true openapiv3.workspace = true From 116b9a22c616ba0e5f2c81069ebaf8de11b2b359 Mon Sep 17 00:00:00 2001 From: John Vandenberg Date: Thu, 21 May 2026 06:39:23 +0800 Subject: [PATCH 11/13] fmt and ignore whitespace of licenses --- .editorconfig | 8 + .mise.toml | 4 +- generated/dart-ws/lib/ws_messages.dart | 539 +++++++++++----------- generated/rust-rest/src/lib.rs | 179 +++---- generated/zig-rest/src/et_rest_client.zig | 21 +- 5 files changed, 358 insertions(+), 393 deletions(-) diff --git a/.editorconfig b/.editorconfig index 1e0fc1b..500b948 100644 --- a/.editorconfig +++ b/.editorconfig @@ -21,6 +21,14 @@ max_line_length = off [*.wit] max_line_length = 300 +# License files use the canonical upstream formatting (centred headers, odd +# indent widths, etc.) — leave them alone. +[LICENSE-*] +indent_size = unset +indent_style = unset +max_line_length = unset +trim_trailing_whitespace = unset + # Binary file formats that should be ignored [*.onnx] charset = unset diff --git a/.mise.toml b/.mise.toml index 6b08ee8..bc1e78a 100644 --- a/.mise.toml +++ b/.mise.toml @@ -43,13 +43,13 @@ node = "22" "http:pyodide" = { version = "0.29.3", url = "https://github.com/pyodide/pyodide/releases/download/{{ version }}/pyodide-{{ version }}.tar.bz2" } osv-scanner = "latest" pipx = "latest" -python = "3.13" "pipx:cmake" = "latest" "pipx:componentize-py" = "latest" "pipx:datamodel-code-generator" = "latest" "pipx:openapi-python-client" = "latest" "pipx:pytest" = "latest" protoc = "latest" +python = "3.13" rclone = "latest" ruff = "latest" rust = [ @@ -548,8 +548,8 @@ run = """ depends = ["fetch-face1-rclone", "fetch-har-motion1-rclone", "fetch-mnist-rclone"] [tasks.prefetch] -description = "Pre-download all dependencies and models (Rust crates, Dart packages, Python envs, Java/Maven, .NET, Node, WIT)" depends = ["download-models"] +description = "Pre-download all dependencies and models (Rust crates, Dart packages, Python envs, Java/Maven, .NET, Node, WIT)" run = """ cargo check --workspace dart pub get --directory services/ws-modules/dart-comm1 diff --git a/generated/dart-ws/lib/ws_messages.dart b/generated/dart-ws/lib/ws_messages.dart index 21cf092..6c14f69 100644 --- a/generated/dart-ws/lib/ws_messages.dart +++ b/generated/dart-ws/lib/ws_messages.dart @@ -14,35 +14,35 @@ final class AgentSummary { required String agentId, String? lastKnownIp, required AgentConnectionState state, - }) => AgentSummaryBuilder( - agentId: agentId, - lastKnownIp: lastKnownIp == null ? null : (lastKnownIp as String), - state: state, - ); + }) => + AgentSummaryBuilder( + agentId: agentId, + lastKnownIp: lastKnownIp == null ? null : (lastKnownIp as String), + state: state, + ); AgentSummaryBuilder toBuilder() => AgentSummaryBuilder( - agentId: agentId, - lastKnownIp: lastKnownIp == null ? null : (lastKnownIp as String), - state: state, - ); + agentId: agentId, + lastKnownIp: lastKnownIp == null ? null : (lastKnownIp as String), + state: state, + ); Map toJson() => { - "agent_id": agentId, - "last_known_ip": lastKnownIp, - "state": state.toJson(), - }; + "agent_id": agentId, + "last_known_ip": lastKnownIp, + "state": state.toJson(), + }; factory AgentSummary.fromJson(Map json) => AgentSummary( - agentId: json["agent_id"] as String, - lastKnownIp: json["last_known_ip"] == null - ? null - : json["last_known_ip"] == null - ? null - : json["last_known_ip"] as String, - state: AgentConnectionState.fromJson(json["state"]), - ); + agentId: json["agent_id"] as String, + lastKnownIp: json["last_known_ip"] == null + ? null + : json["last_known_ip"] == null + ? null + : json["last_known_ip"] as String, + state: AgentConnectionState.fromJson(json["state"]), + ); @override - String toString() => - "AgentSummary(" + String toString() => "AgentSummary(" "agentId: $agentId, " "lastKnownIp: $lastKnownIp, " "state: $state" @@ -85,10 +85,10 @@ final class AgentSummaryBuilder { }); AgentSummary build() => AgentSummary( - agentId: agentId, - lastKnownIp: lastKnownIp == null ? null : (lastKnownIp as String), - state: state, - ); + agentId: agentId, + lastKnownIp: lastKnownIp == null ? null : (lastKnownIp as String), + state: state, + ); } sealed class WsMessage { @@ -133,16 +133,15 @@ final class WsConnect extends WsMessage { @override Map toJson() => {"agent_id": agentId, "type": "et-connect"}; factory WsConnect.fromJson(Map json) => WsConnect( - agentId: json["agent_id"] == null - ? null - : json["agent_id"] == null - ? null - : json["agent_id"] as String, - ); + agentId: json["agent_id"] == null + ? null + : json["agent_id"] == null + ? null + : json["agent_id"] as String, + ); @override - String toString() => - "WsConnect(" + String toString() => "WsConnect(" "agentId: $agentId" ")"; @override @@ -182,24 +181,24 @@ final class WsConnectAck extends WsMessage { static WsConnectAckBuilder builder({ required String agentId, required ConnectStatus status, - }) => WsConnectAckBuilder(agentId: agentId, status: status); + }) => + WsConnectAckBuilder(agentId: agentId, status: status); WsConnectAckBuilder toBuilder() => WsConnectAckBuilder(agentId: agentId, status: status); @override Map toJson() => { - "agent_id": agentId, - "status": status.toJson(), - "type": "et-connect-ack", - }; + "agent_id": agentId, + "status": status.toJson(), + "type": "et-connect-ack", + }; factory WsConnectAck.fromJson(Map json) => WsConnectAck( - agentId: json["agent_id"] as String, - status: ConnectStatus.fromJson(json["status"]), - ); + agentId: json["agent_id"] as String, + status: ConnectStatus.fromJson(json["status"]), + ); @override - String toString() => - "WsConnectAck(" + String toString() => "WsConnectAck(" "agentId: $agentId, " "status: $status" ")"; @@ -249,8 +248,7 @@ final class WsAlive extends WsMessage { WsAlive(timestamp: json["timestamp"] as String); @override - String toString() => - "WsAlive(" + String toString() => "WsAlive(" "timestamp: $timestamp" ")"; @override @@ -291,8 +289,7 @@ final class WsListAgents extends WsMessage { factory WsListAgents.fromJson(Map json) => WsListAgents(); @override - String toString() => - "WsListAgents(" + String toString() => "WsListAgents(" ")"; @override bool operator ==(Object other) { @@ -323,18 +320,19 @@ final class WsListAgentsResponse extends WsMessage { static WsListAgentsResponseBuilder builder({ required List agents, - }) => WsListAgentsResponseBuilder( - agents: agents.map((elem) => elem.toBuilder()).toList(), - ); + }) => + WsListAgentsResponseBuilder( + agents: agents.map((elem) => elem.toBuilder()).toList(), + ); WsListAgentsResponseBuilder toBuilder() => WsListAgentsResponseBuilder( - agents: agents.map((elem) => elem.toBuilder()).toList(), - ); + agents: agents.map((elem) => elem.toBuilder()).toList(), + ); @override Map toJson() => { - "agents": agents.map((inner) => inner.toJson()).toList(), - "type": "et-list-agents-response", - }; + "agents": agents.map((inner) => inner.toJson()).toList(), + "type": "et-list-agents-response", + }; factory WsListAgentsResponse.fromJson(Map json) => WsListAgentsResponse( agents: (json["agents"] as List) @@ -345,8 +343,7 @@ final class WsListAgentsResponse extends WsMessage { ); @override - String toString() => - "WsListAgentsResponse(" + String toString() => "WsListAgentsResponse(" "agents: $agents" ")"; @override @@ -388,26 +385,27 @@ final class WsSendAgentMessage extends WsMessage { final String toAgentId; const WsSendAgentMessage({required this.message, required this.toAgentId}) - : super(); + : super(); static WsSendAgentMessageBuilder builder({ required Map message, required String toAgentId, - }) => WsSendAgentMessageBuilder( - message: message.map((key, value) => MapEntry(key, value)), - toAgentId: toAgentId, - ); + }) => + WsSendAgentMessageBuilder( + message: message.map((key, value) => MapEntry(key, value)), + toAgentId: toAgentId, + ); WsSendAgentMessageBuilder toBuilder() => WsSendAgentMessageBuilder( - message: message.map((key, value) => MapEntry(key, value)), - toAgentId: toAgentId, - ); + message: message.map((key, value) => MapEntry(key, value)), + toAgentId: toAgentId, + ); @override Map toJson() => { - "message": message.map((key, value) => MapEntry(key, value)), - "to_agent_id": toAgentId, - "type": "et-send-agent-message", - }; + "message": message.map((key, value) => MapEntry(key, value)), + "to_agent_id": toAgentId, + "type": "et-send-agent-message", + }; factory WsSendAgentMessage.fromJson(Map json) => WsSendAgentMessage( message: (json["message"] as Map).map( @@ -417,8 +415,7 @@ final class WsSendAgentMessage extends WsMessage { ); @override - String toString() => - "WsSendAgentMessage(" + String toString() => "WsSendAgentMessage(" "message: $message, " "toAgentId: $toAgentId" ")"; @@ -446,11 +443,11 @@ final class WsSendAgentMessage extends WsMessage { @override int get hashCode => Object.hashAll([ - Object.hashAll( - message.entries.expand((entry) => [entry.key, entry.value.hashCode]), - ), - toAgentId.hashCode, - ]); + Object.hashAll( + message.entries.expand((entry) => [entry.key, entry.value.hashCode]), + ), + toAgentId.hashCode, + ]); } /// Builder class for [WsSendAgentMessage] @@ -459,12 +456,12 @@ final class WsSendAgentMessageBuilder extends WsMessageBuilder { String toAgentId; WsSendAgentMessageBuilder({required this.message, required this.toAgentId}) - : super(); + : super(); WsSendAgentMessage build() => WsSendAgentMessage( - message: message.map((key, value) => MapEntry(key, value)), - toAgentId: toAgentId, - ); + message: message.map((key, value) => MapEntry(key, value)), + toAgentId: toAgentId, + ); } final class WsBroadcastMessage extends WsMessage { @@ -474,18 +471,19 @@ final class WsBroadcastMessage extends WsMessage { static WsBroadcastMessageBuilder builder({ required Map message, - }) => WsBroadcastMessageBuilder( - message: message.map((key, value) => MapEntry(key, value)), - ); + }) => + WsBroadcastMessageBuilder( + message: message.map((key, value) => MapEntry(key, value)), + ); WsBroadcastMessageBuilder toBuilder() => WsBroadcastMessageBuilder( - message: message.map((key, value) => MapEntry(key, value)), - ); + message: message.map((key, value) => MapEntry(key, value)), + ); @override Map toJson() => { - "message": message.map((key, value) => MapEntry(key, value)), - "type": "et-broadcast-message", - }; + "message": message.map((key, value) => MapEntry(key, value)), + "type": "et-broadcast-message", + }; factory WsBroadcastMessage.fromJson(Map json) => WsBroadcastMessage( message: (json["message"] as Map).map( @@ -494,8 +492,7 @@ final class WsBroadcastMessage extends WsMessage { ); @override - String toString() => - "WsBroadcastMessage(" + String toString() => "WsBroadcastMessage(" "message: $message" ")"; @override @@ -519,10 +516,10 @@ final class WsBroadcastMessage extends WsMessage { @override int get hashCode => Object.hashAll([ - Object.hashAll( - message.entries.expand((entry) => [entry.key, entry.value.hashCode]), - ), - ]); + Object.hashAll( + message.entries.expand((entry) => [entry.key, entry.value.hashCode]), + ), + ]); } /// Builder class for [WsBroadcastMessage] @@ -532,8 +529,8 @@ final class WsBroadcastMessageBuilder extends WsMessageBuilder { WsBroadcastMessageBuilder({required this.message}) : super(); WsBroadcastMessage build() => WsBroadcastMessage( - message: message.map((key, value) => MapEntry(key, value)), - ); + message: message.map((key, value) => MapEntry(key, value)), + ); } final class WsAgentMessage extends WsMessage { @@ -557,43 +554,43 @@ final class WsAgentMessage extends WsMessage { required String messageId, required MessageScope scope, required String serverReceivedAt, - }) => WsAgentMessageBuilder( - fromAgentId: fromAgentId, - message: message.map((key, value) => MapEntry(key, value)), - messageId: messageId, - scope: scope, - serverReceivedAt: serverReceivedAt, - ); + }) => + WsAgentMessageBuilder( + fromAgentId: fromAgentId, + message: message.map((key, value) => MapEntry(key, value)), + messageId: messageId, + scope: scope, + serverReceivedAt: serverReceivedAt, + ); WsAgentMessageBuilder toBuilder() => WsAgentMessageBuilder( - fromAgentId: fromAgentId, - message: message.map((key, value) => MapEntry(key, value)), - messageId: messageId, - scope: scope, - serverReceivedAt: serverReceivedAt, - ); + fromAgentId: fromAgentId, + message: message.map((key, value) => MapEntry(key, value)), + messageId: messageId, + scope: scope, + serverReceivedAt: serverReceivedAt, + ); @override Map toJson() => { - "from_agent_id": fromAgentId, - "message": message.map((key, value) => MapEntry(key, value)), - "message_id": messageId, - "scope": scope.toJson(), - "server_received_at": serverReceivedAt, - "type": "et-agent-message", - }; + "from_agent_id": fromAgentId, + "message": message.map((key, value) => MapEntry(key, value)), + "message_id": messageId, + "scope": scope.toJson(), + "server_received_at": serverReceivedAt, + "type": "et-agent-message", + }; factory WsAgentMessage.fromJson(Map json) => WsAgentMessage( - fromAgentId: json["from_agent_id"] as String, - message: (json["message"] as Map).map( - (key, value) => MapEntry(key as String, value as dynamic), - ), - messageId: json["message_id"] as String, - scope: MessageScope.fromJson(json["scope"]), - serverReceivedAt: json["server_received_at"] as String, - ); + fromAgentId: json["from_agent_id"] as String, + message: (json["message"] as Map).map( + (key, value) => MapEntry(key as String, value as dynamic), + ), + messageId: json["message_id"] as String, + scope: MessageScope.fromJson(json["scope"]), + serverReceivedAt: json["server_received_at"] as String, + ); @override - String toString() => - "WsAgentMessage(" + String toString() => "WsAgentMessage(" "fromAgentId: $fromAgentId, " "message: $message, " "messageId: $messageId, " @@ -633,14 +630,14 @@ final class WsAgentMessage extends WsMessage { @override int get hashCode => Object.hashAll([ - fromAgentId.hashCode, - Object.hashAll( - message.entries.expand((entry) => [entry.key, entry.value.hashCode]), - ), - messageId.hashCode, - scope.hashCode, - serverReceivedAt.hashCode, - ]); + fromAgentId.hashCode, + Object.hashAll( + message.entries.expand((entry) => [entry.key, entry.value.hashCode]), + ), + messageId.hashCode, + scope.hashCode, + serverReceivedAt.hashCode, + ]); } /// Builder class for [WsAgentMessage] @@ -660,12 +657,12 @@ final class WsAgentMessageBuilder extends WsMessageBuilder { }) : super(); WsAgentMessage build() => WsAgentMessage( - fromAgentId: fromAgentId, - message: message.map((key, value) => MapEntry(key, value)), - messageId: messageId, - scope: scope, - serverReceivedAt: serverReceivedAt, - ); + fromAgentId: fromAgentId, + message: message.map((key, value) => MapEntry(key, value)), + messageId: messageId, + scope: scope, + serverReceivedAt: serverReceivedAt, + ); } final class WsMessageAck extends WsMessage { @@ -679,15 +676,14 @@ final class WsMessageAck extends WsMessage { @override Map toJson() => { - "message_id": messageId, - "type": "et-message-ack", - }; + "message_id": messageId, + "type": "et-message-ack", + }; factory WsMessageAck.fromJson(Map json) => WsMessageAck(messageId: json["message_id"] as String); @override - String toString() => - "WsMessageAck(" + String toString() => "WsMessageAck(" "messageId: $messageId" ")"; @override @@ -732,38 +728,38 @@ final class WsMessageStatus extends WsMessage { required String detail, String? messageId, required MessageDeliveryStatus status, - }) => WsMessageStatusBuilder( - detail: detail, - messageId: messageId == null ? null : (messageId as String), - status: status, - ); + }) => + WsMessageStatusBuilder( + detail: detail, + messageId: messageId == null ? null : (messageId as String), + status: status, + ); WsMessageStatusBuilder toBuilder() => WsMessageStatusBuilder( - detail: detail, - messageId: messageId == null ? null : (messageId as String), - status: status, - ); + detail: detail, + messageId: messageId == null ? null : (messageId as String), + status: status, + ); @override Map toJson() => { - "detail": detail, - "message_id": messageId, - "status": status.toJson(), - "type": "et-message-status", - }; + "detail": detail, + "message_id": messageId, + "status": status.toJson(), + "type": "et-message-status", + }; factory WsMessageStatus.fromJson(Map json) => WsMessageStatus( detail: json["detail"] as String, messageId: json["message_id"] == null ? null : json["message_id"] == null - ? null - : json["message_id"] as String, + ? null + : json["message_id"] as String, status: MessageDeliveryStatus.fromJson(json["status"]), ); @override - String toString() => - "WsMessageStatus(" + String toString() => "WsMessageStatus(" "detail: $detail, " "messageId: $messageId, " "status: $status" @@ -806,10 +802,10 @@ final class WsMessageStatusBuilder extends WsMessageBuilder { }) : super(); WsMessageStatus build() => WsMessageStatus( - detail: detail, - messageId: messageId == null ? null : (messageId as String), - status: status, - ); + detail: detail, + messageId: messageId == null ? null : (messageId as String), + status: status, + ); } final class WsInvalid extends WsMessage { @@ -821,33 +817,33 @@ final class WsInvalid extends WsMessage { static WsInvalidBuilder builder({ required String detail, String? messageId, - }) => WsInvalidBuilder( - detail: detail, - messageId: messageId == null ? null : (messageId as String), - ); + }) => + WsInvalidBuilder( + detail: detail, + messageId: messageId == null ? null : (messageId as String), + ); WsInvalidBuilder toBuilder() => WsInvalidBuilder( - detail: detail, - messageId: messageId == null ? null : (messageId as String), - ); + detail: detail, + messageId: messageId == null ? null : (messageId as String), + ); @override Map toJson() => { - "detail": detail, - "message_id": messageId, - "type": "et-invalid", - }; + "detail": detail, + "message_id": messageId, + "type": "et-invalid", + }; factory WsInvalid.fromJson(Map json) => WsInvalid( - detail: json["detail"] as String, - messageId: json["message_id"] == null - ? null - : json["message_id"] == null - ? null - : json["message_id"] as String, - ); + detail: json["detail"] as String, + messageId: json["message_id"] == null + ? null + : json["message_id"] == null + ? null + : json["message_id"] as String, + ); @override - String toString() => - "WsInvalid(" + String toString() => "WsInvalid(" "detail: $detail, " "messageId: $messageId" ")"; @@ -880,9 +876,9 @@ final class WsInvalidBuilder extends WsMessageBuilder { WsInvalidBuilder({required this.detail, required this.messageId}) : super(); WsInvalid build() => WsInvalid( - detail: detail, - messageId: messageId == null ? null : (messageId as String), - ); + detail: detail, + messageId: messageId == null ? null : (messageId as String), + ); } final class WsClientEvent extends WsMessage { @@ -900,35 +896,35 @@ final class WsClientEvent extends WsMessage { required String action, required String capability, required Map details, - }) => WsClientEventBuilder( - action: action, - capability: capability, - details: details.map((key, value) => MapEntry(key, value)), - ); + }) => + WsClientEventBuilder( + action: action, + capability: capability, + details: details.map((key, value) => MapEntry(key, value)), + ); WsClientEventBuilder toBuilder() => WsClientEventBuilder( - action: action, - capability: capability, - details: details.map((key, value) => MapEntry(key, value)), - ); + action: action, + capability: capability, + details: details.map((key, value) => MapEntry(key, value)), + ); @override Map toJson() => { - "action": action, - "capability": capability, - "details": details.map((key, value) => MapEntry(key, value)), - "type": "et-client-event", - }; + "action": action, + "capability": capability, + "details": details.map((key, value) => MapEntry(key, value)), + "type": "et-client-event", + }; factory WsClientEvent.fromJson(Map json) => WsClientEvent( - action: json["action"] as String, - capability: json["capability"] as String, - details: (json["details"] as Map).map( - (key, value) => MapEntry(key as String, value as dynamic), - ), - ); + action: json["action"] as String, + capability: json["capability"] as String, + details: (json["details"] as Map).map( + (key, value) => MapEntry(key as String, value as dynamic), + ), + ); @override - String toString() => - "WsClientEvent(" + String toString() => "WsClientEvent(" "action: $action, " "capability: $capability, " "details: $details" @@ -960,12 +956,12 @@ final class WsClientEvent extends WsMessage { @override int get hashCode => Object.hashAll([ - action.hashCode, - capability.hashCode, - Object.hashAll( - details.entries.expand((entry) => [entry.key, entry.value.hashCode]), - ), - ]); + action.hashCode, + capability.hashCode, + Object.hashAll( + details.entries.expand((entry) => [entry.key, entry.value.hashCode]), + ), + ]); } /// Builder class for [WsClientEvent] @@ -981,10 +977,10 @@ final class WsClientEventBuilder extends WsMessageBuilder { }) : super(); WsClientEvent build() => WsClientEvent( - action: action, - capability: capability, - details: details.map((key, value) => MapEntry(key, value)), - ); + action: action, + capability: capability, + details: details.map((key, value) => MapEntry(key, value)), + ); } final class WsResponse extends WsMessage { @@ -1002,8 +998,7 @@ final class WsResponse extends WsMessage { WsResponse(message: json["message"] as String); @override - String toString() => - "WsResponse(" + String toString() => "WsResponse(" "message: $message" ")"; @override @@ -1038,20 +1033,20 @@ enum AgentConnectionState { disconnected; factory AgentConnectionState.fromJson(dynamic json) => switch (json) { - "connected" => AgentConnectionState.connected, - "disconnected" => AgentConnectionState.disconnected, - final other => throw ArgumentError("Unknown variant: $other"), - }; + "connected" => AgentConnectionState.connected, + "disconnected" => AgentConnectionState.disconnected, + final other => throw ArgumentError("Unknown variant: $other"), + }; dynamic toJson() => switch (this) { - AgentConnectionState.connected => "connected", - AgentConnectionState.disconnected => "disconnected", - }; + AgentConnectionState.connected => "connected", + AgentConnectionState.disconnected => "disconnected", + }; @override String toString() => switch (this) { - AgentConnectionState.connected => "connected", - AgentConnectionState.disconnected => "disconnected", - }; + AgentConnectionState.connected => "connected", + AgentConnectionState.disconnected => "disconnected", + }; } enum ConnectStatus { @@ -1059,20 +1054,20 @@ enum ConnectStatus { reconnected; factory ConnectStatus.fromJson(dynamic json) => switch (json) { - "assigned" => ConnectStatus.assigned, - "reconnected" => ConnectStatus.reconnected, - final other => throw ArgumentError("Unknown variant: $other"), - }; + "assigned" => ConnectStatus.assigned, + "reconnected" => ConnectStatus.reconnected, + final other => throw ArgumentError("Unknown variant: $other"), + }; dynamic toJson() => switch (this) { - ConnectStatus.assigned => "assigned", - ConnectStatus.reconnected => "reconnected", - }; + ConnectStatus.assigned => "assigned", + ConnectStatus.reconnected => "reconnected", + }; @override String toString() => switch (this) { - ConnectStatus.assigned => "assigned", - ConnectStatus.reconnected => "reconnected", - }; + ConnectStatus.assigned => "assigned", + ConnectStatus.reconnected => "reconnected", + }; } enum MessageDeliveryStatus { @@ -1082,26 +1077,26 @@ enum MessageDeliveryStatus { broadcast; factory MessageDeliveryStatus.fromJson(dynamic json) => switch (json) { - "delivered" => MessageDeliveryStatus.delivered, - "queued" => MessageDeliveryStatus.queued, - "acknowledged" => MessageDeliveryStatus.acknowledged, - "broadcast" => MessageDeliveryStatus.broadcast, - final other => throw ArgumentError("Unknown variant: $other"), - }; + "delivered" => MessageDeliveryStatus.delivered, + "queued" => MessageDeliveryStatus.queued, + "acknowledged" => MessageDeliveryStatus.acknowledged, + "broadcast" => MessageDeliveryStatus.broadcast, + final other => throw ArgumentError("Unknown variant: $other"), + }; dynamic toJson() => switch (this) { - MessageDeliveryStatus.delivered => "delivered", - MessageDeliveryStatus.queued => "queued", - MessageDeliveryStatus.acknowledged => "acknowledged", - MessageDeliveryStatus.broadcast => "broadcast", - }; + MessageDeliveryStatus.delivered => "delivered", + MessageDeliveryStatus.queued => "queued", + MessageDeliveryStatus.acknowledged => "acknowledged", + MessageDeliveryStatus.broadcast => "broadcast", + }; @override String toString() => switch (this) { - MessageDeliveryStatus.delivered => "delivered", - MessageDeliveryStatus.queued => "queued", - MessageDeliveryStatus.acknowledged => "acknowledged", - MessageDeliveryStatus.broadcast => "broadcast", - }; + MessageDeliveryStatus.delivered => "delivered", + MessageDeliveryStatus.queued => "queued", + MessageDeliveryStatus.acknowledged => "acknowledged", + MessageDeliveryStatus.broadcast => "broadcast", + }; } enum MessageScope { @@ -1109,18 +1104,18 @@ enum MessageScope { broadcast; factory MessageScope.fromJson(dynamic json) => switch (json) { - "direct" => MessageScope.direct, - "broadcast" => MessageScope.broadcast, - final other => throw ArgumentError("Unknown variant: $other"), - }; + "direct" => MessageScope.direct, + "broadcast" => MessageScope.broadcast, + final other => throw ArgumentError("Unknown variant: $other"), + }; dynamic toJson() => switch (this) { - MessageScope.direct => "direct", - MessageScope.broadcast => "broadcast", - }; + MessageScope.direct => "direct", + MessageScope.broadcast => "broadcast", + }; @override String toString() => switch (this) { - MessageScope.direct => "direct", - MessageScope.broadcast => "broadcast", - }; + MessageScope.direct => "direct", + MessageScope.broadcast => "broadcast", + }; } diff --git a/generated/rust-rest/src/lib.rs b/generated/rust-rest/src/lib.rs index 00c632f..c62b861 100644 --- a/generated/rust-rest/src/lib.rs +++ b/generated/rust-rest/src/lib.rs @@ -1,7 +1,7 @@ #[allow(unused_imports)] pub use progenitor_client::{ByteStream, ClientInfo, Error, ResponseValue}; #[allow(unused_imports)] -use progenitor_client::{encode_path, ClientHooks, OperationInfo, RequestBuilderExt}; +use progenitor_client::{ClientHooks, OperationInfo, RequestBuilderExt, encode_path}; /// Types used as operation parameters and responses. #[allow(clippy::all)] pub mod types { @@ -11,18 +11,12 @@ pub mod types { pub struct ConversionError(::std::borrow::Cow<'static, str>); impl ::std::error::Error for ConversionError {} impl ::std::fmt::Display for ConversionError { - fn fmt( - &self, - f: &mut ::std::fmt::Formatter<'_>, - ) -> Result<(), ::std::fmt::Error> { + fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> Result<(), ::std::fmt::Error> { ::std::fmt::Display::fmt(&self.0, f) } } impl ::std::fmt::Debug for ConversionError { - fn fmt( - &self, - f: &mut ::std::fmt::Formatter<'_>, - ) -> Result<(), ::std::fmt::Error> { + fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> Result<(), ::std::fmt::Error> { ::std::fmt::Debug::fmt(&self.0, f) } } @@ -124,17 +118,14 @@ impl ClientHooks<()> for &Client {} impl Client { /**Sends a `GET` request to `/health` -*/ - pub async fn health<'a>( - &'a self, - ) -> Result, Error<()>> { + */ + pub async fn health<'a>(&'a self) -> Result, Error<()>> { let url = format!("{}/health", self.baseurl,); let mut header_map = ::reqwest::header::HeaderMap::with_capacity(1usize); - header_map - .append( - ::reqwest::header::HeaderName::from_static("api-version"), - ::reqwest::header::HeaderValue::from_static(Self::api_version()), - ); + header_map.append( + ::reqwest::header::HeaderName::from_static("api-version"), + ::reqwest::header::HeaderValue::from_static(Self::api_version()), + ); #[allow(unused_mut)] let mut request = self .client @@ -145,9 +136,7 @@ impl Client { ) .headers(header_map) .build()?; - let info = OperationInfo { - operation_id: "health", - }; + let info = OperationInfo { operation_id: "health" }; match (|request: &mut ::reqwest::Request| { #[cfg(feature = "tracing")] { @@ -155,20 +144,14 @@ impl Client { &::tracing::Span::current(), ); ::opentelemetry::global::get_text_map_propagator(|propagator| { - propagator - .inject_context( - &cx, - &mut ::opentelemetry_http::HeaderInjector( - request.headers_mut(), - ), - ); + propagator.inject_context(&cx, &mut ::opentelemetry_http::HeaderInjector(request.headers_mut())); }); } #[cfg(not(feature = "tracing"))] let _ = request; async { Ok::<(), ::std::convert::Infallible>(()) } })(&mut request) - .await + .await { Ok(_) => {} Err(e) => return Err(Error::Custom(e.to_string())), @@ -184,17 +167,16 @@ impl Client { } /**Sends a `GET` request to `/modules/` -*/ + */ pub async fn list_modules_handler<'a>( &'a self, ) -> Result>, Error<()>> { let url = format!("{}/modules/", self.baseurl,); let mut header_map = ::reqwest::header::HeaderMap::with_capacity(1usize); - header_map - .append( - ::reqwest::header::HeaderName::from_static("api-version"), - ::reqwest::header::HeaderValue::from_static(Self::api_version()), - ); + header_map.append( + ::reqwest::header::HeaderName::from_static("api-version"), + ::reqwest::header::HeaderValue::from_static(Self::api_version()), + ); #[allow(unused_mut)] let mut request = self .client @@ -215,20 +197,14 @@ impl Client { &::tracing::Span::current(), ); ::opentelemetry::global::get_text_map_propagator(|propagator| { - propagator - .inject_context( - &cx, - &mut ::opentelemetry_http::HeaderInjector( - request.headers_mut(), - ), - ); + propagator.inject_context(&cx, &mut ::opentelemetry_http::HeaderInjector(request.headers_mut())); }); } #[cfg(not(feature = "tracing"))] let _ = request; async { Ok::<(), ::std::convert::Infallible>(()) } })(&mut request) - .await + .await { Ok(_) => {} Err(e) => return Err(Error::Custom(e.to_string())), @@ -243,31 +219,32 @@ impl Client { } } /**OpenAPI placeholder for `GET /modules/{name}/{path}`. `Files::new(...)` in -`configure()` actually serves these requests; this fn exists only so -utoipa has somewhere to attach `#[utoipa::path]`, gated by the -`openapi-spec` feature + `configure()` actually serves these requests; this fn exists only so + utoipa has somewhere to attach `#[utoipa::path]`, gated by the + `openapi-spec` feature -Sends a `GET` request to `/modules/{name}/{path}` + Sends a `GET` request to `/modules/{name}/{path}` -Arguments: -- `name`: Module name -- `path`: Relative path of the file under the module's pkg/ dir -*/ + Arguments: + - `name`: Module name + - `path`: Relative path of the file under the module's pkg/ dir + */ pub async fn get_module_file<'a>( &'a self, name: &'a str, path: &'a str, ) -> Result, Error<()>> { let url = format!( - "{}/modules/{}/{}", self.baseurl, encode_path(& name.to_string()), - encode_path(& path.to_string()), + "{}/modules/{}/{}", + self.baseurl, + encode_path(&name.to_string()), + encode_path(&path.to_string()), ); let mut header_map = ::reqwest::header::HeaderMap::with_capacity(1usize); - header_map - .append( - ::reqwest::header::HeaderName::from_static("api-version"), - ::reqwest::header::HeaderValue::from_static(Self::api_version()), - ); + header_map.append( + ::reqwest::header::HeaderName::from_static("api-version"), + ::reqwest::header::HeaderValue::from_static(Self::api_version()), + ); #[allow(unused_mut)] let mut request = self.client.get(url).headers(header_map).build()?; let info = OperationInfo { @@ -280,20 +257,14 @@ Arguments: &::tracing::Span::current(), ); ::opentelemetry::global::get_text_map_propagator(|propagator| { - propagator - .inject_context( - &cx, - &mut ::opentelemetry_http::HeaderInjector( - request.headers_mut(), - ), - ); + propagator.inject_context(&cx, &mut ::opentelemetry_http::HeaderInjector(request.headers_mut())); }); } #[cfg(not(feature = "tracing"))] let _ = request; async { Ok::<(), ::std::convert::Infallible>(()) } })(&mut request) - .await + .await { Ok(_) => {} Err(e) => return Err(Error::Custom(e.to_string())), @@ -309,31 +280,32 @@ Arguments: } } /**OpenAPI placeholder for `GET /storage/{agent_id}/{filename}`. `Files::new(...)` -in `configure()` actually serves these requests; this fn exists only so -utoipa has somewhere to attach `#[utoipa::path]`, so it's behind the -`openapi-spec` feature + in `configure()` actually serves these requests; this fn exists only so + utoipa has somewhere to attach `#[utoipa::path]`, so it's behind the + `openapi-spec` feature -Sends a `GET` request to `/storage/{agent_id}/{filename}` + Sends a `GET` request to `/storage/{agent_id}/{filename}` -Arguments: -- `agent_id`: Agent identifier -- `filename`: Stored filename -*/ + Arguments: + - `agent_id`: Agent identifier + - `filename`: Stored filename + */ pub async fn get_file<'a>( &'a self, agent_id: &'a str, filename: &'a str, ) -> Result, Error<()>> { let url = format!( - "{}/storage/{}/{}", self.baseurl, encode_path(& agent_id.to_string()), - encode_path(& filename.to_string()), + "{}/storage/{}/{}", + self.baseurl, + encode_path(&agent_id.to_string()), + encode_path(&filename.to_string()), ); let mut header_map = ::reqwest::header::HeaderMap::with_capacity(1usize); - header_map - .append( - ::reqwest::header::HeaderName::from_static("api-version"), - ::reqwest::header::HeaderValue::from_static(Self::api_version()), - ); + header_map.append( + ::reqwest::header::HeaderName::from_static("api-version"), + ::reqwest::header::HeaderValue::from_static(Self::api_version()), + ); #[allow(unused_mut)] let mut request = self.client.get(url).headers(header_map).build()?; let info = OperationInfo { @@ -346,20 +318,14 @@ Arguments: &::tracing::Span::current(), ); ::opentelemetry::global::get_text_map_propagator(|propagator| { - propagator - .inject_context( - &cx, - &mut ::opentelemetry_http::HeaderInjector( - request.headers_mut(), - ), - ); + propagator.inject_context(&cx, &mut ::opentelemetry_http::HeaderInjector(request.headers_mut())); }); } #[cfg(not(feature = "tracing"))] let _ = request; async { Ok::<(), ::std::convert::Infallible>(()) } })(&mut request) - .await + .await { Ok(_) => {} Err(e) => return Err(Error::Custom(e.to_string())), @@ -376,11 +342,11 @@ Arguments: } /**Sends a `PUT` request to `/storage/{agent_id}/{filename}` -Arguments: -- `agent_id`: Agent identifier (must be a connected agent) -- `filename`: Single-segment filename to write -- `body`: Raw file bytes -*/ + Arguments: + - `agent_id`: Agent identifier (must be a connected agent) + - `filename`: Single-segment filename to write + - `body`: Raw file bytes + */ pub async fn put_file<'a, B: Into>( &'a self, agent_id: &'a str, @@ -388,15 +354,16 @@ Arguments: body: B, ) -> Result, Error<()>> { let url = format!( - "{}/storage/{}/{}", self.baseurl, encode_path(& agent_id.to_string()), - encode_path(& filename.to_string()), + "{}/storage/{}/{}", + self.baseurl, + encode_path(&agent_id.to_string()), + encode_path(&filename.to_string()), ); let mut header_map = ::reqwest::header::HeaderMap::with_capacity(1usize); - header_map - .append( - ::reqwest::header::HeaderName::from_static("api-version"), - ::reqwest::header::HeaderValue::from_static(Self::api_version()), - ); + header_map.append( + ::reqwest::header::HeaderName::from_static("api-version"), + ::reqwest::header::HeaderValue::from_static(Self::api_version()), + ); #[allow(unused_mut)] let mut request = self .client @@ -418,20 +385,14 @@ Arguments: &::tracing::Span::current(), ); ::opentelemetry::global::get_text_map_propagator(|propagator| { - propagator - .inject_context( - &cx, - &mut ::opentelemetry_http::HeaderInjector( - request.headers_mut(), - ), - ); + propagator.inject_context(&cx, &mut ::opentelemetry_http::HeaderInjector(request.headers_mut())); }); } #[cfg(not(feature = "tracing"))] let _ = request; async { Ok::<(), ::std::convert::Infallible>(()) } })(&mut request) - .await + .await { Ok(_) => {} Err(e) => return Err(Error::Custom(e.to_string())), diff --git a/generated/zig-rest/src/et_rest_client.zig b/generated/zig-rest/src/et_rest_client.zig index d6d69ae..01ebb88 100644 --- a/generated/zig-rest/src/et_rest_client.zig +++ b/generated/zig-rest/src/et_rest_client.zig @@ -9,12 +9,10 @@ pub const HealthResponse = struct { service: []const u8, }; - /////////////////////////////////////////// // Generated Zig API client from OpenAPI /////////////////////////////////////////// - pub fn Owned(comptime T: type) type { return struct { allocator: std.mem.Allocator, @@ -140,10 +138,14 @@ pub fn requestRaw(client: *Client, method: std.http.Method, url: []const u8, pay const body_slice = payload orelse ""; const response_buf = try allocator.alloc(u8, 64 * 1024); const written = js_rest_request( - method_str.ptr, method_str.len, - url.ptr, url.len, - body_slice.ptr, body_slice.len, - response_buf.ptr, response_buf.len, + method_str.ptr, + method_str.len, + url.ptr, + url.len, + body_slice.ptr, + body_slice.len, + response_buf.ptr, + response_buf.len, ); if (written < 0) { allocator.free(response_buf); @@ -443,7 +445,7 @@ pub fn get_fileRaw(client: *Client, agent_id: []const u8, filename: []const u8) const allocator = client.allocator; var uri_buf: std.Io.Writer.Allocating = .init(allocator); defer uri_buf.deinit(); - try uri_buf.writer.print("{s}/storage/{s}/{s}", .{client.base_url, agent_id, filename}); + try uri_buf.writer.print("{s}/storage/{s}/{s}", .{ client.base_url, agent_id, filename }); const payload: ?[]const u8 = null; return requestRaw(client, std.http.Method.GET, uri_buf.written(), payload); @@ -459,7 +461,7 @@ pub fn put_fileRaw(client: *Client, agent_id: []const u8, filename: []const u8, const allocator = client.allocator; var uri_buf: std.Io.Writer.Allocating = .init(allocator); defer uri_buf.deinit(); - try uri_buf.writer.print("{s}/storage/{s}/{s}", .{client.base_url, agent_id, filename}); + try uri_buf.writer.print("{s}/storage/{s}/{s}", .{ client.base_url, agent_id, filename }); const payload: ?[]const u8 = requestBody; @@ -483,13 +485,12 @@ pub fn get_module_fileRaw(client: *Client, name: []const u8, path: []const u8) ! const allocator = client.allocator; var uri_buf: std.Io.Writer.Allocating = .init(allocator); defer uri_buf.deinit(); - try uri_buf.writer.print("{s}/modules/{s}/{s}", .{client.base_url, name, path}); + try uri_buf.writer.print("{s}/modules/{s}/{s}", .{ client.base_url, name, path }); const payload: ?[]const u8 = null; return requestRaw(client, std.http.Method.GET, uri_buf.written(), payload); } - /// Host-provided HTTP transport. The JS shim implements this against /// browser `fetch()` (via SharedArrayBuffer + Atomics so this looks /// synchronous to Zig). Returns the number of bytes written to From 92810ef681d3c5eb66d98be48bb90c347001abfc Mon Sep 17 00:00:00 2001 From: John Vandenberg Date: Thu, 21 May 2026 06:55:01 +0800 Subject: [PATCH 12/13] fix dart reformatting --- .mise.toml | 17 +- generated/dart-ws/lib/ws_messages.dart | 539 +++++++++++++------------ 2 files changed, 287 insertions(+), 269 deletions(-) diff --git a/.mise.toml b/.mise.toml index bc1e78a..90b5e9e 100644 --- a/.mise.toml +++ b/.mise.toml @@ -105,16 +105,29 @@ run = "dprint fmt" depends = ["cargo-clippy-fix", "cargo-fmt", "dart-fmt", "dotnet-fmt", "dprint-fmt", "ruff-fmt", "taplo-fmt", "zig-fmt"] description = "Run repository formatters" -[tasks.dart-check] +[tasks.dart-pub-get] +# `dart format` and `dart analyze` walk up to find pubspec.yaml *and* read +# `.dart_tool/package_config.json` if present. With package_config.json, the +# formatter picks the package's exact language version; without it, it +# falls back to a different default — same source file, different output. +# That mismatch is invisible locally (we accumulate .dart_tool/ over time) +# but bites CI's fresh checkout. Run `dart pub get` for every Dart package +# before any dart-* task so .dart_tool/ exists and the format is stable. run = """ +dart pub get --directory generated/dart-ws dart pub get --directory services/ws-modules/dart-comm1 -dart analyze services/ws-modules/dart-comm1/ generated/dart-ws/ """ +[tasks.dart-check] +depends = ["dart-pub-get"] +run = "dart analyze services/ws-modules/dart-comm1/ generated/dart-ws/" + [tasks.dart-fmt] +depends = ["dart-pub-get"] run = "dart format services/ws-modules/dart-comm1/ generated/dart-ws/" [tasks.dart-fmt-check] +depends = ["dart-pub-get"] # Parallel of cargo-fmt-check: fail if any tracked Dart source would be # reformatted, so CI enforces the same formatting `dart-fmt` applies. run = "dart format --output=none --set-exit-if-changed services/ws-modules/dart-comm1/ generated/dart-ws/" diff --git a/generated/dart-ws/lib/ws_messages.dart b/generated/dart-ws/lib/ws_messages.dart index 6c14f69..21cf092 100644 --- a/generated/dart-ws/lib/ws_messages.dart +++ b/generated/dart-ws/lib/ws_messages.dart @@ -14,35 +14,35 @@ final class AgentSummary { required String agentId, String? lastKnownIp, required AgentConnectionState state, - }) => - AgentSummaryBuilder( - agentId: agentId, - lastKnownIp: lastKnownIp == null ? null : (lastKnownIp as String), - state: state, - ); + }) => AgentSummaryBuilder( + agentId: agentId, + lastKnownIp: lastKnownIp == null ? null : (lastKnownIp as String), + state: state, + ); AgentSummaryBuilder toBuilder() => AgentSummaryBuilder( - agentId: agentId, - lastKnownIp: lastKnownIp == null ? null : (lastKnownIp as String), - state: state, - ); + agentId: agentId, + lastKnownIp: lastKnownIp == null ? null : (lastKnownIp as String), + state: state, + ); Map toJson() => { - "agent_id": agentId, - "last_known_ip": lastKnownIp, - "state": state.toJson(), - }; + "agent_id": agentId, + "last_known_ip": lastKnownIp, + "state": state.toJson(), + }; factory AgentSummary.fromJson(Map json) => AgentSummary( - agentId: json["agent_id"] as String, - lastKnownIp: json["last_known_ip"] == null - ? null - : json["last_known_ip"] == null - ? null - : json["last_known_ip"] as String, - state: AgentConnectionState.fromJson(json["state"]), - ); + agentId: json["agent_id"] as String, + lastKnownIp: json["last_known_ip"] == null + ? null + : json["last_known_ip"] == null + ? null + : json["last_known_ip"] as String, + state: AgentConnectionState.fromJson(json["state"]), + ); @override - String toString() => "AgentSummary(" + String toString() => + "AgentSummary(" "agentId: $agentId, " "lastKnownIp: $lastKnownIp, " "state: $state" @@ -85,10 +85,10 @@ final class AgentSummaryBuilder { }); AgentSummary build() => AgentSummary( - agentId: agentId, - lastKnownIp: lastKnownIp == null ? null : (lastKnownIp as String), - state: state, - ); + agentId: agentId, + lastKnownIp: lastKnownIp == null ? null : (lastKnownIp as String), + state: state, + ); } sealed class WsMessage { @@ -133,15 +133,16 @@ final class WsConnect extends WsMessage { @override Map toJson() => {"agent_id": agentId, "type": "et-connect"}; factory WsConnect.fromJson(Map json) => WsConnect( - agentId: json["agent_id"] == null - ? null - : json["agent_id"] == null - ? null - : json["agent_id"] as String, - ); + agentId: json["agent_id"] == null + ? null + : json["agent_id"] == null + ? null + : json["agent_id"] as String, + ); @override - String toString() => "WsConnect(" + String toString() => + "WsConnect(" "agentId: $agentId" ")"; @override @@ -181,24 +182,24 @@ final class WsConnectAck extends WsMessage { static WsConnectAckBuilder builder({ required String agentId, required ConnectStatus status, - }) => - WsConnectAckBuilder(agentId: agentId, status: status); + }) => WsConnectAckBuilder(agentId: agentId, status: status); WsConnectAckBuilder toBuilder() => WsConnectAckBuilder(agentId: agentId, status: status); @override Map toJson() => { - "agent_id": agentId, - "status": status.toJson(), - "type": "et-connect-ack", - }; + "agent_id": agentId, + "status": status.toJson(), + "type": "et-connect-ack", + }; factory WsConnectAck.fromJson(Map json) => WsConnectAck( - agentId: json["agent_id"] as String, - status: ConnectStatus.fromJson(json["status"]), - ); + agentId: json["agent_id"] as String, + status: ConnectStatus.fromJson(json["status"]), + ); @override - String toString() => "WsConnectAck(" + String toString() => + "WsConnectAck(" "agentId: $agentId, " "status: $status" ")"; @@ -248,7 +249,8 @@ final class WsAlive extends WsMessage { WsAlive(timestamp: json["timestamp"] as String); @override - String toString() => "WsAlive(" + String toString() => + "WsAlive(" "timestamp: $timestamp" ")"; @override @@ -289,7 +291,8 @@ final class WsListAgents extends WsMessage { factory WsListAgents.fromJson(Map json) => WsListAgents(); @override - String toString() => "WsListAgents(" + String toString() => + "WsListAgents(" ")"; @override bool operator ==(Object other) { @@ -320,19 +323,18 @@ final class WsListAgentsResponse extends WsMessage { static WsListAgentsResponseBuilder builder({ required List agents, - }) => - WsListAgentsResponseBuilder( - agents: agents.map((elem) => elem.toBuilder()).toList(), - ); + }) => WsListAgentsResponseBuilder( + agents: agents.map((elem) => elem.toBuilder()).toList(), + ); WsListAgentsResponseBuilder toBuilder() => WsListAgentsResponseBuilder( - agents: agents.map((elem) => elem.toBuilder()).toList(), - ); + agents: agents.map((elem) => elem.toBuilder()).toList(), + ); @override Map toJson() => { - "agents": agents.map((inner) => inner.toJson()).toList(), - "type": "et-list-agents-response", - }; + "agents": agents.map((inner) => inner.toJson()).toList(), + "type": "et-list-agents-response", + }; factory WsListAgentsResponse.fromJson(Map json) => WsListAgentsResponse( agents: (json["agents"] as List) @@ -343,7 +345,8 @@ final class WsListAgentsResponse extends WsMessage { ); @override - String toString() => "WsListAgentsResponse(" + String toString() => + "WsListAgentsResponse(" "agents: $agents" ")"; @override @@ -385,27 +388,26 @@ final class WsSendAgentMessage extends WsMessage { final String toAgentId; const WsSendAgentMessage({required this.message, required this.toAgentId}) - : super(); + : super(); static WsSendAgentMessageBuilder builder({ required Map message, required String toAgentId, - }) => - WsSendAgentMessageBuilder( - message: message.map((key, value) => MapEntry(key, value)), - toAgentId: toAgentId, - ); + }) => WsSendAgentMessageBuilder( + message: message.map((key, value) => MapEntry(key, value)), + toAgentId: toAgentId, + ); WsSendAgentMessageBuilder toBuilder() => WsSendAgentMessageBuilder( - message: message.map((key, value) => MapEntry(key, value)), - toAgentId: toAgentId, - ); + message: message.map((key, value) => MapEntry(key, value)), + toAgentId: toAgentId, + ); @override Map toJson() => { - "message": message.map((key, value) => MapEntry(key, value)), - "to_agent_id": toAgentId, - "type": "et-send-agent-message", - }; + "message": message.map((key, value) => MapEntry(key, value)), + "to_agent_id": toAgentId, + "type": "et-send-agent-message", + }; factory WsSendAgentMessage.fromJson(Map json) => WsSendAgentMessage( message: (json["message"] as Map).map( @@ -415,7 +417,8 @@ final class WsSendAgentMessage extends WsMessage { ); @override - String toString() => "WsSendAgentMessage(" + String toString() => + "WsSendAgentMessage(" "message: $message, " "toAgentId: $toAgentId" ")"; @@ -443,11 +446,11 @@ final class WsSendAgentMessage extends WsMessage { @override int get hashCode => Object.hashAll([ - Object.hashAll( - message.entries.expand((entry) => [entry.key, entry.value.hashCode]), - ), - toAgentId.hashCode, - ]); + Object.hashAll( + message.entries.expand((entry) => [entry.key, entry.value.hashCode]), + ), + toAgentId.hashCode, + ]); } /// Builder class for [WsSendAgentMessage] @@ -456,12 +459,12 @@ final class WsSendAgentMessageBuilder extends WsMessageBuilder { String toAgentId; WsSendAgentMessageBuilder({required this.message, required this.toAgentId}) - : super(); + : super(); WsSendAgentMessage build() => WsSendAgentMessage( - message: message.map((key, value) => MapEntry(key, value)), - toAgentId: toAgentId, - ); + message: message.map((key, value) => MapEntry(key, value)), + toAgentId: toAgentId, + ); } final class WsBroadcastMessage extends WsMessage { @@ -471,19 +474,18 @@ final class WsBroadcastMessage extends WsMessage { static WsBroadcastMessageBuilder builder({ required Map message, - }) => - WsBroadcastMessageBuilder( - message: message.map((key, value) => MapEntry(key, value)), - ); + }) => WsBroadcastMessageBuilder( + message: message.map((key, value) => MapEntry(key, value)), + ); WsBroadcastMessageBuilder toBuilder() => WsBroadcastMessageBuilder( - message: message.map((key, value) => MapEntry(key, value)), - ); + message: message.map((key, value) => MapEntry(key, value)), + ); @override Map toJson() => { - "message": message.map((key, value) => MapEntry(key, value)), - "type": "et-broadcast-message", - }; + "message": message.map((key, value) => MapEntry(key, value)), + "type": "et-broadcast-message", + }; factory WsBroadcastMessage.fromJson(Map json) => WsBroadcastMessage( message: (json["message"] as Map).map( @@ -492,7 +494,8 @@ final class WsBroadcastMessage extends WsMessage { ); @override - String toString() => "WsBroadcastMessage(" + String toString() => + "WsBroadcastMessage(" "message: $message" ")"; @override @@ -516,10 +519,10 @@ final class WsBroadcastMessage extends WsMessage { @override int get hashCode => Object.hashAll([ - Object.hashAll( - message.entries.expand((entry) => [entry.key, entry.value.hashCode]), - ), - ]); + Object.hashAll( + message.entries.expand((entry) => [entry.key, entry.value.hashCode]), + ), + ]); } /// Builder class for [WsBroadcastMessage] @@ -529,8 +532,8 @@ final class WsBroadcastMessageBuilder extends WsMessageBuilder { WsBroadcastMessageBuilder({required this.message}) : super(); WsBroadcastMessage build() => WsBroadcastMessage( - message: message.map((key, value) => MapEntry(key, value)), - ); + message: message.map((key, value) => MapEntry(key, value)), + ); } final class WsAgentMessage extends WsMessage { @@ -554,43 +557,43 @@ final class WsAgentMessage extends WsMessage { required String messageId, required MessageScope scope, required String serverReceivedAt, - }) => - WsAgentMessageBuilder( - fromAgentId: fromAgentId, - message: message.map((key, value) => MapEntry(key, value)), - messageId: messageId, - scope: scope, - serverReceivedAt: serverReceivedAt, - ); + }) => WsAgentMessageBuilder( + fromAgentId: fromAgentId, + message: message.map((key, value) => MapEntry(key, value)), + messageId: messageId, + scope: scope, + serverReceivedAt: serverReceivedAt, + ); WsAgentMessageBuilder toBuilder() => WsAgentMessageBuilder( - fromAgentId: fromAgentId, - message: message.map((key, value) => MapEntry(key, value)), - messageId: messageId, - scope: scope, - serverReceivedAt: serverReceivedAt, - ); + fromAgentId: fromAgentId, + message: message.map((key, value) => MapEntry(key, value)), + messageId: messageId, + scope: scope, + serverReceivedAt: serverReceivedAt, + ); @override Map toJson() => { - "from_agent_id": fromAgentId, - "message": message.map((key, value) => MapEntry(key, value)), - "message_id": messageId, - "scope": scope.toJson(), - "server_received_at": serverReceivedAt, - "type": "et-agent-message", - }; + "from_agent_id": fromAgentId, + "message": message.map((key, value) => MapEntry(key, value)), + "message_id": messageId, + "scope": scope.toJson(), + "server_received_at": serverReceivedAt, + "type": "et-agent-message", + }; factory WsAgentMessage.fromJson(Map json) => WsAgentMessage( - fromAgentId: json["from_agent_id"] as String, - message: (json["message"] as Map).map( - (key, value) => MapEntry(key as String, value as dynamic), - ), - messageId: json["message_id"] as String, - scope: MessageScope.fromJson(json["scope"]), - serverReceivedAt: json["server_received_at"] as String, - ); + fromAgentId: json["from_agent_id"] as String, + message: (json["message"] as Map).map( + (key, value) => MapEntry(key as String, value as dynamic), + ), + messageId: json["message_id"] as String, + scope: MessageScope.fromJson(json["scope"]), + serverReceivedAt: json["server_received_at"] as String, + ); @override - String toString() => "WsAgentMessage(" + String toString() => + "WsAgentMessage(" "fromAgentId: $fromAgentId, " "message: $message, " "messageId: $messageId, " @@ -630,14 +633,14 @@ final class WsAgentMessage extends WsMessage { @override int get hashCode => Object.hashAll([ - fromAgentId.hashCode, - Object.hashAll( - message.entries.expand((entry) => [entry.key, entry.value.hashCode]), - ), - messageId.hashCode, - scope.hashCode, - serverReceivedAt.hashCode, - ]); + fromAgentId.hashCode, + Object.hashAll( + message.entries.expand((entry) => [entry.key, entry.value.hashCode]), + ), + messageId.hashCode, + scope.hashCode, + serverReceivedAt.hashCode, + ]); } /// Builder class for [WsAgentMessage] @@ -657,12 +660,12 @@ final class WsAgentMessageBuilder extends WsMessageBuilder { }) : super(); WsAgentMessage build() => WsAgentMessage( - fromAgentId: fromAgentId, - message: message.map((key, value) => MapEntry(key, value)), - messageId: messageId, - scope: scope, - serverReceivedAt: serverReceivedAt, - ); + fromAgentId: fromAgentId, + message: message.map((key, value) => MapEntry(key, value)), + messageId: messageId, + scope: scope, + serverReceivedAt: serverReceivedAt, + ); } final class WsMessageAck extends WsMessage { @@ -676,14 +679,15 @@ final class WsMessageAck extends WsMessage { @override Map toJson() => { - "message_id": messageId, - "type": "et-message-ack", - }; + "message_id": messageId, + "type": "et-message-ack", + }; factory WsMessageAck.fromJson(Map json) => WsMessageAck(messageId: json["message_id"] as String); @override - String toString() => "WsMessageAck(" + String toString() => + "WsMessageAck(" "messageId: $messageId" ")"; @override @@ -728,38 +732,38 @@ final class WsMessageStatus extends WsMessage { required String detail, String? messageId, required MessageDeliveryStatus status, - }) => - WsMessageStatusBuilder( - detail: detail, - messageId: messageId == null ? null : (messageId as String), - status: status, - ); + }) => WsMessageStatusBuilder( + detail: detail, + messageId: messageId == null ? null : (messageId as String), + status: status, + ); WsMessageStatusBuilder toBuilder() => WsMessageStatusBuilder( - detail: detail, - messageId: messageId == null ? null : (messageId as String), - status: status, - ); + detail: detail, + messageId: messageId == null ? null : (messageId as String), + status: status, + ); @override Map toJson() => { - "detail": detail, - "message_id": messageId, - "status": status.toJson(), - "type": "et-message-status", - }; + "detail": detail, + "message_id": messageId, + "status": status.toJson(), + "type": "et-message-status", + }; factory WsMessageStatus.fromJson(Map json) => WsMessageStatus( detail: json["detail"] as String, messageId: json["message_id"] == null ? null : json["message_id"] == null - ? null - : json["message_id"] as String, + ? null + : json["message_id"] as String, status: MessageDeliveryStatus.fromJson(json["status"]), ); @override - String toString() => "WsMessageStatus(" + String toString() => + "WsMessageStatus(" "detail: $detail, " "messageId: $messageId, " "status: $status" @@ -802,10 +806,10 @@ final class WsMessageStatusBuilder extends WsMessageBuilder { }) : super(); WsMessageStatus build() => WsMessageStatus( - detail: detail, - messageId: messageId == null ? null : (messageId as String), - status: status, - ); + detail: detail, + messageId: messageId == null ? null : (messageId as String), + status: status, + ); } final class WsInvalid extends WsMessage { @@ -817,33 +821,33 @@ final class WsInvalid extends WsMessage { static WsInvalidBuilder builder({ required String detail, String? messageId, - }) => - WsInvalidBuilder( - detail: detail, - messageId: messageId == null ? null : (messageId as String), - ); + }) => WsInvalidBuilder( + detail: detail, + messageId: messageId == null ? null : (messageId as String), + ); WsInvalidBuilder toBuilder() => WsInvalidBuilder( - detail: detail, - messageId: messageId == null ? null : (messageId as String), - ); + detail: detail, + messageId: messageId == null ? null : (messageId as String), + ); @override Map toJson() => { - "detail": detail, - "message_id": messageId, - "type": "et-invalid", - }; + "detail": detail, + "message_id": messageId, + "type": "et-invalid", + }; factory WsInvalid.fromJson(Map json) => WsInvalid( - detail: json["detail"] as String, - messageId: json["message_id"] == null - ? null - : json["message_id"] == null - ? null - : json["message_id"] as String, - ); + detail: json["detail"] as String, + messageId: json["message_id"] == null + ? null + : json["message_id"] == null + ? null + : json["message_id"] as String, + ); @override - String toString() => "WsInvalid(" + String toString() => + "WsInvalid(" "detail: $detail, " "messageId: $messageId" ")"; @@ -876,9 +880,9 @@ final class WsInvalidBuilder extends WsMessageBuilder { WsInvalidBuilder({required this.detail, required this.messageId}) : super(); WsInvalid build() => WsInvalid( - detail: detail, - messageId: messageId == null ? null : (messageId as String), - ); + detail: detail, + messageId: messageId == null ? null : (messageId as String), + ); } final class WsClientEvent extends WsMessage { @@ -896,35 +900,35 @@ final class WsClientEvent extends WsMessage { required String action, required String capability, required Map details, - }) => - WsClientEventBuilder( - action: action, - capability: capability, - details: details.map((key, value) => MapEntry(key, value)), - ); + }) => WsClientEventBuilder( + action: action, + capability: capability, + details: details.map((key, value) => MapEntry(key, value)), + ); WsClientEventBuilder toBuilder() => WsClientEventBuilder( - action: action, - capability: capability, - details: details.map((key, value) => MapEntry(key, value)), - ); + action: action, + capability: capability, + details: details.map((key, value) => MapEntry(key, value)), + ); @override Map toJson() => { - "action": action, - "capability": capability, - "details": details.map((key, value) => MapEntry(key, value)), - "type": "et-client-event", - }; + "action": action, + "capability": capability, + "details": details.map((key, value) => MapEntry(key, value)), + "type": "et-client-event", + }; factory WsClientEvent.fromJson(Map json) => WsClientEvent( - action: json["action"] as String, - capability: json["capability"] as String, - details: (json["details"] as Map).map( - (key, value) => MapEntry(key as String, value as dynamic), - ), - ); + action: json["action"] as String, + capability: json["capability"] as String, + details: (json["details"] as Map).map( + (key, value) => MapEntry(key as String, value as dynamic), + ), + ); @override - String toString() => "WsClientEvent(" + String toString() => + "WsClientEvent(" "action: $action, " "capability: $capability, " "details: $details" @@ -956,12 +960,12 @@ final class WsClientEvent extends WsMessage { @override int get hashCode => Object.hashAll([ - action.hashCode, - capability.hashCode, - Object.hashAll( - details.entries.expand((entry) => [entry.key, entry.value.hashCode]), - ), - ]); + action.hashCode, + capability.hashCode, + Object.hashAll( + details.entries.expand((entry) => [entry.key, entry.value.hashCode]), + ), + ]); } /// Builder class for [WsClientEvent] @@ -977,10 +981,10 @@ final class WsClientEventBuilder extends WsMessageBuilder { }) : super(); WsClientEvent build() => WsClientEvent( - action: action, - capability: capability, - details: details.map((key, value) => MapEntry(key, value)), - ); + action: action, + capability: capability, + details: details.map((key, value) => MapEntry(key, value)), + ); } final class WsResponse extends WsMessage { @@ -998,7 +1002,8 @@ final class WsResponse extends WsMessage { WsResponse(message: json["message"] as String); @override - String toString() => "WsResponse(" + String toString() => + "WsResponse(" "message: $message" ")"; @override @@ -1033,20 +1038,20 @@ enum AgentConnectionState { disconnected; factory AgentConnectionState.fromJson(dynamic json) => switch (json) { - "connected" => AgentConnectionState.connected, - "disconnected" => AgentConnectionState.disconnected, - final other => throw ArgumentError("Unknown variant: $other"), - }; + "connected" => AgentConnectionState.connected, + "disconnected" => AgentConnectionState.disconnected, + final other => throw ArgumentError("Unknown variant: $other"), + }; dynamic toJson() => switch (this) { - AgentConnectionState.connected => "connected", - AgentConnectionState.disconnected => "disconnected", - }; + AgentConnectionState.connected => "connected", + AgentConnectionState.disconnected => "disconnected", + }; @override String toString() => switch (this) { - AgentConnectionState.connected => "connected", - AgentConnectionState.disconnected => "disconnected", - }; + AgentConnectionState.connected => "connected", + AgentConnectionState.disconnected => "disconnected", + }; } enum ConnectStatus { @@ -1054,20 +1059,20 @@ enum ConnectStatus { reconnected; factory ConnectStatus.fromJson(dynamic json) => switch (json) { - "assigned" => ConnectStatus.assigned, - "reconnected" => ConnectStatus.reconnected, - final other => throw ArgumentError("Unknown variant: $other"), - }; + "assigned" => ConnectStatus.assigned, + "reconnected" => ConnectStatus.reconnected, + final other => throw ArgumentError("Unknown variant: $other"), + }; dynamic toJson() => switch (this) { - ConnectStatus.assigned => "assigned", - ConnectStatus.reconnected => "reconnected", - }; + ConnectStatus.assigned => "assigned", + ConnectStatus.reconnected => "reconnected", + }; @override String toString() => switch (this) { - ConnectStatus.assigned => "assigned", - ConnectStatus.reconnected => "reconnected", - }; + ConnectStatus.assigned => "assigned", + ConnectStatus.reconnected => "reconnected", + }; } enum MessageDeliveryStatus { @@ -1077,26 +1082,26 @@ enum MessageDeliveryStatus { broadcast; factory MessageDeliveryStatus.fromJson(dynamic json) => switch (json) { - "delivered" => MessageDeliveryStatus.delivered, - "queued" => MessageDeliveryStatus.queued, - "acknowledged" => MessageDeliveryStatus.acknowledged, - "broadcast" => MessageDeliveryStatus.broadcast, - final other => throw ArgumentError("Unknown variant: $other"), - }; + "delivered" => MessageDeliveryStatus.delivered, + "queued" => MessageDeliveryStatus.queued, + "acknowledged" => MessageDeliveryStatus.acknowledged, + "broadcast" => MessageDeliveryStatus.broadcast, + final other => throw ArgumentError("Unknown variant: $other"), + }; dynamic toJson() => switch (this) { - MessageDeliveryStatus.delivered => "delivered", - MessageDeliveryStatus.queued => "queued", - MessageDeliveryStatus.acknowledged => "acknowledged", - MessageDeliveryStatus.broadcast => "broadcast", - }; + MessageDeliveryStatus.delivered => "delivered", + MessageDeliveryStatus.queued => "queued", + MessageDeliveryStatus.acknowledged => "acknowledged", + MessageDeliveryStatus.broadcast => "broadcast", + }; @override String toString() => switch (this) { - MessageDeliveryStatus.delivered => "delivered", - MessageDeliveryStatus.queued => "queued", - MessageDeliveryStatus.acknowledged => "acknowledged", - MessageDeliveryStatus.broadcast => "broadcast", - }; + MessageDeliveryStatus.delivered => "delivered", + MessageDeliveryStatus.queued => "queued", + MessageDeliveryStatus.acknowledged => "acknowledged", + MessageDeliveryStatus.broadcast => "broadcast", + }; } enum MessageScope { @@ -1104,18 +1109,18 @@ enum MessageScope { broadcast; factory MessageScope.fromJson(dynamic json) => switch (json) { - "direct" => MessageScope.direct, - "broadcast" => MessageScope.broadcast, - final other => throw ArgumentError("Unknown variant: $other"), - }; + "direct" => MessageScope.direct, + "broadcast" => MessageScope.broadcast, + final other => throw ArgumentError("Unknown variant: $other"), + }; dynamic toJson() => switch (this) { - MessageScope.direct => "direct", - MessageScope.broadcast => "broadcast", - }; + MessageScope.direct => "direct", + MessageScope.broadcast => "broadcast", + }; @override String toString() => switch (this) { - MessageScope.direct => "direct", - MessageScope.broadcast => "broadcast", - }; + MessageScope.direct => "direct", + MessageScope.broadcast => "broadcast", + }; } From be8a2b8bf9d05aa81b326eb06dea5cd38abc12de Mon Sep 17 00:00:00 2001 From: John Vandenberg Date: Thu, 21 May 2026 07:10:08 +0800 Subject: [PATCH 13/13] python fmt --- .../api/et_modules_service/get_module_file.py | 12 ++++++------ .../api/et_storage_service/get_file.py | 8 ++++---- .../api/et_storage_service/put_file.py | 8 ++++++-- 3 files changed, 16 insertions(+), 12 deletions(-) diff --git a/generated/python-rest/et_rest_client/api/et_modules_service/get_module_file.py b/generated/python-rest/et_rest_client/api/et_modules_service/get_module_file.py index 0e29d74..52c7a69 100644 --- a/generated/python-rest/et_rest_client/api/et_modules_service/get_module_file.py +++ b/generated/python-rest/et_rest_client/api/et_modules_service/get_module_file.py @@ -54,9 +54,9 @@ def sync_detailed( client: AuthenticatedClient | Client, ) -> Response[Any]: """OpenAPI placeholder for `GET /modules/{name}/{path}`. `Files::new(...)` in - `configure()` actually serves these requests, but utoipa needs a function - to attach `#[utoipa::path]` to. The fn name shapes the generated client's - method name (via the OpenAPI `operationId`), so call it what callers want. + `configure()` actually serves these requests; this fn exists only so + utoipa has somewhere to attach `#[utoipa::path]`, gated by the + `openapi-spec` feature. Args: name (str): @@ -89,9 +89,9 @@ async def asyncio_detailed( client: AuthenticatedClient | Client, ) -> Response[Any]: """OpenAPI placeholder for `GET /modules/{name}/{path}`. `Files::new(...)` in - `configure()` actually serves these requests, but utoipa needs a function - to attach `#[utoipa::path]` to. The fn name shapes the generated client's - method name (via the OpenAPI `operationId`), so call it what callers want. + `configure()` actually serves these requests; this fn exists only so + utoipa has somewhere to attach `#[utoipa::path]`, gated by the + `openapi-spec` feature. Args: name (str): diff --git a/generated/python-rest/et_rest_client/api/et_storage_service/get_file.py b/generated/python-rest/et_rest_client/api/et_storage_service/get_file.py index b8826a2..c6bb244 100644 --- a/generated/python-rest/et_rest_client/api/et_storage_service/get_file.py +++ b/generated/python-rest/et_rest_client/api/et_storage_service/get_file.py @@ -55,8 +55,8 @@ def sync_detailed( ) -> Response[Any]: """OpenAPI placeholder for `GET /storage/{agent_id}/{filename}`. `Files::new(...)` in `configure()` actually serves these requests; this fn exists only so - utoipa has somewhere to attach `#[utoipa::path]`. The fn name flows through - the OpenAPI `operationId` into the generated client's method name. + utoipa has somewhere to attach `#[utoipa::path]`, so it's behind the + `openapi-spec` feature. Args: agent_id (str): @@ -90,8 +90,8 @@ async def asyncio_detailed( ) -> Response[Any]: """OpenAPI placeholder for `GET /storage/{agent_id}/{filename}`. `Files::new(...)` in `configure()` actually serves these requests; this fn exists only so - utoipa has somewhere to attach `#[utoipa::path]`. The fn name flows through - the OpenAPI `operationId` into the generated client's method name. + utoipa has somewhere to attach `#[utoipa::path]`, so it's behind the + `openapi-spec` feature. Args: agent_id (str): diff --git a/generated/python-rest/et_rest_client/api/et_storage_service/put_file.py b/generated/python-rest/et_rest_client/api/et_storage_service/put_file.py index b961dfc..c9337bd 100644 --- a/generated/python-rest/et_rest_client/api/et_storage_service/put_file.py +++ b/generated/python-rest/et_rest_client/api/et_storage_service/put_file.py @@ -70,7 +70,9 @@ def sync_detailed( agent_id (str): filename (str): body (File): Phantom type used to label binary request/response bodies as - `string`/`binary` in the OpenAPI document. Never constructed at runtime. + `string`/`binary` in the OpenAPI document. Never constructed at runtime; + only exists under the `openapi-spec` feature so the `utoipa::ToSchema` + derive has something to attach to. Raises: errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. @@ -105,7 +107,9 @@ async def asyncio_detailed( agent_id (str): filename (str): body (File): Phantom type used to label binary request/response bodies as - `string`/`binary` in the OpenAPI document. Never constructed at runtime. + `string`/`binary` in the OpenAPI document. Never constructed at runtime; + only exists under the `openapi-spec` feature so the `utoipa::ToSchema` + derive has something to attach to. Raises: errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.