diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index e42a48cd3c..cbd776dae7 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -150,6 +150,43 @@ jobs: artifact-name: unit-tests-report github-token: ${{ secrets.GITHUB_TOKEN }} + check-builtin-tools: + runs-on: blacksmith-8vcpu-ubuntu-2204 + needs: build-and-store + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 1 + - uses: ./.github/actions/setup-rust + with: + use-cache: 'false' + rust-targets: 'wasm32-wasip2' + install-cargo-binstall: 'true' + install-protoc: 'true' + - uses: ./.github/actions/restore-binaries + with: + run-id: ${{ github.run_id }} + copy-to-target: 'true' + - name: Setup MoonBit + uses: hustcer/setup-moonbit@v1 + with: + version: ${{ env.MOONBIT_INSTALL_VERSION }} + core-version: ${{ env.MOONBIT_INSTALL_VERSION }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Install wasm-tools + run: cargo binstall --force --locked wasm-tools@1.248.0 + - name: Update MoonBit workspace dependencies + run: moon update + working-directory: sdks/moonbit + - name: Build built-in tools + run: cargo make --profile ci build-builtin-tools + - name: Validate committed built-in tool artifacts + run: | + wasm-tools validate --features all plugins/filesystem-tools-rust.wasm + wasm-tools validate --features all plugins/filesystem-tools-moonbit.wasm + git diff --exit-code -- plugins/filesystem-tools-rust plugins/filesystem-tools-rust.wasm plugins/filesystem-tools-moonbit plugins/filesystem-tools-moonbit.wasm + worker-tests: env: CARGO_BUILD_JOBS: 10 @@ -789,10 +826,10 @@ jobs: run: moon test cmd lib working-directory: sdks/moonbit/golem_sdk_tools - # The Rust SDK test suite is dominated by three integration test targets that each shell out to - # cargo (tool: one `cargo check` per test, tool_middleware_component: a full wasm component - # build, ui: trybuild). They run as separate matrix entries; the `check` entry runs lint and every - # other test target and fails if a test target is added without being assigned to an entry. + # The Rust SDK test suite is dominated by two integration test targets that each shell out to + # cargo (tool: one `cargo check` per test, ui: trybuild). They run as separate matrix entries; + # the `check` entry runs lint and every other test target, and fails if a test target is added + # without being assigned to an entry. build-golem-rust: name: build-golem-rust (${{ matrix.part.name }}) runs-on: blacksmith-16vcpu-ubuntu-2204 @@ -804,8 +841,6 @@ jobs: test-args: --lib --bins --test agent --test http_router --test tool_canonical --test schema_reexport --test reflection_recipe_compile - name: tool test-args: --test tool - - name: tool-middleware-component - test-args: --test tool_middleware_component - name: ui test-args: --test ui steps: @@ -836,7 +871,6 @@ jobs: schema_reexport tool tool_canonical - tool_middleware_component ui' actual=$(cargo metadata --no-deps --format-version 1 \ | jq -r '.packages[].targets[] | select(.kind | index("test")) | .name' | sort) diff --git a/.github/workflows/component-size.yaml b/.github/workflows/component-size.yaml new file mode 100644 index 0000000000..18f983e337 --- /dev/null +++ b/.github/workflows/component-size.yaml @@ -0,0 +1,85 @@ +name: Component size (informational) + +on: + workflow_dispatch: + pull_request: + paths: + - '.github/workflows/component-size.yaml' + - 'test-components/size-analysis/**' + - 'test-components/agent-counters/**' + - 'sdks/rust/**' + - 'golem-rust-macro/**' + - 'golem-schema/**' + - 'cli/golem-cli/templates/rust/**' + +permissions: + contents: read + +jobs: + report: + runs-on: ubuntu-latest + timeout-minutes: 30 + continue-on-error: true + steps: + - uses: actions/checkout@v5 + - uses: ./.github/actions/setup-rust + with: + rust-targets: wasm32-wasip2 + install-cargo-binstall: 'true' + install-cargo-make: 'false' + cache-shared-key: component-size + - name: Install analysis tools + run: | + cargo binstall --no-confirm --locked wasm-tools@1.253.0 twiggy@0.8.0 + curl -fLsS https://github.com/WebAssembly/binaryen/releases/download/version_123/binaryen-version_123-x86_64-linux.tar.gz -o /tmp/binaryen.tar.gz + tar xzf /tmp/binaryen.tar.gz -C /tmp + echo /tmp/binaryen-version_123/bin >> "$GITHUB_PATH" + - name: Test binary accounting and rebundling + run: python3 -m unittest discover -s test-components/size-analysis -v + - name: Build reflection retention fixtures + run: | + for package in retention-canonical-floor retention-empty retention-non-reflective retention-reflection; do + python3 test-components/size-analysis/analyze.py \ + --manifest test-components/size-analysis/fixtures/Cargo.toml \ + --package "$package" \ + --out "tmp/$package" + done + wasm-tools component wit --json tmp/retention-canonical-floor/current/component.wasm \ + > tmp/retention-canonical-floor/world.json + wasm-tools component wit --json tmp/retention-empty/current/component.wasm \ + > tmp/retention-empty/world.json + python3 test-components/size-analysis/compare-export-contract.py \ + tmp/retention-canonical-floor/world.json tmp/retention-empty/world.json + - name: Check reflection retention contracts + run: | + python3 test-components/size-analysis/retention.py \ + --negative empty=tmp/retention-empty/current/component.wasm \ + --negative tool=tmp/retention-non-reflective/current/component.wasm \ + --positive reflection=tmp/retention-reflection/current/component.wasm \ + --enforce-option-b \ + --out tmp/reflection-retention.json + - name: Build and report independent release choices + run: | + python3 test-components/size-analysis/analyze.py \ + --manifest test-components/agent-counters/Cargo.toml \ + --matrix --optimize --out tmp/component-size + - name: Isolated allocator performance smoke check + run: | + node test-components/size-analysis/benchmark-core.mjs \ + tmp/component-size/baseline-s/report/core-0/module.wasm \ + tmp/component-size/strip/report/core-0/module.wasm \ + tmp/component-size/cgu1/report/core-0/module.wasm \ + tmp/component-size/z/report/core-0/module.wasm \ + tmp/component-size/strip/optimized-report/core-0/module.wasm \ + > tmp/component-size/allocator-performance.json + - name: Upload report, including partial results on failure + if: always() + uses: actions/upload-artifact@v4 + with: + name: component-size-${{ github.sha }} + path: | + tmp/component-size + tmp/retention-* + tmp/reflection-retention.json + retention-days: 14 + if-no-files-found: warn diff --git a/Cargo.lock b/Cargo.lock index 7646e740c5..54e80ac10c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4144,7 +4144,6 @@ dependencies = [ "blake3", "bytes", "chrono", - "combine", "criterion", "desert_rust", "golem-api-grpc", @@ -4155,14 +4154,12 @@ dependencies = [ "poem", "poem-openapi", "proptest", - "range-set-blaze", "regex", "rust_decimal", "serde", "serde_json", "test-r", "thiserror 2.0.20", - "typed-path", "url", "uuid", "wasip2", @@ -12641,7 +12638,7 @@ dependencies = [ [[package]] name = "wit-bindgen" version = "0.59.0" -source = "git+https://github.com/golemcloud/wit-bindgen?branch=golem-outline-lift-v0.58.0#4407232ead86d9bcbd06cbebd790a52120a4087a" +source = "git+https://github.com/golemcloud/wit-bindgen?rev=d1d16370eff379655f68df661891b7a2116c7557#d1d16370eff379655f68df661891b7a2116c7557" dependencies = [ "bitflags 2.13.1", "futures", @@ -12662,7 +12659,7 @@ dependencies = [ [[package]] name = "wit-bindgen-core" version = "0.59.0" -source = "git+https://github.com/golemcloud/wit-bindgen?branch=golem-outline-lift-v0.58.0#4407232ead86d9bcbd06cbebd790a52120a4087a" +source = "git+https://github.com/golemcloud/wit-bindgen?rev=d1d16370eff379655f68df661891b7a2116c7557#d1d16370eff379655f68df661891b7a2116c7557" dependencies = [ "anyhow", "heck", @@ -12672,7 +12669,7 @@ dependencies = [ [[package]] name = "wit-bindgen-rust" version = "0.59.0" -source = "git+https://github.com/golemcloud/wit-bindgen?branch=golem-outline-lift-v0.58.0#4407232ead86d9bcbd06cbebd790a52120a4087a" +source = "git+https://github.com/golemcloud/wit-bindgen?rev=d1d16370eff379655f68df661891b7a2116c7557#d1d16370eff379655f68df661891b7a2116c7557" dependencies = [ "anyhow", "heck", @@ -12687,7 +12684,7 @@ dependencies = [ [[package]] name = "wit-bindgen-rust-macro" version = "0.59.0" -source = "git+https://github.com/golemcloud/wit-bindgen?branch=golem-outline-lift-v0.58.0#4407232ead86d9bcbd06cbebd790a52120a4087a" +source = "git+https://github.com/golemcloud/wit-bindgen?rev=d1d16370eff379655f68df661891b7a2116c7557#d1d16370eff379655f68df661891b7a2116c7557" dependencies = [ "anyhow", "macro-string", diff --git a/Cargo.toml b/Cargo.toml index 113ad9d2d2..384c5f2679 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -330,7 +330,7 @@ webbrowser = "1.0.4" webpki-roots = { version = "0.26.7" } windows-sys = { version = "0.61.2", features = ["Win32_Foundation", "Win32_Globalization", "Win32_System_Console"] } which = "8.0.0" -wit-bindgen = { git = "https://github.com/golemcloud/wit-bindgen", branch = "golem-outline-lift-v0.58.0", version = "=0.59.0" } +wit-bindgen = { git = "https://github.com/golemcloud/wit-bindgen", rev = "d1d16370eff379655f68df661891b7a2116c7557", version = "=0.59.0" } wit-bindgen-rust = "0.58.0" wit-component = "0.248" wit-encoder = "0.248" diff --git a/Makefile.toml b/Makefile.toml index 7ed6fa6865..f30f0c8ecf 100644 --- a/Makefile.toml +++ b/Makefile.toml @@ -1609,16 +1609,18 @@ cd ../.. ## BUILT-IN TOOLS [tasks.build-builtin-tools] dependencies = ["wit"] -description = "Builds the representative built-in tool fixture (production descriptor inventories are currently empty)" +description = "Builds the Rust and MoonBit built-in filesystem tool components" script_runner = "@duckscript" script = ''' -exec --fail-on-error cargo build -p golem-cli --bin golem-cli -golem_cli = set "${CARGO_MAKE_CRATE_TARGET_DIRECTORY}/debug/golem-cli" -rust_sdk = canonicalize sdks/rust/golem-rust -set_env GOLEM_RUST_PATH ${rust_sdk} -cd test-components/tool-streaming -exec --fail-on-error ${golem_cli} --preset release build --yes --skip-check -exec --fail-on-error ${golem_cli} --preset release exec copy +exec --fail-on-error cargo build -p golem --bin golem +golem = set "${CARGO_MAKE_CRATE_TARGET_DIRECTORY}/debug/golem" +cd plugins/filesystem-tools-rust +exec --fail-on-error ${golem} build -P release --force-build --yes +exec --fail-on-error ${golem} exec -P release copy +cd ../filesystem-tools-moonbit +exec --fail-on-error ${golem} build -P release --force-build --yes +exec --fail-on-error moon fmt component +exec --fail-on-error ${golem} exec -P release copy cd ../.. ''' diff --git a/cli/golem-cli/src/bridge_gen/moonbit/mod.rs b/cli/golem-cli/src/bridge_gen/moonbit/mod.rs index 46a96aa45d..6e5b9ad9f8 100644 --- a/cli/golem-cli/src/bridge_gen/moonbit/mod.rs +++ b/cli/golem-cli/src/bridge_gen/moonbit/mod.rs @@ -663,7 +663,13 @@ impl MoonBitBridgeGenerator { if !is_named_composite(resolved) { continue; } - self.write_encode_fn(writer, &name.name, resolved)?; + self.write_encode_fn(writer, &name.name, resolved, true, false)?; + if self.mode == MoonBitBridgeMode::GuestWasmRpc { + writer.blank(); + self.write_encode_fn(writer, &name.name, resolved, false, true)?; + writer.blank(); + self.write_encode_fn(writer, &name.name, resolved, false, false)?; + } writer.blank(); self.write_decode_fn(writer, &name.name, resolved)?; writer.blank(); @@ -852,9 +858,16 @@ fn guest_decode_unstructured_binary(value : @model.SchemaValue, allowed : Array[ writer: &mut MoonBitWriter, name: &str, resolved: &SchemaType, + cleanup: bool, + preflight: bool, ) -> anyhow::Result<()> { if self.mode == MoonBitBridgeMode::GuestWasmRpc { - writer.line("#warnings(\"-unused_error_type-unused_errdefer\")"); + let warnings = if cleanup { + "-unused_error_type-unused_errdefer" + } else { + "-unused_error_type-unused_errdefer-unused_value" + }; + writer.line(format!("#warnings(\"{warnings}\")")); } let context = if self.mode == MoonBitBridgeMode::ExternalRest && contains_stream_in_graph(self.type_naming.graph(), resolved) @@ -868,11 +881,20 @@ fn guest_decode_unstructured_binary(value : @model.SchemaValue, allowed : Array[ } else { " raise" }; + let function = if cleanup { + format!("encode_{name}") + } else if preflight { + format!("stream_preflight_{name}") + } else { + format!("stream_encode_{name}") + }; + let visibility = if cleanup { "pub " } else { "" }; writer.line(format!( - "pub fn encode_{name}({context}value : {name}) -> @runtime.SchemaValue{raise_clause} {{" + "{visibility}fn {function}({context}value : {name}) -> @runtime.SchemaValue{raise_clause} {{" )); writer.indent(); - if self.mode == MoonBitBridgeMode::GuestWasmRpc + if cleanup + && self.mode == MoonBitBridgeMode::GuestWasmRpc && contains_stream_in_graph(&self.agent_type.schema, resolved) { writer.line(format!("errdefer release_{name}(value)")); @@ -885,10 +907,12 @@ fn guest_decode_unstructured_binary(value : @model.SchemaValue, allowed : Array[ } else { let mut elems = Vec::new(); for (idx, field) in fields.iter().enumerate() { - let enc = self.encode_expr( + let enc = self.encode_expr_mode( &format!("value.{}", field_names[idx]), &field.body, 0, + cleanup, + preflight, )?; writer.line(format!("let f{idx} = {enc}")); elems.push(format!("f{idx}")); @@ -904,7 +928,8 @@ fn guest_decode_unstructured_binary(value : @model.SchemaValue, allowed : Array[ let case_name = &case_names[idx]; match &case.payload { Some(payload) => { - let enc = self.encode_expr("inner", payload, 0)?; + let enc = + self.encode_expr_mode("inner", payload, 0, cleanup, preflight)?; writer.line(format!("{name}::{case_name}(inner) => {{")); writer.indent(); writer.line(format!("let vp = {enc}")); @@ -949,7 +974,8 @@ fn guest_decode_unstructured_binary(value : @model.SchemaValue, allowed : Array[ for (idx, branch) in spec.branches.iter().enumerate() { let branch_name = &branch_names[idx]; let tag = moonbit_string_literal(branch.tag.as_str()); - let enc = self.encode_expr("inner", &branch.body, 0)?; + let enc = + self.encode_expr_mode("inner", &branch.body, 0, cleanup, preflight)?; writer.line(format!("{name}::{branch_name}(inner) => {{")); writer.indent(); writer.line(format!("let ub = {enc}")); @@ -2488,11 +2514,22 @@ fn guest_decode_unstructured_binary(value : @model.SchemaValue, allowed : Array[ // --- Codec dispatchers -------------------------------------------------- fn encode_expr(&self, val: &str, typ: &SchemaType, depth: usize) -> anyhow::Result { + self.encode_expr_mode(val, typ, depth, true, false) + } + + fn encode_expr_mode( + &self, + val: &str, + typ: &SchemaType, + depth: usize, + cleanup: bool, + preflight: bool, + ) -> anyhow::Result { if self.mode == MoonBitBridgeMode::GuestWasmRpc && (unstructured_text_restrictions(self.type_naming.graph(), typ)?.is_some() || unstructured_binary_restrictions(self.type_naming.graph(), typ)?.is_some()) { - return self.encode_structural(val, typ, depth); + return self.encode_structural(val, typ, depth, cleanup, preflight); } if let Some(name) = self.type_naming.type_name_for_type(typ) && is_named_composite(self.resolve_ref(typ)) @@ -2504,9 +2541,16 @@ fn guest_decode_unstructured_binary(value : @model.SchemaValue, allowed : Array[ } else { "" }; - return Ok(format!("encode_{}({context}{val})", name.name)); + let function = if cleanup { + format!("encode_{}", name.name) + } else if preflight { + format!("stream_preflight_{}", name.name) + } else { + format!("stream_encode_{}", name.name) + }; + return Ok(format!("{function}({context}{val})")); } - self.encode_structural(val, typ, depth) + self.encode_structural(val, typ, depth, cleanup, preflight) } fn decode_expr(&self, val: &str, typ: &SchemaType, depth: usize) -> anyhow::Result { @@ -2529,6 +2573,8 @@ fn guest_decode_unstructured_binary(value : @model.SchemaValue, allowed : Array[ val: &str, typ: &SchemaType, depth: usize, + cleanup: bool, + preflight: bool, ) -> anyhow::Result { if let Some(restrictions) = unstructured_text_restrictions(self.type_naming.graph(), typ)? { if self.mode == MoonBitBridgeMode::GuestWasmRpc { @@ -2551,6 +2597,18 @@ fn guest_decode_unstructured_binary(value : @model.SchemaValue, allowed : Array[ } let resolved = self.resolve_ref(typ); + if preflight + && self.mode == MoonBitBridgeMode::GuestWasmRpc + && matches!( + resolved, + SchemaType::Stream { .. } + | SchemaType::Secret { .. } + | SchemaType::QuotaToken { .. } + | SchemaType::PermissionCard { .. } + ) + { + return Ok("@runtime.SchemaValue::String(\"\")".to_string()); + } let e = format!("e{depth}"); let next = depth + 1; let rendered = match resolved { @@ -2589,17 +2647,17 @@ fn guest_decode_unstructured_binary(value : @model.SchemaValue, allowed : Array[ }, SchemaType::String { .. } => format!("@runtime.StringValue({val})"), SchemaType::Option { inner, .. } => { - let inner_enc = self.encode_expr(&e, inner, next)?; + let inner_enc = self.encode_expr_mode(&e, inner, next, cleanup, preflight)?; format!("@runtime.OptionValue({val}.map(({e}) => {inner_enc}))") } SchemaType::List { element, .. } => { - let inner_enc = self.encode_expr(&e, element, next)?; + let inner_enc = self.encode_expr_mode(&e, element, next, cleanup, preflight)?; format!("@runtime.ListValue({val}.map(({e}) => {inner_enc}))") } SchemaType::FixedList { element, length, .. } => { - let inner_enc = self.encode_expr(&e, element, next)?; + let inner_enc = self.encode_expr_mode(&e, element, next, cleanup, preflight)?; match self.mode { MoonBitBridgeMode::ExternalRest => { format!("@runtime.FixedListValue({val}.map(({e}) => {inner_enc}))") @@ -2613,20 +2671,22 @@ fn guest_decode_unstructured_binary(value : @model.SchemaValue, allowed : Array[ let entries = format!("entries{depth}"); let k = format!("k{depth}"); let v = format!("v{depth}"); - let key_enc = self.encode_expr(&k, key, next)?; - let val_enc = self.encode_expr(&v, value, next)?; + let key_enc = self.encode_expr_mode(&k, key, next, cleanup, preflight)?; + let val_enc = self.encode_expr_mode(&v, value, next, cleanup, preflight)?; format!( "{{\n let {entries} : Array[@runtime.SchemaMapEntry] = []\n {val}.each(({k}, {v}) => {entries}.push(@runtime.SchemaMapEntry::{{ key: {key_enc}, value: {val_enc} }}))\n @runtime.MapValue({entries})\n}}" ) } - SchemaType::Tuple { elements, .. } => self.encode_tuple(val, elements, depth)?, + SchemaType::Tuple { elements, .. } => { + self.encode_tuple(val, elements, depth, cleanup, preflight)? + } SchemaType::Result { spec, .. } => { let r = format!("r{depth}"); let l = format!("l{depth}"); let p = format!("p{depth}"); let ok_arm = match spec.ok.as_deref() { Some(ok_type) => { - let enc = self.encode_expr(&r, ok_type, next)?; + let enc = self.encode_expr_mode(&r, ok_type, next, cleanup, preflight)?; match self.mode { MoonBitBridgeMode::ExternalRest => format!( "Ok({r}) => {{ let {p} = {enc}; @runtime.ResultValue(@runtime.ResultOk(Some({p}))) }}" @@ -2647,7 +2707,7 @@ fn guest_decode_unstructured_binary(value : @model.SchemaValue, allowed : Array[ }; let err_arm = match spec.err.as_deref() { Some(err_type) => { - let enc = self.encode_expr(&l, err_type, next)?; + let enc = self.encode_expr_mode(&l, err_type, next, cleanup, preflight)?; match self.mode { MoonBitBridgeMode::ExternalRest => format!( "Err({l}) => {{ let {p} = {enc}; @runtime.ResultValue(@runtime.ResultErr(Some({p}))) }}" @@ -2687,7 +2747,7 @@ fn guest_decode_unstructured_binary(value : @model.SchemaValue, allowed : Array[ let inner = inner .as_deref() .context("MoonBit external streams require an element schema")?; - let encoded = self.encode_expr(&e, inner, next)?; + let encoded = self.encode_expr_mode(&e, inner, next, cleanup, preflight)?; let wire_kind = match self.resolve_ref(inner) { SchemaType::U8 { .. } => "u8", SchemaType::Binary { .. } => "binary", @@ -2928,13 +2988,15 @@ fn guest_decode_unstructured_binary(value : @model.SchemaValue, allowed : Array[ val: &str, elements: &[SchemaType], depth: usize, + cleanup: bool, + preflight: bool, ) -> anyhow::Result { if elements.is_empty() { return Ok("@runtime.TupleValue([])".to_string()); } let next = depth + 1; if elements.len() == 1 { - let enc = self.encode_expr(val, &elements[0], next)?; + let enc = self.encode_expr_mode(val, &elements[0], next, cleanup, preflight)?; return Ok(format!( "{{\n let te{depth}_0 = {enc}\n @runtime.TupleValue([te{depth}_0])\n}}" )); @@ -2943,7 +3005,8 @@ fn guest_decode_unstructured_binary(value : @model.SchemaValue, allowed : Array[ let mut lines = vec![format!(" let {t} = {val}")]; let mut names = Vec::new(); for (idx, element) in elements.iter().enumerate() { - let enc = self.encode_expr(&format!("{t}.{idx}"), element, next)?; + let enc = + self.encode_expr_mode(&format!("{t}.{idx}"), element, next, cleanup, preflight)?; lines.push(format!(" let te{depth}_{idx} = {enc}")); names.push(format!("te{depth}_{idx}")); } diff --git a/cli/golem-cli/src/bridge_gen/moonbit/streams.rs b/cli/golem-cli/src/bridge_gen/moonbit/streams.rs index 795ea87c5c..7f4983ab3a 100644 --- a/cli/golem-cli/src/bridge_gen/moonbit/streams.rs +++ b/cli/golem-cli/src/bridge_gen/moonbit/streams.rs @@ -200,12 +200,14 @@ impl MoonBitBridgeGenerator { .as_deref() .context("MoonBit guest streams require an element schema")?; let ty = self.type_reference(inner)?; - let encode = guest_codec_source(self.encode_expr("item", inner, 0)?); + let preflight = + guest_codec_source(self.encode_expr_mode("item", inner, 0, false, true)?); + let encode = guest_codec_source(self.encode_expr_mode("item", inner, 0, false, false)?); let decode = guest_codec_source(self.decode_expr("item", inner, 0)?); let release = self.release_expr("item", inner, 0)?; writer.line("#warnings(\"-unused_error_type\")"); writer.line(format!( - "fn stream_encode_{path}(item : {ty}) -> @model.SchemaValue raise {{ {encode} }}" + "fn stream_encode_{path}(item : {ty}) -> @model.SchemaValue raise {{ ignore({preflight}); {encode} }}" )); writer.blank(); writer.line("#warnings(\"-unused_try\")"); diff --git a/cli/golem-cli/src/bridge_gen/moonbit/tool.rs b/cli/golem-cli/src/bridge_gen/moonbit/tool.rs index 627346efe1..a94f4e10a1 100644 --- a/cli/golem-cli/src/bridge_gen/moonbit/tool.rs +++ b/cli/golem-cli/src/bridge_gen/moonbit/tool.rs @@ -123,6 +123,10 @@ impl MoonBitToolBridgeGenerator { "\"golemcloud/golem_sdk/interface/golem/core/types\" @types", ), ("@model.", "\"golemcloud/golem_sdk/schema_model\" @model"), + ( + "@model_host.", + "\"golemcloud/golem_sdk/schema_model_host\" @model_host", + ), ("@tool.", "\"golemcloud/golem_sdk/tool\""), ] .into_iter() @@ -396,6 +400,11 @@ impl MoonBitToolBridgeGenerator { ) }; if has_stdout { + writer.line("let input = try @model.typed_schema_value_to_wit(input) catch {"); + writer.indent(); + writer.line("error => return Err(@tool.tool_protocol_error(\"failed to encode tool input: \" + repr(error)))"); + writer.dedent(); + writer.line("}"); writer.line(format!( "match self.client.start({path}, input, {stdin}, true, {error_decoder}) {{" )); @@ -403,7 +412,7 @@ impl MoonBitToolBridgeGenerator { writer.line("Err(error) => Err(error)"); writer.line("Ok(invocation) => @tool.typed_invocation(invocation, fn(result) {"); writer.indent(); - self.result_decode(writer, body)?; + self.result_decode(writer, body, true)?; writer.dedent(); writer.line("})"); writer.dedent(); @@ -423,7 +432,7 @@ impl MoonBitToolBridgeGenerator { writer.line("Err(error) => Err(error)"); writer.line("Ok(result) => {"); writer.indent(); - self.result_decode(writer, body)?; + self.result_decode(writer, body, false)?; writer.dedent(); writer.line("}"); writer.dedent(); @@ -433,9 +442,28 @@ impl MoonBitToolBridgeGenerator { Ok(()) } - fn result_decode(&self, writer: &mut MoonBitWriter, body: &CommandBody) -> anyhow::Result<()> { + fn result_decode( + &self, + writer: &mut MoonBitWriter, + body: &CommandBody, + wire: bool, + ) -> anyhow::Result<()> { + if wire { + writer.line("let result_value = match result.result {"); + writer.indent(); + writer.line("None => None"); + writer.line("Some(value) => try { Some(@model_host.typed_schema_value_from_wit(value)) } catch {"); + writer.indent(); + writer.line("error => return Err(@tool.tool_protocol_error(\"failed to decode tool result: \" + repr(error)))"); + writer.dedent(); + writer.line("}"); + writer.dedent(); + writer.line("}"); + } else { + writer.line("let result_value = result.result"); + } if let Some(result) = &body.result { - writer.line("let typed = match @tool.expect_value(result.result) {"); + writer.line("let typed = match @tool.expect_value(result_value) {"); writer.indent(); writer.line("Ok(value) => value"); writer.line("Err(error) => return Err(error)"); @@ -452,7 +480,7 @@ impl MoonBitToolBridgeGenerator { writer.line("}"); writer.line("Ok(decoded)"); } else { - writer.line("match @tool.expect_no_value(result.result) {"); + writer.line("match @tool.expect_no_value(result_value) {"); writer.indent(); writer.line("Err(error) => Err(error)"); writer.line("Ok(_) => Ok(())"); @@ -479,6 +507,7 @@ impl MoonBitToolBridgeGenerator { .as_ref() .context("error enum command has no body")?; let variants = error_variant_names(body); + let wire_error = body.stdout.is_some(); writer.line("///|"); writer.line(format!("pub(all) enum {error_name} {{")); writer.indent(); @@ -498,10 +527,20 @@ impl MoonBitToolBridgeGenerator { writer.line("///|"); writer.line(format!( - "fn {}(name : String, value : @model.TypedSchemaValue) -> Result[{error_name}, String]? {{", - error_decoder_name(error_name) + "fn {}(name : String, value : {}.TypedSchemaValue) -> Result[{error_name}, String]? {{", + error_decoder_name(error_name), if wire_error { "@types" } else { "@model" } )); writer.indent(); + if wire_error { + writer + .line("let value = try @model_host.typed_schema_value_from_wit(value) catch {"); + writer.indent(); + writer.line( + "error => return Some(Err(\"failed to decode tool error: \" + repr(error)))", + ); + writer.dedent(); + writer.line("}"); + } writer.line("match name {"); writer.indent(); let mut grouped = BTreeMap::<&str, Vec<_>>::new(); diff --git a/cli/golem-cli/src/bridge_gen/rust/mod.rs b/cli/golem-cli/src/bridge_gen/rust/mod.rs index b6a6bec0cb..f0e96981c8 100644 --- a/cli/golem-cli/src/bridge_gen/rust/mod.rs +++ b/cli/golem-cli/src/bridge_gen/rust/mod.rs @@ -60,6 +60,7 @@ mod rust; mod schema_graph; pub mod tool; mod type_name; +mod wire; pub use type_name::RustTypeName; @@ -145,6 +146,7 @@ impl RustRuntimeConfig { } } RustBridgeMode::GuestWasmRpc => quote! { + use golem_rust::schema::wit::wire as __wire; pub mod __golem_bridge_runtime { pub use golem_rust::agentic::EphemeralInvocationResult; @@ -731,10 +733,8 @@ impl RustBridgeGenerator { self.input_param_defs_with_ident_names(&constructor_input, &constructor_param_names)?; let constructor_param_refs = self.input_param_refs_with_ident_names(&constructor_param_names); - let constructor_params_value = self.input_param_schema_value_with_ident_names( - &constructor_input, - &constructor_param_names, - )?; + let constructor_params_value = + self.guest_input_wire(&constructor_input, &constructor_param_names, false)?; let local_configs: Vec = self .agent_type @@ -817,8 +817,7 @@ impl RustBridgeGenerator { .iter() .map(|s| quote! { #s.to_string() }) .collect(); - let value_encode = - self.emit_encode_expr(quote! { value }, &config.value_type, false, 0)?; + let value_encode = self.guest_wire_tree(quote! { value }, &config.value_type, false)?; let config_graph = typed_schema_value_with_projected_defs( &self.agent_type.schema, config.value_type.clone(), @@ -829,15 +828,10 @@ impl RustBridgeGenerator { let config_graph = schema_graph::graph_clone(self.schema_graphs.intern(config_graph)); config_encode_stmts.push(quote! { if let Some(value) = #param_name { - let __config_value: crate::__golem_bridge_runtime::schema::SchemaValue = (|| -> Result { - #value_encode - })().map_err(|__e| crate::__golem_bridge_runtime::ClientError::ConfigEncodingFailed { message: __e })?; - let __config_graph: golem_rust::SchemaGraph = #config_graph; - let __typed = golem_rust::TypedSchemaValue::new(__config_graph, __config_value); + let __config_value = #value_encode.map_err(|message| crate::__golem_bridge_runtime::ClientError::ConfigEncodingFailed { message })?; #agent_config_values.push(golem_rust::golem_agentic::golem::agent::common::TypedAgentConfigValue { path: vec![#(#path_segments),*], - value: golem_rust::encode_typed_schema_value(&__typed) - .map_err(|__e| crate::__golem_bridge_runtime::ClientError::ConfigEncodingFailed { message: format!("Failed to encode config value: {__e}") })?, + value: __wire::TypedSchemaValue { graph: #config_graph, value: __config_value }, }); } }); @@ -965,11 +959,10 @@ impl RustBridgeGenerator { #agent_config_param: Vec, #(#constructor_param_defs),* ) -> Result { - let constructor_value: crate::__golem_bridge_runtime::schema::SchemaValue = #constructor_params_value; + let constructor_value = #constructor_params_value; let wasm_rpc = golem_rust::golem_agentic::golem::agent::host::WasmRpc::new( #agent_type_name, - golem_rust::encode_schema_value(&constructor_value) - .map_err(|__e| crate::__golem_bridge_runtime::ClientError::SchemaEncodeFailed { message: __e.to_string() })?, + constructor_value, #phantom_id_param.map(Into::into), #agent_config_param, ); @@ -1018,14 +1011,21 @@ impl RustBridgeGenerator { "Creates a native stream with item schema `{json}`.\n\nUse this factory instead of `AgentStream::new` for generated items: its consuming, fallible codecs preserve this schema even when multiple schemas share a Rust type. Write concurrently with the awaited invocation or reader; each write waits for acceptance. A codec rejection consumes the item, sends nothing, and leaves the writer usable. Dropping the writer produces EOF. Dropping the reader is observed by subsequent writes. Forward the unread reader directly to transfer the original endpoint without decoding items." ); let ty = self.type_reference(&item, false)?; - let encode = self.emit_encode_expr(quote! { item }, &item, false, 0)?; - let decode = self.emit_decode_expr(quote! { item }, &item, false, 0)?; + let encode = self.guest_wire_tree(quote! { item }, &item, true)?; + let decode = self.guest_wire_decode_expr(quote! { root }, &item, false, 0)?; factories.push(quote! { #[doc = #doc] pub fn #name() -> (golem_rust::agentic::AgentStreamWriter<#ty>, golem_rust::agentic::AgentStream<#ty>) { - golem_rust::agentic::AgentStream::new_with_codecs( - |item| #encode, - |item| #decode, + golem_rust::agentic::AgentStream::new_with_wire_codecs( + |item: #ty| async move { #encode }, + |tree: __wire::SchemaValueTree| -> Result<#ty, String> { + let root = tree.root; + let mut reader = golem_rust::schema::wit::direct::WireReader::new(tree.value_nodes); + let __reader = &mut reader; + let value = #decode?; + reader.finish().map_err(|e| e.to_string())?; + Ok(value) + }, ) } }); @@ -1457,15 +1457,12 @@ impl RustBridgeGenerator { let param_defs = self.input_param_defs_with_ident_names(&method.input_schema, &names.param_names)?; let name_lit = method.name.as_str(); - let params_schema_value = self - .input_param_schema_value_with_ident_names(&method.input_schema, &names.param_names)?; + let params_wire = self.guest_input_wire(&method.input_schema, &names.param_names, false)?; if self.agent_type.mode == AgentMode::Ephemeral { return Ok(quote! { pub fn #name(&self, #(#param_defs),*) -> Result { - let method_parameters: crate::__golem_bridge_runtime::schema::SchemaValue = #params_schema_value; - let method_parameters = golem_rust::encode_schema_value(&method_parameters) - .map_err(|__e| crate::__golem_bridge_runtime::ClientError::SchemaEncodeFailed { message: __e.to_string() })?; + let method_parameters = #params_wire; self.wasm_rpc.invoke(#name_lit, method_parameters, None) .map_err(|__e| crate::__golem_bridge_runtime::ClientError::RpcFailed { message: format!("{__e:?}") }) } @@ -1474,9 +1471,7 @@ impl RustBridgeGenerator { Ok(quote! { pub fn #name(&self, #(#param_defs),*) -> Result<(), crate::__golem_bridge_runtime::ClientError> { - let method_parameters: crate::__golem_bridge_runtime::schema::SchemaValue = #params_schema_value; - let method_parameters = golem_rust::encode_schema_value(&method_parameters) - .map_err(|__e| crate::__golem_bridge_runtime::ClientError::SchemaEncodeFailed { message: __e.to_string() })?; + let method_parameters = #params_wire; self.wasm_rpc.invoke(#name_lit, method_parameters, None) .map(|_| ()) .map_err(|__e| crate::__golem_bridge_runtime::ClientError::RpcFailed { message: format!("{__e:?}") }) @@ -1494,15 +1489,12 @@ impl RustBridgeGenerator { let param_defs = self.input_param_defs_with_ident_names(&method.input_schema, &names.param_names)?; let name_lit = method.name.as_str(); - let params_schema_value = self - .input_param_schema_value_with_ident_names(&method.input_schema, &names.param_names)?; + let params_wire = self.guest_input_wire(&method.input_schema, &names.param_names, false)?; if self.agent_type.mode == AgentMode::Ephemeral { return Ok(quote! { pub fn #name(&self, #(#param_defs,)* #scheduled_time_param: golem_rust::ScheduledTime) -> Result { - let method_parameters: crate::__golem_bridge_runtime::schema::SchemaValue = #params_schema_value; - let method_parameters = golem_rust::encode_schema_value(&method_parameters) - .map_err(|__e| crate::__golem_bridge_runtime::ClientError::SchemaEncodeFailed { message: __e.to_string() })?; + let method_parameters = #params_wire; self.wasm_rpc.schedule_invocation(#scheduled_time_param, #name_lit, method_parameters, None) .map(|__receipt| __receipt.metadata) .map_err(|__e| crate::__golem_bridge_runtime::ClientError::RpcFailed { message: format!("{__e:?}") }) @@ -1512,9 +1504,7 @@ impl RustBridgeGenerator { Ok(quote! { pub fn #name(&self, #(#param_defs,)* #scheduled_time_param: golem_rust::ScheduledTime) -> Result<(), crate::__golem_bridge_runtime::ClientError> { - let method_parameters: crate::__golem_bridge_runtime::schema::SchemaValue = #params_schema_value; - let method_parameters = golem_rust::encode_schema_value(&method_parameters) - .map_err(|__e| crate::__golem_bridge_runtime::ClientError::SchemaEncodeFailed { message: __e.to_string() })?; + let method_parameters = #params_wire; self.wasm_rpc.schedule_invocation(#scheduled_time_param, #name_lit, method_parameters, None) .map(|_| ()) .map_err(|__e| crate::__golem_bridge_runtime::ClientError::RpcFailed { message: format!("{__e:?}") }) @@ -1532,15 +1522,12 @@ impl RustBridgeGenerator { let param_defs = self.input_param_defs_with_ident_names(&method.input_schema, &names.param_names)?; let name_lit = method.name.as_str(); - let params_schema_value = self - .input_param_schema_value_with_ident_names(&method.input_schema, &names.param_names)?; + let params_wire = self.guest_input_wire(&method.input_schema, &names.param_names, false)?; if self.agent_type.mode == AgentMode::Ephemeral { return Ok(quote! { pub fn #name(&self, #(#param_defs,)* #scheduled_time_param: golem_rust::ScheduledTime) -> Result { - let method_parameters: crate::__golem_bridge_runtime::schema::SchemaValue = #params_schema_value; - let method_parameters = golem_rust::encode_schema_value(&method_parameters) - .map_err(|__e| crate::__golem_bridge_runtime::ClientError::SchemaEncodeFailed { message: __e.to_string() })?; + let method_parameters = #params_wire; self.wasm_rpc.schedule_cancelable_invocation(#scheduled_time_param, #name_lit, method_parameters, None) .map_err(|__e| crate::__golem_bridge_runtime::ClientError::RpcFailed { message: format!("{__e:?}") }) } @@ -1549,9 +1536,7 @@ impl RustBridgeGenerator { Ok(quote! { pub fn #name(&self, #(#param_defs,)* #scheduled_time_param: golem_rust::ScheduledTime) -> Result { - let method_parameters: crate::__golem_bridge_runtime::schema::SchemaValue = #params_schema_value; - let method_parameters = golem_rust::encode_schema_value(&method_parameters) - .map_err(|__e| crate::__golem_bridge_runtime::ClientError::SchemaEncodeFailed { message: __e.to_string() })?; + let method_parameters = #params_wire; self.wasm_rpc.schedule_cancelable_invocation(#scheduled_time_param, #name_lit, method_parameters, None) .map(|__receipt| __receipt.cancellation_token) .map_err(|__e| crate::__golem_bridge_runtime::ClientError::RpcFailed { message: format!("{__e:?}") }) @@ -1569,26 +1554,18 @@ impl RustBridgeGenerator { let param_defs = self.input_param_defs_with_ident_names(&method.input_schema, &names.param_names)?; let return_type = self.output_return_type(&method.output_schema)?; - let params_schema_value = self - .input_param_schema_value_with_ident_names(&method.input_schema, &names.param_names)?; + let params_wire = self.guest_input_wire(&method.input_schema, &names.param_names, true)?; let ephemeral = self.agent_type.mode == AgentMode::Ephemeral; - let encode_parameters = if method.uses_streams(&self.agent_type.schema) { - quote! { golem_rust::encode_schema_value_async(&method_parameters).await } - } else { - quote! { golem_rust::encode_schema_value(&method_parameters) } - }; match return_type { Some(return_type) if ephemeral => { - let decode_body = self.output_decode_expr(&method.output_schema)?; + let decode_body = self.guest_output_wire(&method.output_schema)?; Ok(quote! { async fn #name(&self, #(#param_defs),*) -> Result<(golem_rust::golem_agentic::golem::agent::host::InvocationMetadata, Option<#return_type>), crate::__golem_bridge_runtime::ClientError> { - let method_parameters: crate::__golem_bridge_runtime::schema::SchemaValue = #params_schema_value; - let method_parameters = #encode_parameters - .map_err(|__e| crate::__golem_bridge_runtime::ClientError::SchemaEncodeFailed { message: __e.to_string() })?; + let method_parameters = #params_wire; let invocation = self.wasm_rpc.async_invoke_and_await(#name_lit, method_parameters, None); let metadata = invocation.metadata; - let response = golem_rust::agentic::await_invoke_schema_value_result(invocation.future).await + let response = invocation.future.get().await .map_err(|__e| crate::__golem_bridge_runtime::ClientError::RpcFailed { message: format!("{__e:?}") })?; match response { Some(__value) => { @@ -1603,14 +1580,12 @@ impl RustBridgeGenerator { }) } Some(return_type) => { - let decode_body = self.output_decode_expr(&method.output_schema)?; + let decode_body = self.guest_output_wire(&method.output_schema)?; Ok(quote! { async fn #name(&self, #(#param_defs),*) -> Result, crate::__golem_bridge_runtime::ClientError> { - let method_parameters: crate::__golem_bridge_runtime::schema::SchemaValue = #params_schema_value; - let method_parameters = #encode_parameters - .map_err(|__e| crate::__golem_bridge_runtime::ClientError::SchemaEncodeFailed { message: __e.to_string() })?; + let method_parameters = #params_wire; let rpc_result_future = self.wasm_rpc.async_invoke_and_await(#name_lit, method_parameters, None).future; - let response = golem_rust::agentic::await_invoke_schema_value_result(rpc_result_future).await + let response = rpc_result_future.get().await .map_err(|__e| crate::__golem_bridge_runtime::ClientError::RpcFailed { message: format!("{__e:?}") })?; match response { Some(__value) => { @@ -1626,23 +1601,19 @@ impl RustBridgeGenerator { } None if ephemeral => Ok(quote! { async fn #name(&self, #(#param_defs),*) -> Result<(golem_rust::golem_agentic::golem::agent::host::InvocationMetadata, Option<()>), crate::__golem_bridge_runtime::ClientError> { - let method_parameters: crate::__golem_bridge_runtime::schema::SchemaValue = #params_schema_value; - let method_parameters = #encode_parameters - .map_err(|__e| crate::__golem_bridge_runtime::ClientError::SchemaEncodeFailed { message: __e.to_string() })?; + let method_parameters = #params_wire; let invocation = self.wasm_rpc.async_invoke_and_await(#name_lit, method_parameters, None); let metadata = invocation.metadata; - let _response = golem_rust::agentic::await_invoke_schema_value_result(invocation.future).await + let _response = invocation.future.get().await .map_err(|__e| crate::__golem_bridge_runtime::ClientError::RpcFailed { message: format!("{__e:?}") })?; Ok((metadata, Some(()))) } }), None => Ok(quote! { async fn #name(&self, #(#param_defs),*) -> Result, crate::__golem_bridge_runtime::ClientError> { - let method_parameters: crate::__golem_bridge_runtime::schema::SchemaValue = #params_schema_value; - let method_parameters = #encode_parameters - .map_err(|__e| crate::__golem_bridge_runtime::ClientError::SchemaEncodeFailed { message: __e.to_string() })?; + let method_parameters = #params_wire; let rpc_result_future = self.wasm_rpc.async_invoke_and_await(#name_lit, method_parameters, None).future; - let _response = golem_rust::agentic::await_invoke_schema_value_result(rpc_result_future).await + let _response = rpc_result_future.get().await .map_err(|__e| crate::__golem_bridge_runtime::ClientError::RpcFailed { message: format!("{__e:?}") })?; Ok(Some(())) } @@ -2040,6 +2011,33 @@ impl RustBridgeGenerator { .iter() .any(|(_, payload)| contains_stream_in_graph(&self.agent_type.schema, payload)); + if self.mode == RustBridgeMode::GuestWasmRpc { + let typ = SchemaType::Variant { + cases: cases + .iter() + .map(|(name, payload)| VariantCaseType { + name: name.clone(), + payload: Some(payload.clone()), + metadata: Default::default(), + }) + .collect(), + metadata: Default::default(), + }; + let definition = self.emit_typedef(&enum_ident, &typ)?; + let encode_fn = Self::ident_from_name(format!("encode_{name}")); + let decode_fn = Self::ident_from_name(format!("decode_{name}")); + let encode = self.guest_wire_encode_body(&enum_ident, &typ)?; + let decode = self.guest_wire_decode_body(&enum_ident, &typ)?; + let visitors = self.guest_wire_visitors(&enum_ident, &typ)?; + items.push(quote! { + #definition + fn #encode_fn(value: &#enum_ident, __writer: &mut golem_rust::schema::wit::direct::WireWriter) -> Result { #encode } + fn #decode_fn(value: i32, __reader: &mut golem_rust::schema::wit::direct::WireReader) -> Result<#enum_ident, String> { #decode } + #visitors + }); + continue; + } + let mut variants = Vec::new(); let mut encode_arms = Vec::new(); let mut decode_arms = Vec::new(); @@ -2211,7 +2209,20 @@ impl RustBridgeGenerator { let typedef = self.emit_typedef(&name_ident, &resolved)?; let encode_fn = Ident::new(&format!("encode_{name_str}"), Span::call_site()); let decode_fn = Ident::new(&format!("decode_{name_str}"), Span::call_site()); - let ordinary_codecs = if self.mode == RustBridgeMode::ExternalRest + let ordinary_codecs = if self.mode == RustBridgeMode::GuestWasmRpc { + let encode = self.guest_wire_encode_body(&name_ident, &resolved)?; + let decode = self.guest_wire_decode_body(&name_ident, &resolved)?; + let visitors = self.guest_wire_visitors(&name_ident, &resolved)?; + quote! { + fn #encode_fn(value: &#name_ident, __writer: &mut golem_rust::schema::wit::direct::WireWriter) -> Result { + #encode + } + fn #decode_fn(value: i32, __reader: &mut golem_rust::schema::wit::direct::WireReader) -> Result<#name_ident, String> { + #decode + } + #visitors + } + } else if self.mode == RustBridgeMode::ExternalRest && contains_stream_in_graph(&self.agent_type.schema, &resolved) { quote! {} diff --git a/cli/golem-cli/src/bridge_gen/rust/schema_graph.rs b/cli/golem-cli/src/bridge_gen/rust/schema_graph.rs index baf812a55d..fa3bd7f5a9 100644 --- a/cli/golem-cli/src/bridge_gen/rust/schema_graph.rs +++ b/cli/golem-cli/src/bridge_gen/rust/schema_graph.rs @@ -7,12 +7,7 @@ // http://license.golem.cloud/LICENSE use golem_common::schema::graph::SchemaGraph; -use golem_common::schema::metadata::{MetadataEnvelope, Role}; -use golem_common::schema::schema_type::{ - BinaryRestrictions, DiscriminatorRule, NumericBound, NumericRestrictions, PathDirection, - PathKind, PathSpec, QuantitySpec, QuantityValue, QuotaTokenSpec, SchemaType, SecretSpec, - TextRestrictions, UrlRestrictions, -}; +use golem_common::schema::wit::{encode_graph, wire}; use proc_macro2::TokenStream; use quote::quote; @@ -37,7 +32,7 @@ impl SchemaGraphRegistry { let name = graph_ident(index); let literal = emit_schema_graph_literal(graph); quote! { - static #name: std::sync::LazyLock = + static #name: std::sync::LazyLock = std::sync::LazyLock::new(|| #literal); } }); @@ -50,6 +45,10 @@ pub(crate) fn graph_clone(index: usize) -> TokenStream { quote! { (*#name).clone() } } +pub(crate) fn emit_schema_graph_literal(graph: &SchemaGraph) -> TokenStream { + emit_wire_graph(&encode_graph(graph).expect("validated schema graph must be encodable")) +} + fn graph_ident(index: usize) -> syn::Ident { syn::Ident::new( &format!("__GOLEM_SCHEMA_GRAPH_{index}"), @@ -57,247 +56,195 @@ fn graph_ident(index: usize) -> syn::Ident { ) } -pub(crate) fn emit_schema_graph_literal(graph: &SchemaGraph) -> TokenStream { +fn emit_wire_graph(graph: &wire::SchemaGraph) -> TokenStream { + let nodes = graph.type_nodes.iter().map(emit_node); let defs = graph.defs.iter().map(|def| { - let id = def.id.as_str(); + let id = &def.id; let name = option_string(def.name.as_deref()); - let body = emit_schema_type(&def.body); - quote! { - golem_rust::schema::graph::SchemaTypeDef { - id: golem_rust::schema::metadata::TypeId::new(#id), - name: #name, - body: #body, - } - } + let body = def.body; + quote! { golem_rust::schema::wit::wire::SchemaTypeDef { + id: #id.to_string(), name: #name, body: #body, + } } }); - let root = emit_schema_type(&graph.root); - quote! { - golem_rust::schema::graph::SchemaGraph { - defs: vec![#(#defs),*], - root: #root, - } - } + let root = graph.root; + quote! { golem_rust::schema::wit::wire::SchemaGraph { + type_nodes: vec![#(#nodes),*], defs: vec![#(#defs),*], root: #root, + } } } -fn emit_schema_type(typ: &SchemaType) -> TokenStream { - use SchemaType::*; +fn emit_node(node: &wire::SchemaTypeNode) -> TokenStream { + let body = emit_body(&node.body); + let metadata = emit_metadata(&node.metadata); + quote! { golem_rust::schema::wit::wire::SchemaTypeNode { + body: #body, metadata: #metadata, + } } +} - let metadata = emit_metadata(typ.metadata()); - match typ { - Ref { id, .. } => { - let id = id.as_str(); - quote! { golem_rust::schema::schema_type::SchemaType::Ref { - id: golem_rust::schema::metadata::TypeId::new(#id), - metadata: #metadata, - } } - } - Bool { .. } => { - quote! { golem_rust::schema::schema_type::SchemaType::Bool { metadata: #metadata } } - } - S8 { restrictions, .. } => numeric("S8", restrictions.as_ref(), metadata), - S16 { restrictions, .. } => numeric("S16", restrictions.as_ref(), metadata), - S32 { restrictions, .. } => numeric("S32", restrictions.as_ref(), metadata), - S64 { restrictions, .. } => numeric("S64", restrictions.as_ref(), metadata), - U8 { restrictions, .. } => numeric("U8", restrictions.as_ref(), metadata), - U16 { restrictions, .. } => numeric("U16", restrictions.as_ref(), metadata), - U32 { restrictions, .. } => numeric("U32", restrictions.as_ref(), metadata), - U64 { restrictions, .. } => numeric("U64", restrictions.as_ref(), metadata), - F32 { restrictions, .. } => numeric("F32", restrictions.as_ref(), metadata), - F64 { restrictions, .. } => numeric("F64", restrictions.as_ref(), metadata), - Char { .. } => { - quote! { golem_rust::schema::schema_type::SchemaType::Char { metadata: #metadata } } - } - String { .. } => { - quote! { golem_rust::schema::schema_type::SchemaType::String { metadata: #metadata } } - } - Record { fields, .. } => { - let fields = fields.iter().map(|field| { - let name = &field.name; - let body = emit_schema_type(&field.body); - let metadata = emit_metadata(&field.metadata); - quote! { golem_rust::schema::schema_type::NamedFieldType { - name: #name.to_string(), body: #body, metadata: #metadata, - } } - }); - quote! { golem_rust::schema::schema_type::SchemaType::Record { - fields: vec![#(#fields),*], metadata: #metadata, - } } - } - Variant { cases, .. } => { - let cases = cases.iter().map(|case| { - let name = &case.name; - let payload = option_type(case.payload.as_ref()); - let metadata = emit_metadata(&case.metadata); - quote! { golem_rust::schema::schema_type::VariantCaseType { - name: #name.to_string(), payload: #payload, metadata: #metadata, - } } - }); - quote! { golem_rust::schema::schema_type::SchemaType::Variant { - cases: vec![#(#cases),*], metadata: #metadata, - } } - } - Enum { cases, .. } => { - let cases = string_vec(cases); - quote! { golem_rust::schema::schema_type::SchemaType::Enum { cases: #cases, metadata: #metadata } } - } - Flags { flags, .. } => { - let flags = string_vec(flags); - quote! { golem_rust::schema::schema_type::SchemaType::Flags { flags: #flags, metadata: #metadata } } - } - Tuple { elements, .. } => { - let elements = elements.iter().map(emit_schema_type); - quote! { golem_rust::schema::schema_type::SchemaType::Tuple { - elements: vec![#(#elements),*], metadata: #metadata, - } } - } - List { element, .. } => { - let element = emit_schema_type(element); - quote! { golem_rust::schema::schema_type::SchemaType::List { - element: Box::new(#element), metadata: #metadata, - } } - } - FixedList { - element, length, .. - } => { - let element = emit_schema_type(element); - quote! { golem_rust::schema::schema_type::SchemaType::FixedList { - element: Box::new(#element), length: #length, metadata: #metadata, - } } - } - Map { key, value, .. } => { - let key = emit_schema_type(key); - let value = emit_schema_type(value); - quote! { golem_rust::schema::schema_type::SchemaType::Map { - key: Box::new(#key), value: Box::new(#value), metadata: #metadata, - } } - } - Option { inner, .. } => { - let inner = emit_schema_type(inner); - quote! { golem_rust::schema::schema_type::SchemaType::Option { - inner: Box::new(#inner), metadata: #metadata, - } } +fn emit_body(body: &wire::SchemaTypeBody) -> TokenStream { + use wire::SchemaTypeBody::*; + let path = quote! { golem_rust::schema::wit::wire::SchemaTypeBody }; + match body { + RefType(value) => quote! { #path::RefType(#value) }, + BoolType => quote! { #path::BoolType }, + S8Type(value) => numeric_body("S8Type", value), + S16Type(value) => numeric_body("S16Type", value), + S32Type(value) => numeric_body("S32Type", value), + S64Type(value) => numeric_body("S64Type", value), + U8Type(value) => numeric_body("U8Type", value), + U16Type(value) => numeric_body("U16Type", value), + U32Type(value) => numeric_body("U32Type", value), + U64Type(value) => numeric_body("U64Type", value), + F32Type(value) => numeric_body("F32Type", value), + F64Type(value) => numeric_body("F64Type", value), + CharType => quote! { #path::CharType }, + StringType => quote! { #path::StringType }, + RecordType(fields) => { + let fields = fields.iter().map(emit_field); + quote! { #path::RecordType(vec![#(#fields),*]) } + } + VariantType(cases) => { + let cases = cases.iter().map(emit_case); + quote! { #path::VariantType(vec![#(#cases),*]) } + } + EnumType(values) => { + let values = string_vec(values); + quote! { #path::EnumType(#values) } } - Result { spec, .. } => { - let ok = option_boxed_type(spec.ok.as_deref()); - let err = option_boxed_type(spec.err.as_deref()); - quote! { golem_rust::schema::schema_type::SchemaType::Result { - spec: golem_rust::schema::schema_type::ResultSpec { ok: #ok, err: #err }, - metadata: #metadata, - } } + FlagsType(values) => { + let values = string_vec(values); + quote! { #path::FlagsType(#values) } + } + TupleType(values) => quote! { #path::TupleType(vec![#(#values),*]) }, + ListType(value) => quote! { #path::ListType(#value) }, + FixedListType(value) => { + let element = value.element; + let length = value.length; + quote! { #path::FixedListType(golem_rust::schema::wit::wire::FixedListSpec { + element: #element, length: #length, + }) } } - Text { restrictions, .. } => { - let restrictions = text_restrictions(restrictions); - quote! { golem_rust::schema::schema_type::SchemaType::Text { - restrictions: #restrictions, metadata: #metadata, - } } + MapType(value) => { + let key = value.key; + let val = value.value; + quote! { #path::MapType(golem_rust::schema::wit::wire::MapSpec { key: #key, value: #val }) } } - Binary { restrictions, .. } => { - let restrictions = binary_restrictions(restrictions); - quote! { golem_rust::schema::schema_type::SchemaType::Binary { - restrictions: #restrictions, metadata: #metadata, - } } + OptionType(value) => quote! { #path::OptionType(#value) }, + ResultType(value) => { + let ok = option_copy(value.ok); + let err = option_copy(value.err); + quote! { #path::ResultType(golem_rust::schema::wit::wire::ResultSpec { ok: #ok, err: #err }) } } - Path { spec, .. } => { - let spec = path_spec(spec); - quote! { golem_rust::schema::schema_type::SchemaType::Path { spec: #spec, metadata: #metadata } } + TextType(value) => { + let value = text_restrictions(value); + quote! { #path::TextType(#value) } } - Url { restrictions, .. } => { - let restrictions = url_restrictions(restrictions); - quote! { golem_rust::schema::schema_type::SchemaType::Url { - restrictions: #restrictions, metadata: #metadata, - } } + BinaryType(value) => { + let value = binary_restrictions(value); + quote! { #path::BinaryType(#value) } } - Datetime { .. } => { - quote! { golem_rust::schema::schema_type::SchemaType::Datetime { metadata: #metadata } } + PathType(value) => { + let value = path_spec(value); + quote! { #path::PathType(#value) } } - Duration { .. } => { - quote! { golem_rust::schema::schema_type::SchemaType::Duration { metadata: #metadata } } + UrlType(value) => { + let value = url_restrictions(value); + quote! { #path::UrlType(#value) } } - Quantity { spec, .. } => { - let spec = quantity_spec(spec); - quote! { golem_rust::schema::schema_type::SchemaType::Quantity { spec: #spec, metadata: #metadata } } + DatetimeType => quote! { #path::DatetimeType }, + DurationType => quote! { #path::DurationType }, + QuantityType(value) => { + let value = quantity_spec(value); + quote! { #path::QuantityType(#value) } } - Union { spec, .. } => { - let branches = spec.branches.iter().map(|branch| { - let tag = &branch.tag; - let body = emit_schema_type(&branch.body); - let discriminator = discriminator(&branch.discriminator); - let metadata = emit_metadata(&branch.metadata); - quote! { golem_rust::schema::schema_type::UnionBranch { - tag: #tag.to_string(), body: #body, discriminator: #discriminator, metadata: #metadata, - } } - }); - quote! { golem_rust::schema::schema_type::SchemaType::Union { - spec: golem_rust::schema::schema_type::UnionSpec { branches: vec![#(#branches),*] }, - metadata: #metadata, - } } + UnionType(value) => { + let branches = value.branches.iter().map(emit_branch); + quote! { #path::UnionType(golem_rust::schema::wit::wire::UnionSpec { branches: vec![#(#branches),*] }) } } - Secret { spec, .. } => { - let spec = secret_spec(spec); - quote! { golem_rust::schema::schema_type::SchemaType::Secret { spec: #spec, metadata: #metadata } } + SecretType(value) => { + let inner = value.inner; + let category = option_string(value.category.as_deref()); + quote! { #path::SecretType(golem_rust::schema::wit::wire::SecretSpec { inner: #inner, category: #category }) } } - QuotaToken { spec, .. } => { - let spec = quota_token_spec(spec); - quote! { golem_rust::schema::schema_type::SchemaType::QuotaToken { spec: #spec, metadata: #metadata } } + QuotaTokenType(value) => { + let resource_name = option_string(value.resource_name.as_deref()); + quote! { #path::QuotaTokenType(golem_rust::schema::wit::wire::QuotaTokenSpec { resource_name: #resource_name }) } } - PermissionCard { spec, .. } => { - let polymorphic = spec.polymorphic; - quote! { golem_rust::schema::schema_type::SchemaType::PermissionCard { - spec: golem_rust::schema::schema_type::PermissionCardSpec { polymorphic: #polymorphic }, - metadata: #metadata, - } } + PermissionCardType(value) => { + let polymorphic = value.polymorphic; + quote! { #path::PermissionCardType(golem_rust::schema::wit::wire::PermissionCardSpec { polymorphic: #polymorphic }) } } - Future { inner, .. } => { - let inner = option_boxed_type(inner.as_deref()); - quote! { golem_rust::schema::schema_type::SchemaType::Future { inner: #inner, metadata: #metadata } } + FutureType(value) => { + let value = option_copy(*value); + quote! { #path::FutureType(#value) } } - Stream { inner, .. } => { - let inner = option_boxed_type(inner.as_deref()); - quote! { golem_rust::schema::schema_type::SchemaType::Stream { inner: #inner, metadata: #metadata } } + StreamType(value) => { + let value = option_copy(*value); + quote! { #path::StreamType(#value) } } } } -fn numeric( - name: &str, - restrictions: Option<&NumericRestrictions>, - metadata: TokenStream, -) -> TokenStream { - let variant = syn::Ident::new(name, proc_macro2::Span::call_site()); - let restrictions = option_numeric_restrictions(restrictions); - quote! { golem_rust::schema::schema_type::SchemaType::#variant { - restrictions: #restrictions, metadata: #metadata, +fn emit_field(value: &wire::NamedFieldType) -> TokenStream { + let name = &value.name; + let body = value.body; + let metadata = emit_metadata(&value.metadata); + quote! { golem_rust::schema::wit::wire::NamedFieldType { + name: #name.to_string(), body: #body, metadata: #metadata, } } } -fn option_numeric_restrictions(value: Option<&NumericRestrictions>) -> TokenStream { - value.map_or_else( +fn emit_case(value: &wire::VariantCaseType) -> TokenStream { + let name = &value.name; + let payload = option_copy(value.payload); + let metadata = emit_metadata(&value.metadata); + quote! { golem_rust::schema::wit::wire::VariantCaseType { + name: #name.to_string(), payload: #payload, metadata: #metadata, + } } +} + +fn emit_branch(value: &wire::UnionBranch) -> TokenStream { + let tag = &value.tag; + let body = value.body; + let discriminator = emit_discriminator(&value.discriminator); + let metadata = emit_metadata(&value.metadata); + quote! { golem_rust::schema::wit::wire::UnionBranch { + tag: #tag.to_string(), body: #body, discriminator: #discriminator, metadata: #metadata, + } } +} + +fn numeric_body(name: &str, value: &Option) -> TokenStream { + let variant = syn::Ident::new(name, proc_macro2::Span::call_site()); + let value = value.as_ref().map_or_else( || quote! { None }, |value| { - let min = option_bound(value.min); - let max = option_bound(value.max); - let unit = option_string(value.unit.as_deref()); - quote! { Some(golem_rust::schema::schema_type::NumericRestrictions { - min: #min, max: #max, unit: #unit, - }) } + let value = numeric_restrictions(value); + quote! { Some(#value) } }, - ) + ); + quote! { golem_rust::schema::wit::wire::SchemaTypeBody::#variant(#value) } } -fn option_bound(value: Option) -> TokenStream { +fn numeric_restrictions(value: &wire::NumericRestrictions) -> TokenStream { + let min = option_bound(value.min.as_ref()); + let max = option_bound(value.max.as_ref()); + let unit = option_string(value.unit.as_deref()); + quote! { golem_rust::schema::wit::wire::NumericRestrictions { min: #min, max: #max, unit: #unit } } +} + +fn option_bound(value: Option<&wire::NumericBound>) -> TokenStream { value.map_or_else( || quote! { None }, |value| { let value = match value { - NumericBound::Signed(value) => { - let value = i64_literal(value); - quote! { golem_rust::schema::schema_type::NumericBound::Signed(#value) } + wire::NumericBound::Signed(value) => { + let value = i64_literal(*value); + quote! { golem_rust::schema::wit::wire::NumericBound::Signed(#value) } } - NumericBound::Unsigned(value) => { - quote! { golem_rust::schema::schema_type::NumericBound::Unsigned(#value) } + wire::NumericBound::Unsigned(value) => { + quote! { golem_rust::schema::wit::wire::NumericBound::Unsigned(#value) } } - NumericBound::FloatBits(value) => { - quote! { golem_rust::schema::schema_type::NumericBound::FloatBits(#value) } + wire::NumericBound::FloatBits(value) => { + quote! { golem_rust::schema::wit::wire::NumericBound::FloatBits(#value) } } }; quote! { Some(#value) } @@ -305,7 +252,7 @@ fn option_bound(value: Option) -> TokenStream { ) } -fn emit_metadata(value: &MetadataEnvelope) -> TokenStream { +fn emit_metadata(value: &wire::MetadataEnvelope) -> TokenStream { let doc = option_string(value.doc.as_deref()); let aliases = string_vec(&value.aliases); let examples = string_vec(&value.examples); @@ -314,170 +261,115 @@ fn emit_metadata(value: &MetadataEnvelope) -> TokenStream { || quote! { None }, |role| { let role = match role { - Role::Multimodal => quote! { golem_rust::schema::metadata::Role::Multimodal }, - Role::UnstructuredText => { - quote! { golem_rust::schema::metadata::Role::UnstructuredText } + wire::Role::Multimodal => { + quote! { golem_rust::schema::wit::wire::Role::Multimodal } + } + wire::Role::UnstructuredText => { + quote! { golem_rust::schema::wit::wire::Role::UnstructuredText } } - Role::UnstructuredBinary => { - quote! { golem_rust::schema::metadata::Role::UnstructuredBinary } + wire::Role::UnstructuredBinary => { + quote! { golem_rust::schema::wit::wire::Role::UnstructuredBinary } } - Role::Other(value) => { - quote! { golem_rust::schema::metadata::Role::Other(#value.to_string()) } + wire::Role::Other(value) => { + quote! { golem_rust::schema::wit::wire::Role::Other(#value.to_string()) } } }; quote! { Some(#role) } }, ); - quote! { golem_rust::schema::metadata::MetadataEnvelope { + quote! { golem_rust::schema::wit::wire::MetadataEnvelope { doc: #doc, aliases: #aliases, examples: #examples, deprecated: #deprecated, role: #role, } } } -fn text_restrictions(value: &TextRestrictions) -> TokenStream { +fn text_restrictions(value: &wire::TextRestrictions) -> TokenStream { let languages = option_string_vec(value.languages.as_deref()); let min_length = option_copy(value.min_length); let max_length = option_copy(value.max_length); let regex = option_string(value.regex.as_deref()); - quote! { golem_rust::schema::schema_type::TextRestrictions { + quote! { golem_rust::schema::wit::wire::TextRestrictions { languages: #languages, min_length: #min_length, max_length: #max_length, regex: #regex, } } } -fn binary_restrictions(value: &BinaryRestrictions) -> TokenStream { +fn binary_restrictions(value: &wire::BinaryRestrictions) -> TokenStream { let mime_types = option_string_vec(value.mime_types.as_deref()); let min_bytes = option_copy(value.min_bytes); let max_bytes = option_copy(value.max_bytes); - quote! { golem_rust::schema::schema_type::BinaryRestrictions { + quote! { golem_rust::schema::wit::wire::BinaryRestrictions { mime_types: #mime_types, min_bytes: #min_bytes, max_bytes: #max_bytes, } } } -fn path_spec(value: &PathSpec) -> TokenStream { +fn path_spec(value: &wire::PathSpec) -> TokenStream { let direction = match value.direction { - PathDirection::Input => quote! { golem_rust::schema::schema_type::PathDirection::Input }, - PathDirection::Output => quote! { golem_rust::schema::schema_type::PathDirection::Output }, - PathDirection::InOut => quote! { golem_rust::schema::schema_type::PathDirection::InOut }, + wire::PathDirection::Input => { + quote! { golem_rust::schema::wit::wire::PathDirection::Input } + } + wire::PathDirection::Output => { + quote! { golem_rust::schema::wit::wire::PathDirection::Output } + } + wire::PathDirection::InOut => { + quote! { golem_rust::schema::wit::wire::PathDirection::InOut } + } }; let kind = match value.kind { - PathKind::File => quote! { golem_rust::schema::schema_type::PathKind::File }, - PathKind::Directory => quote! { golem_rust::schema::schema_type::PathKind::Directory }, - PathKind::Any => quote! { golem_rust::schema::schema_type::PathKind::Any }, + wire::PathKind::File => quote! { golem_rust::schema::wit::wire::PathKind::File }, + wire::PathKind::Directory => quote! { golem_rust::schema::wit::wire::PathKind::Directory }, + wire::PathKind::Any => quote! { golem_rust::schema::wit::wire::PathKind::Any }, }; let allowed_mime_types = option_string_vec(value.allowed_mime_types.as_deref()); let allowed_extensions = option_string_vec(value.allowed_extensions.as_deref()); - quote! { golem_rust::schema::schema_type::PathSpec { - direction: #direction, kind: #kind, - allowed_mime_types: #allowed_mime_types, allowed_extensions: #allowed_extensions, - } } + quote! { golem_rust::schema::wit::wire::PathSpec { direction: #direction, kind: #kind, allowed_mime_types: #allowed_mime_types, allowed_extensions: #allowed_extensions } } } -fn url_restrictions(value: &UrlRestrictions) -> TokenStream { +fn url_restrictions(value: &wire::UrlRestrictions) -> TokenStream { let allowed_schemes = option_string_vec(value.allowed_schemes.as_deref()); let allowed_hosts = option_string_vec(value.allowed_hosts.as_deref()); - quote! { golem_rust::schema::schema_type::UrlRestrictions { - allowed_schemes: #allowed_schemes, allowed_hosts: #allowed_hosts, - } } + quote! { golem_rust::schema::wit::wire::UrlRestrictions { allowed_schemes: #allowed_schemes, allowed_hosts: #allowed_hosts } } } -fn quantity_spec(value: &QuantitySpec) -> TokenStream { +fn quantity_spec(value: &wire::QuantitySpec) -> TokenStream { let base_unit = &value.base_unit; let allowed_suffixes = string_vec(&value.allowed_suffixes); let min = option_quantity(value.min.as_ref()); let max = option_quantity(value.max.as_ref()); - quote! { golem_rust::schema::schema_type::QuantitySpec { - base_unit: #base_unit.to_string(), allowed_suffixes: #allowed_suffixes, min: #min, max: #max, - } } + quote! { golem_rust::schema::wit::wire::QuantitySpec { base_unit: #base_unit.to_string(), allowed_suffixes: #allowed_suffixes, min: #min, max: #max } } } -fn option_quantity(value: Option<&QuantityValue>) -> TokenStream { - value.map_or_else( - || quote! { None }, - |value| { - let mantissa = i64_literal(value.mantissa); - let scale = value.scale; - let unit = &value.unit; - quote! { Some(golem_rust::schema::schema_type::QuantityValue { - mantissa: #mantissa, scale: #scale, unit: #unit.to_string(), - }) } - }, - ) +fn option_quantity(value: Option<&wire::QuantityValue>) -> TokenStream { + value.map_or_else(|| quote! { None }, |value| { let mantissa = i64_literal(value.mantissa); let scale = value.scale; let unit = &value.unit; + quote! { Some(golem_rust::schema::wit::wire::QuantityValue { mantissa: #mantissa, scale: #scale, unit: #unit.to_string() }) } + }) } -fn discriminator(value: &DiscriminatorRule) -> TokenStream { +fn emit_discriminator(value: &wire::DiscriminatorRule) -> TokenStream { + let path = quote! { golem_rust::schema::wit::wire::DiscriminatorRule }; match value { - DiscriminatorRule::Prefix { prefix } => quote! { - golem_rust::schema::schema_type::DiscriminatorRule::Prefix { prefix: #prefix.to_string() } - }, - DiscriminatorRule::Suffix { suffix } => quote! { - golem_rust::schema::schema_type::DiscriminatorRule::Suffix { suffix: #suffix.to_string() } - }, - DiscriminatorRule::Contains { substring } => quote! { - golem_rust::schema::schema_type::DiscriminatorRule::Contains { substring: #substring.to_string() } - }, - DiscriminatorRule::Regex { regex } => quote! { - golem_rust::schema::schema_type::DiscriminatorRule::Regex { regex: #regex.to_string() } - }, - DiscriminatorRule::FieldEquals(field) => { - let field_name = &field.field_name; - let literal = option_string(field.literal.as_deref()); - quote! { golem_rust::schema::schema_type::DiscriminatorRule::FieldEquals( - golem_rust::schema::schema_type::FieldDiscriminator { - field_name: #field_name.to_string(), literal: #literal, - } - ) } + wire::DiscriminatorRule::Prefix(value) => quote! { #path::Prefix(#value.to_string()) }, + wire::DiscriminatorRule::Suffix(value) => quote! { #path::Suffix(#value.to_string()) }, + wire::DiscriminatorRule::Contains(value) => quote! { #path::Contains(#value.to_string()) }, + wire::DiscriminatorRule::Regex(value) => quote! { #path::Regex(#value.to_string()) }, + wire::DiscriminatorRule::FieldEquals(value) => { + let field_name = &value.field_name; + let literal = option_string(value.literal.as_deref()); + quote! { #path::FieldEquals(golem_rust::schema::wit::wire::FieldDiscriminator { field_name: #field_name.to_string(), literal: #literal }) } + } + wire::DiscriminatorRule::FieldAbsent(value) => { + quote! { #path::FieldAbsent(#value.to_string()) } } - DiscriminatorRule::FieldAbsent { field_name } => quote! { - golem_rust::schema::schema_type::DiscriminatorRule::FieldAbsent { - field_name: #field_name.to_string(), - } - }, } } -fn secret_spec(value: &SecretSpec) -> TokenStream { - let inner = emit_schema_type(&value.inner); - let category = option_string(value.category.as_deref()); - quote! { golem_rust::schema::schema_type::SecretSpec { - inner: Box::new(#inner), category: #category, - } } -} - -fn quota_token_spec(value: &QuotaTokenSpec) -> TokenStream { - let resource_name = option_string(value.resource_name.as_deref()); - quote! { golem_rust::schema::schema_type::QuotaTokenSpec { resource_name: #resource_name } } -} - -fn option_type(value: Option<&SchemaType>) -> TokenStream { - value.map_or_else( - || quote! { None }, - |value| { - let value = emit_schema_type(value); - quote! { Some(#value) } - }, - ) -} - -fn option_boxed_type(value: Option<&SchemaType>) -> TokenStream { - value.map_or_else( - || quote! { None }, - |value| { - let value = emit_schema_type(value); - quote! { Some(Box::new(#value)) } - }, - ) -} - fn option_string(value: Option<&str>) -> TokenStream { value.map_or_else( || quote! { None }, |value| quote! { Some(#value.to_string()) }, ) } - fn string_vec(values: &[String]) -> TokenStream { quote! { vec![#(#values.to_string()),*] } } - fn option_string_vec(values: Option<&[String]>) -> TokenStream { values.map_or_else( || quote! { None }, @@ -487,11 +379,9 @@ fn option_string_vec(values: Option<&[String]>) -> TokenStream { }, ) } - fn option_copy(value: Option) -> TokenStream { value.map_or_else(|| quote! { None }, |value| quote! { Some(#value) }) } - fn i64_literal(value: i64) -> TokenStream { if value == i64::MIN { quote! { i64::MIN } @@ -509,49 +399,41 @@ mod tests { use test_r::test; #[test] - fn exhaustive_literal_is_deterministic_and_preserves_edges() { - let graph = exhaustive_schema_graph(); - let literal = emit_schema_graph_literal(&graph); + fn exhaustive_wire_literal_is_deterministic_and_preserves_edges() { + let graph = encode_graph(&exhaustive_schema_graph()).unwrap(); + let literal = emit_wire_graph(&graph); let source = literal.to_string(); - - assert_eq!(source, emit_schema_graph_literal(&graph).to_string()); + assert_eq!(source, emit_wire_graph(&graph).to_string()); syn::parse2::(literal).unwrap(); for expected in [ - "i64 :: MIN", - "18446744073709551615u64", - "9223372036854775808u64", - "mantissa : i64 :: MIN", - "scale : - 2147483648i32", - "length : 4294967295u32", + "SchemaGraph", + "type_nodes", + "RefType", + "NumericRestrictions", "Role :: Multimodal", - "Role :: UnstructuredText", - "Role :: UnstructuredBinary", - "Role :: Other", - "DiscriminatorRule :: FieldEquals", - "DiscriminatorRule :: FieldAbsent", - "SchemaType :: Future", - "SchemaType :: Stream", + "FieldEquals", + "FutureType", + "StreamType", "fixture.Recursive", ] { assert!(source.contains(expected), "missing {expected}:\n{source}"); } - assert!(source.contains(r#"quote \" slash \\"#)); + assert!(!source.contains("schema :: graph :: SchemaGraph")); + assert!(!source.contains("encode_graph")); } #[test] - fn registry_deduplicates_exact_graphs_in_stable_order() { + fn registry_emits_flat_wire_graphs_and_deduplicates_in_stable_order() { let realistic = realistic_schema_graph(); let exhaustive = exhaustive_schema_graph(); let mut registry = SchemaGraphRegistry::default(); - assert_eq!(registry.intern(realistic.clone()), 0); assert_eq!(registry.intern(realistic), 0); assert_eq!(registry.intern(exhaustive), 1); - let definitions = registry.definitions().to_string(); assert_eq!(definitions.matches("static").count(), 2); - assert!(definitions.contains("__GOLEM_SCHEMA_GRAPH_0")); - assert!(definitions.contains("__GOLEM_SCHEMA_GRAPH_1")); + assert!(definitions.contains("wire :: SchemaGraph")); + assert!(!definitions.contains("schema :: graph :: SchemaGraph")); assert_eq!( graph_clone(0).to_string(), "(* __GOLEM_SCHEMA_GRAPH_0) . clone ()" diff --git a/cli/golem-cli/src/bridge_gen/rust/tool.rs b/cli/golem-cli/src/bridge_gen/rust/tool.rs index 55c7b9405b..bfce8a7646 100644 --- a/cli/golem-cli/src/bridge_gen/rust/tool.rs +++ b/cli/golem-cli/src/bridge_gen/rust/tool.rs @@ -161,6 +161,25 @@ impl RustToolBridgeGenerator { #schema_graphs + trait __WireParameter { + fn preflight(&self, resources: &mut golem_rust::schema::wit::direct::WirePreflight) -> Result<(), String>; + fn prepare(&self) -> std::pin::Pin> + '_>>; + fn encode(&self, writer: &mut golem_rust::schema::wit::direct::WireWriter) -> Result; + } + + struct __Parameter { + value: T, + preflight: fn(&T, &mut golem_rust::schema::wit::direct::WirePreflight) -> Result<(), String>, + prepare: for<'a> fn(&'a T) -> std::pin::Pin> + 'a>>, + encode: fn(&T, &mut golem_rust::schema::wit::direct::WireWriter) -> Result, + } + + impl __WireParameter for __Parameter { + fn preflight(&self, resources: &mut golem_rust::schema::wit::direct::WirePreflight) -> Result<(), String> { (self.preflight)(&self.value, resources) } + fn prepare(&self) -> std::pin::Pin> + '_>> { (self.prepare)(&self.value) } + fn encode(&self, writer: &mut golem_rust::schema::wit::direct::WireWriter) -> Result { (self.encode)(&self.value, writer) } + } + #(#client_items)* #(#error_items)* @@ -211,7 +230,7 @@ impl RustToolBridgeGenerator { #(#doc)* pub struct #struct_ident { rpc: golem_rust::golem_agentic::golem::tool::host::ToolRpc, - inherited: Vec, + inherited: Vec>, } impl #struct_ident { @@ -267,6 +286,30 @@ impl RustToolBridgeGenerator { Ok(methods) } + fn capture_parameter( + &mut self, + parameter: &Ident, + field: &CanonicalInputField, + ) -> anyhow::Result { + let preflight = self + .inner + .guest_wire_visit(quote! { value }, &field.type_, false, None)?; + let prepare = self + .inner + .guest_wire_visit(quote! { value }, &field.type_, true, None)?; + let encode = self + .inner + .guest_wire_encode_expr(quote! { value }, &field.type_, false, 0)?; + Ok(quote! { + std::rc::Rc::new(__Parameter { + value: #parameter, + preflight: |value, __resources| { #preflight Ok(()) }, + prepare: |value| Box::pin(async move { #prepare Ok(()) }), + encode: |value, __writer| { #encode }, + }) + }) + } + fn accessor_method( &mut self, child_index: usize, @@ -293,12 +336,10 @@ impl RustToolBridgeGenerator { let param_name = naming.fresh(self.inner.to_rust_ident(&field.name)); let param_ident = ident(¶m_name); let param_type = self.inner.type_reference(&field.type_, false)?; - let encode = - self.inner - .emit_encode_expr(quote! { #param_ident }, &field.type_, false, 0)?; + let capture = self.capture_parameter(¶m_ident, &field)?; param_defs.push(quote! { #param_ident: #param_type }); encodes.push(quote! { - inherited.push((#encode).expect("failed to encode tool parameter")); + inherited.push(#capture); }); } let tool_name = &self.tool_name; @@ -342,15 +383,10 @@ impl RustToolBridgeGenerator { let param_name = naming.fresh(self.inner.to_rust_ident(&field.name)); let param_ident = ident(¶m_name); let typ = self.inner.type_reference(&field.type_, false)?; - let encode = - self.inner - .emit_encode_expr(quote! { #param_ident }, &field.type_, false, 0)?; - let name = &field.name; + let capture = self.capture_parameter(¶m_ident, field)?; param_defs.push(quote! { #param_ident: #typ }); field_encodes.push(quote! { - __fields.push((|| -> Result { - #encode - })().map_err(|e| golem_rust::agentic::tool_protocol_error(format!("failed to encode tool parameter `{}`: {e}", #name)))?); + __parameters.push(#capture); }); } let stdin_expr = match &body.stdin { @@ -400,13 +436,16 @@ impl RustToolBridgeGenerator { Ok(quote! { #(#doc)* pub #asyncness fn #method_ident(&self, #(#param_defs),*) -> #return_type { - let __schema: golem_rust::SchemaGraph = #schema; - let mut __fields: Vec = self.inherited.clone(); + let mut __parameters = self.inherited.clone(); #(#field_encodes)* - let __input = golem_rust::TypedSchemaValue::new( - __schema, - crate::__golem_bridge_runtime::schema::SchemaValue::Record { fields: __fields }, - ); + let mut __resources = golem_rust::schema::wit::direct::WirePreflight::asynchronous(); + for parameter in &__parameters { parameter.preflight(&mut __resources).map_err(golem_rust::agentic::tool_protocol_error)?; } + for parameter in &__parameters { parameter.prepare().await.map_err(golem_rust::agentic::tool_protocol_error)?; } + let mut __writer = golem_rust::schema::wit::direct::WireWriter::default(); + let mut __fields = Vec::with_capacity(__parameters.len()); + for parameter in &__parameters { __fields.push(parameter.encode(&mut __writer).map_err(golem_rust::agentic::tool_protocol_error)?); } + let __root = __writer.push(__wire::SchemaValueNode::RecordValue(__fields)); + let __input = __wire::TypedSchemaValue { graph: #schema, value: __writer.finish(__root) }; #completion } }) @@ -420,34 +459,15 @@ impl RustToolBridgeGenerator { path_tokens: &[TokenStream], ) -> anyhow::Result { let decode_result = self.started_result_decode(body)?; - let decode_error = if body.errors.is_empty() { - quote! { - |_, _| Ok(None) - } - } else { - let error_ident = ident( - self.error_names - .get(&command_index) - .context("missing error enum")?, - ); - let decode_arms = self.error_decode_arms(&error_ident, body)?; - quote! { - |__name: String, __value: golem_rust::TypedSchemaValue| -> Result, String> { - let (_, __value) = __value.into_parts(); - match __name.as_str() { - #(#decode_arms)* - _ => Ok(None), - } - } - } - }; + let (recognizes, decode_error) = self.error_decoder(command_index, body)?; Ok(quote! { - golem_rust::agentic::start_tool_invocation( + golem_rust::agentic::start_tool_invocation_direct_input( &self.rpc, &[#(#path_tokens),*], - &__input, + __input, #stdin_expr, #decode_result, + #recognizes, #decode_error, ) }) @@ -460,40 +480,43 @@ impl RustToolBridgeGenerator { stdin_expr: &TokenStream, path_tokens: &[TokenStream], ) -> anyhow::Result { + let (recognizes, decode_error) = self.error_decoder(command_index, body)?; + Ok(quote! { + golem_rust::agentic::invoke_and_await_direct_with_error_decoder( + &self.rpc, + &[#(#path_tokens),*], + __input, + (#stdin_expr).map(golem_rust::agentic::pump_tool_stdin), + None, + #recognizes, + #decode_error, + ).await + }) + } + + fn error_decoder( + &mut self, + command_index: usize, + body: &CommandBody, + ) -> anyhow::Result<(TokenStream, TokenStream)> { if body.errors.is_empty() { - Ok(quote! { - golem_rust::agentic::invoke_and_await_infallible( - &self.rpc, - &[#(#path_tokens),*], - &__input, - (#stdin_expr).map(golem_rust::agentic::pump_tool_stdin), - None, - ).await - }) - } else { - let error_ident = ident( - self.error_names - .get(&command_index) - .context("missing error enum")?, - ); - let decode_arms = self.error_decode_arms(&error_ident, body)?; - Ok(quote! { - golem_rust::agentic::invoke_and_await( - &self.rpc, - &[#(#path_tokens),*], - &__input, - (#stdin_expr).map(golem_rust::agentic::pump_tool_stdin), - None, - |__name: String, __value: golem_rust::TypedSchemaValue| -> Result, String> { - let (_, __value) = __value.into_parts(); - match __name.as_str() { - #(#decode_arms)* - _ => Ok(None), - } - }, - ).await - }) + return Ok((quote! { |_| false }, quote! { |_, _, _| Ok(None) })); } + let error_ident = ident( + self.error_names + .get(&command_index) + .context("missing error enum")?, + ); + let names = body.errors.iter().map(|case| &case.name); + let arms = self.error_decode_arms(&error_ident, body)?; + Ok(( + quote! { |name| matches!(name, #(#names)|*) }, + quote! { + |__name: &str, __reader: &mut golem_rust::schema::wit::direct::WireReader, __value: i32| -> Result, String> { + match __name { #(#arms)* _ => Ok(None) } + } + }, + )) } fn error_decode_arms( @@ -505,15 +528,15 @@ impl RustToolBridgeGenerator { for (case, variant) in body.errors.iter().zip(error_variant_idents(body)) { let name = &case.name; if let Some(payload) = &case.payload { - let dec = self - .inner - .emit_decode_expr(quote! { __value }, payload, false, 0)?; + let dec = + self.inner + .guest_wire_decode_expr(quote! { __value }, payload, false, 0)?; arms.push(quote! { #name => (#dec).map(|__payload| Some(#error_ident::#variant(__payload))), }); } else { arms.push(quote! { - #name => <() as golem_rust::FromSchema>::from_value(&__value) + #name => <() as golem_rust::FromWire>::read_wire(__reader, __value) .map(|()| Some(#error_ident::#variant)) .map_err(|__error| __error.to_string()), }); @@ -523,39 +546,32 @@ impl RustToolBridgeGenerator { } fn started_result_decode(&mut self, body: &CommandBody) -> anyhow::Result { - match &body.result { - Some(result) => { - let dec = - self.inner - .emit_decode_expr(quote! { __value }, &result.type_, false, 0)?; - Ok(quote! { - |__result| { - let __value = golem_rust::agentic::expect_value(__result.result)?; - let (_, __value) = __value.into_parts(); - (#dec).map_err(golem_rust::agentic::tool_protocol_error) - } - }) - } - None => Ok(quote! { - |__result| golem_rust::agentic::expect_no_value(__result.result) - }), - } + let decode = self.result_decode(body)?; + Ok(quote! { |__result| { #decode } }) } fn result_decode(&mut self, body: &CommandBody) -> anyhow::Result { match &body.result { Some(result) => { - let dec = - self.inner - .emit_decode_expr(quote! { __value }, &result.type_, false, 0)?; + let dec = self.inner.guest_wire_decode_expr( + quote! { __value }, + &result.type_, + false, + 0, + )?; Ok(quote! { - let __value = golem_rust::agentic::expect_value(__result.result)?; - let (_, __value) = __value.into_parts(); - (#dec).map_err(golem_rust::agentic::tool_protocol_error) + let __value = __result.root.ok_or_else(|| golem_rust::agentic::tool_protocol_error("tool result did not contain a value"))?; + let mut __reader = __result.snapshot.reader(); + let __decoded = (|| -> Result<_, String> { + let __reader = &mut __reader; + #dec + })().map_err(golem_rust::agentic::tool_protocol_error)?; + __reader.finish().map_err(|e| golem_rust::agentic::tool_protocol_error(e.to_string()))?; + Ok(__decoded) }) } None => Ok(quote! { - golem_rust::agentic::expect_no_value(__result.result) + golem_rust::agentic::decode_direct_result_empty(__result) }), } } diff --git a/cli/golem-cli/src/bridge_gen/rust/wire.rs b/cli/golem-cli/src/bridge_gen/rust/wire.rs new file mode 100644 index 0000000000..553c442a62 --- /dev/null +++ b/cli/golem-cli/src/bridge_gen/rust/wire.rs @@ -0,0 +1,906 @@ +// Copyright 2024-2026 Golem Cloud +// +// Licensed under the Golem Source License v1.1 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://license.golem.cloud/LICENSE +// +// 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. + +use super::{RustBridgeGenerator, RustInput, RustOutput, RustTypeName}; +use anyhow::{anyhow, bail}; +use golem_common::schema::agent::{InputSchema, OutputSchema}; +use golem_common::schema::schema_type::SchemaType; +use golem_common::schema::unstructured::{ + unstructured_binary_restrictions, unstructured_text_restrictions, +}; +use proc_macro2::{Ident, Span, TokenStream}; +use quote::quote; +use syn::Index; + +impl RustBridgeGenerator { + pub(super) fn guest_wire_visit( + &mut self, + value: TokenStream, + typ: &SchemaType, + prepare: bool, + body: Option<&Ident>, + ) -> anyhow::Result { + if unstructured_text_restrictions(self.type_naming.graph(), typ)?.is_some() + || unstructured_binary_restrictions(self.type_naming.graph(), typ)?.is_some() + { + return Ok(quote! {}); + } + if body.is_none() + && let Some(RustTypeName::Derived(name)) = self.type_naming.type_name_for_type(typ) + { + let function = Ident::new( + &format!("{}_{name}", if prepare { "prepare" } else { "preflight" }), + Span::call_site(), + ); + return Ok(if prepare { + quote! { #function(#value).await?; } + } else { + quote! { #function(#value, __resources)?; } + }); + } + Ok(match typ { + SchemaType::Record { fields, .. } => { + let mut statements = Vec::new(); + for field in fields { + let name = Ident::new(&self.to_rust_ident(&field.name), Span::call_site()); + statements.push(self.guest_wire_visit( + quote! { &(#value).#name }, + &field.body, + prepare, + None, + )?); + } + quote! { #(#statements)* } + } + SchemaType::Variant { cases, .. } => { + let name = body.ok_or_else(|| anyhow!("unnamed variant in wire visitor"))?; + let mut arms = Vec::new(); + for case in cases { + let case_name = + Ident::new(&self.to_rust_case_name(&case.name), Span::call_site()); + if let Some(payload) = &case.payload { + let visit = + self.guest_wire_visit(quote! { __payload }, payload, prepare, None)?; + arms.push(quote! { #name::#case_name(__payload) => { #visit } }); + } else { + arms.push(quote! { #name::#case_name => {} }); + } + } + quote! { match #value { #(#arms)* } } + } + SchemaType::Union { spec, .. } => { + let name = body.ok_or_else(|| anyhow!("unnamed union in wire visitor"))?; + let mut arms = Vec::new(); + for branch in &spec.branches { + let case = Ident::new(&self.to_rust_case_name(&branch.tag), Span::call_site()); + let visit = + self.guest_wire_visit(quote! { __payload }, &branch.body, prepare, None)?; + arms.push(quote! { #name::#case(__payload) => { #visit } }); + } + quote! { match #value { #(#arms)* } } + } + SchemaType::Option { inner, .. } => { + let visit = self.guest_wire_visit(quote! { __payload }, inner, prepare, None)?; + quote! { if let Some(__payload) = #value { #visit } } + } + SchemaType::Result { spec, .. } => { + let ok = spec + .ok + .as_deref() + .map(|t| self.guest_wire_visit(quote! { __payload }, t, prepare, None)) + .transpose()?; + let err = spec + .err + .as_deref() + .map(|t| self.guest_wire_visit(quote! { __payload }, t, prepare, None)) + .transpose()?; + quote! { match #value { Ok(__payload) => { #ok } Err(__payload) => { #err } } } + } + SchemaType::List { element, .. } | SchemaType::FixedList { element, .. } => { + let visit = self.guest_wire_visit(quote! { __item }, element, prepare, None)?; + let check = if !prepare && let SchemaType::FixedList { length, .. } = typ { + let length = *length as usize; + quote! { + if (#value).len() != #length { + return Err(format!("Expected fixed-list of length {}, got {}", #length, (#value).len())); + } + } + } else { + quote! {} + }; + quote! { #check for __item in (#value).iter() { #visit } } + } + SchemaType::Map { + key, value: item, .. + } => { + let k = self.guest_wire_visit(quote! { __key }, key, prepare, None)?; + let v = self.guest_wire_visit(quote! { __item }, item, prepare, None)?; + quote! { for (__key, __item) in (#value).iter() { #k #v } } + } + SchemaType::Tuple { elements, .. } => { + let mut statements = Vec::new(); + for (index, element) in elements.iter().enumerate() { + let index = Index::from(index); + statements.push(self.guest_wire_visit( + quote! { &(#value).#index }, + element, + prepare, + None, + )?); + } + quote! { #(#statements)* } + } + SchemaType::Secret { .. } + | SchemaType::QuotaToken { .. } + | SchemaType::PermissionCard { .. } + | SchemaType::Stream { .. } => { + if prepare { + quote! { golem_rust::IntoWire::prepare_wire(#value).await.map_err(|e| e.to_string())?; } + } else { + quote! { golem_rust::IntoWire::preflight(#value, __resources).map_err(|e| e.to_string())?; } + } + } + SchemaType::Datetime { .. } if !prepare => quote! { + chrono::DateTime::parse_from_rfc3339(#value) + .map_err(|__error| format!("Invalid RFC3339 datetime: {__error}"))?; + }, + _ => quote! {}, + }) + } + + pub(super) fn guest_wire_visitors( + &mut self, + name: &Ident, + typ: &SchemaType, + ) -> anyhow::Result { + let preflight_name = Ident::new(&format!("preflight_{name}"), Span::call_site()); + let prepare_name = Ident::new(&format!("prepare_{name}"), Span::call_site()); + let preflight = self.guest_wire_visit(quote! { value }, typ, false, Some(name))?; + let prepare = self.guest_wire_visit(quote! { value }, typ, true, Some(name))?; + Ok(quote! { + fn #preflight_name(value: &#name, __resources: &mut golem_rust::schema::wit::direct::WirePreflight) -> Result<(), String> { + #preflight + Ok(()) + } + fn #prepare_name(value: &#name) -> std::pin::Pin> + '_>> { + Box::pin(async move { #prepare Ok(()) }) + } + }) + } + + pub(super) fn guest_input_wire( + &mut self, + input: &InputSchema, + names: &[String], + asynchronous: bool, + ) -> anyhow::Result { + let mut preflight = Vec::new(); + let mut prepare = Vec::new(); + let mut fields = Vec::new(); + match self.rust_input(input)? { + RustInput::Params(params) => { + for ((_, typ), name) in params.iter().zip(names) { + let name = Self::ident_from_name(name); + preflight.push(self.guest_wire_visit(quote! { &#name }, typ, false, None)?); + if asynchronous { + prepare.push(self.guest_wire_visit(quote! { &#name }, typ, true, None)?); + } + let encode = self.guest_wire_encode_expr(quote! { &#name }, typ, false, 0)?; + fields.push(quote! { #encode? }); + } + } + RustInput::Multimodal(cases) => { + let name = self.get_or_create_multimodal(&cases); + let encode = Self::ident_from_name(format!("encode_{name}")); + let preflight_fn = Self::ident_from_name(format!("preflight_{name}")); + let prepare_fn = Self::ident_from_name(format!("prepare_{name}")); + let parameter = Self::ident_from_name(&names[0]); + preflight.push( + quote! { for value in &#parameter { #preflight_fn(value, __resources)?; } }, + ); + if asynchronous { + prepare + .push(quote! { for value in &#parameter { #prepare_fn(value).await?; } }); + } + fields.push(quote! {{ + let items = #parameter.iter().map(|value| #encode(value, __writer)).collect::, String>>()?; + __writer.push(__wire::SchemaValueNode::ListValue(items)) + }}); + } + } + let resources = if asynchronous { + quote! { golem_rust::schema::wit::direct::WirePreflight::asynchronous() } + } else { + quote! { golem_rust::schema::wit::direct::WirePreflight::default() } + }; + let body = quote! { + let __resources = &mut #resources; + #(#preflight)* + #(#prepare)* + let mut writer = golem_rust::schema::wit::direct::WireWriter::default(); + let __writer = &mut writer; + let fields = vec![#(#fields),*]; + let root = __writer.push(__wire::SchemaValueNode::RecordValue(fields)); + Ok::<_, String>(writer.finish(root)) + }; + let invoke = if asynchronous { + quote! { (async move { #body }).await } + } else { + quote! { (|| { #body })() } + }; + Ok( + quote! { #invoke.map_err(|message| crate::__golem_bridge_runtime::ClientError::SchemaEncodeFailed { message })? }, + ) + } + + pub(super) fn guest_output_wire( + &mut self, + output: &OutputSchema, + ) -> anyhow::Result { + let decode = match self.rust_output(output)? { + RustOutput::Unit => quote! { Ok::<_, String>(()) }, + RustOutput::Single(typ) => { + self.guest_wire_decode_expr(quote! { __root }, &typ, false, 0)? + } + RustOutput::Multimodal(cases) => { + let name = self.get_or_create_multimodal(&cases); + let decode = Self::ident_from_name(format!("decode_{name}")); + quote! { match __reader.take(__root).map_err(|e| e.to_string())? { + __wire::SchemaValueNode::ListValue(items) => items.into_iter().map(|index| #decode(index, __reader)).collect::, String>>(), + _ => Err("Expected multimodal list".to_string()), + } } + } + }; + Ok(quote! { + let __root = __value.root; + let mut reader = golem_rust::schema::wit::direct::WireReader::new(__value.value_nodes); + let __reader = &mut reader; + let value = #decode?; + reader.finish().map_err(|e| e.to_string())?; + Ok(value) + }) + } + + pub(super) fn guest_wire_tree( + &mut self, + value: TokenStream, + typ: &SchemaType, + asynchronous: bool, + ) -> anyhow::Result { + let preflight = self.guest_wire_visit(quote! { &__item }, typ, false, None)?; + let prepare = if asynchronous { + self.guest_wire_visit(quote! { &__item }, typ, true, None)? + } else { + quote! {} + }; + let encode = self.guest_wire_encode_expr(quote! { &__item }, typ, false, 0)?; + let resources = if asynchronous { + quote! { golem_rust::schema::wit::direct::WirePreflight::asynchronous() } + } else { + quote! { golem_rust::schema::wit::direct::WirePreflight::default() } + }; + let body = quote! { + let __item = #value; + let __resources = &mut #resources; + #preflight + #prepare + let mut writer = golem_rust::schema::wit::direct::WireWriter::default(); + let __writer = &mut writer; + let root = #encode?; + Ok::<_, String>(writer.finish(root)) + }; + Ok(if asynchronous { + quote! { (async move { #body }).await } + } else { + quote! { (|| { #body })() } + }) + } + + pub(super) fn guest_wire_encode_expr( + &mut self, + val: TokenStream, + typ: &SchemaType, + box_recursive: bool, + depth: usize, + ) -> anyhow::Result { + let inner = if let Some(name) = self.type_naming.type_name_for_type(typ).cloned() { + let RustTypeName::Derived(name) = name else { + bail!("Remapped type names are not supported yet"); + }; + let function = Ident::new(&format!("encode_{name}"), Span::call_site()); + quote! { #function(#val, __writer) } + } else { + let inner = + self.guest_wire_encode_structural(quote! { __source }, typ, box_recursive, depth)?; + quote! { { let __source = #val; #inner } } + }; + Ok(quote! {{ let __result: Result = { #inner }; __result }}) + } + + pub(super) fn guest_wire_decode_expr( + &mut self, + index: TokenStream, + typ: &SchemaType, + box_recursive: bool, + depth: usize, + ) -> anyhow::Result { + let inner = if let Some(name) = self.type_naming.type_name_for_type(typ).cloned() { + let RustTypeName::Derived(name) = name else { + bail!("Remapped type names are not supported yet"); + }; + let function = Ident::new(&format!("decode_{name}"), Span::call_site()); + if box_recursive && self.type_naming.is_recursive_ref(typ) { + quote! { #function(#index, __reader).map(Box::new) } + } else { + quote! { #function(#index, __reader) } + } + } else { + self.guest_wire_decode_structural(index, typ, box_recursive, depth)? + }; + Ok(quote! {{ let __result: Result<_, String> = { #inner }; __result }}) + } + + fn guest_wire_encode_structural( + &mut self, + val: TokenStream, + typ: &SchemaType, + box_recursive: bool, + depth: usize, + ) -> anyhow::Result { + let text = unstructured_text_restrictions(self.type_naming.graph(), typ)?.cloned(); + if let Some(restrictions) = text { + let ty = self.unstructured_text_type(&restrictions); + return Ok(quote! { + <#ty as golem_rust::schema::wit::direct::IntoWire>::write_wire(#val, __writer) + .map_err(|__error| __error.to_string()) + }); + } + let binary = unstructured_binary_restrictions(self.type_naming.graph(), typ)?.cloned(); + if let Some(restrictions) = binary { + let ty = self.unstructured_binary_type(&restrictions); + return Ok(quote! { + <#ty as golem_rust::schema::wit::direct::IntoWire>::write_wire(#val, __writer) + .map_err(|__error| __error.to_string()) + }); + } + + let element = Ident::new(&format!("__element{depth}"), Span::call_site()); + let next = depth + 1; + let push = |node: TokenStream| quote! { Ok(__writer.push(#node)) }; + Ok(match typ { + SchemaType::Bool { .. } => push(quote! { __wire::SchemaValueNode::BoolValue(*#val) }), + SchemaType::S8 { .. } => push(quote! { __wire::SchemaValueNode::S8Value(*#val) }), + SchemaType::S16 { .. } => push(quote! { __wire::SchemaValueNode::S16Value(*#val) }), + SchemaType::S32 { .. } => push(quote! { __wire::SchemaValueNode::S32Value(*#val) }), + SchemaType::S64 { .. } => push(quote! { __wire::SchemaValueNode::S64Value(*#val) }), + SchemaType::U8 { .. } => push(quote! { __wire::SchemaValueNode::U8Value(*#val) }), + SchemaType::U16 { .. } => push(quote! { __wire::SchemaValueNode::U16Value(*#val) }), + SchemaType::U32 { .. } => push(quote! { __wire::SchemaValueNode::U32Value(*#val) }), + SchemaType::U64 { .. } => push(quote! { __wire::SchemaValueNode::U64Value(*#val) }), + SchemaType::F32 { .. } => push(quote! { __wire::SchemaValueNode::F32Value(*#val) }), + SchemaType::F64 { .. } => push(quote! { __wire::SchemaValueNode::F64Value(*#val) }), + SchemaType::Char { .. } => push(quote! { __wire::SchemaValueNode::CharValue(*#val) }), + SchemaType::String { .. } => { + push(quote! { __wire::SchemaValueNode::StringValue(#val.clone()) }) + } + SchemaType::Path { .. } => { + push(quote! { __wire::SchemaValueNode::PathValue(#val.clone()) }) + } + SchemaType::Url { .. } => { + push(quote! { __wire::SchemaValueNode::UrlValue(#val.clone()) }) + } + SchemaType::Duration { .. } => push(quote! { + __wire::SchemaValueNode::DurationValue(__wire::DurationValuePayload { nanoseconds: *#val }) + }), + SchemaType::Datetime { .. } => quote! { + chrono::DateTime::parse_from_rfc3339(&#val) + .map_err(|__error| format!("Invalid RFC3339 datetime: {__error}")) + .map(|__datetime| __writer.push(__wire::SchemaValueNode::DatetimeValue( + __wire::Datetime { + seconds: __datetime.timestamp(), + nanoseconds: __datetime.timestamp_subsec_nanos(), + } + ))) + }, + SchemaType::Option { inner, .. } => { + let encode = + self.guest_wire_encode_expr(quote! { #element }, inner, box_recursive, next)?; + quote! { + match #val { + Some(#element) => { let payload = #encode?; Ok(__writer.push(__wire::SchemaValueNode::OptionValue(Some(payload)))) }, + None => Ok(__writer.push(__wire::SchemaValueNode::OptionValue(None))), + } + } + } + SchemaType::List { element: item, .. } + | SchemaType::FixedList { element: item, .. } => { + let encode = self.guest_wire_encode_expr(quote! { #element }, item, false, next)?; + let node = if matches!(typ, SchemaType::List { .. }) { + quote! { __wire::SchemaValueNode::ListValue(__indices) } + } else { + quote! { __wire::SchemaValueNode::FixedListValue(__indices) } + }; + let length_check = if let SchemaType::FixedList { length, .. } = typ { + let length = *length as usize; + quote! { + if #val.len() != #length { + return Err(format!("Expected fixed-list of length {}, got {}", #length, #val.len())); + } + } + } else { + quote! {} + }; + quote! {{ + #length_check + let __indices = #val.into_iter().map(|#element| #encode).collect::, String>>()?; + Ok(__writer.push(#node)) + }} + } + SchemaType::Map { key, value, .. } => { + let key_name = Ident::new(&format!("__key{depth}"), Span::call_site()); + let value_name = Ident::new(&format!("__value{depth}"), Span::call_site()); + let encode_key = + self.guest_wire_encode_expr(quote! { #key_name }, key, false, next)?; + let encode_value = + self.guest_wire_encode_expr(quote! { #value_name }, value, false, next)?; + quote! {{ + let __entries = #val.into_iter().map(|(#key_name, #value_name)| { + Ok::<_, String>(__wire::MapEntry { key: #encode_key?, value: #encode_value? }) + }).collect::, String>>()?; + Ok(__writer.push(__wire::SchemaValueNode::MapValue(__entries))) + }} + } + SchemaType::Tuple { elements, .. } => { + let tuple = Ident::new(&format!("__tuple{depth}"), Span::call_site()); + let mut encoded = Vec::new(); + for (position, item) in elements.iter().enumerate() { + let position = Index::from(position); + let encode = self.guest_wire_encode_expr( + quote! { &#tuple.#position }, + item, + box_recursive, + next, + )?; + encoded.push(quote! { #encode? }); + } + quote! {{ + let #tuple = #val; + let elements = vec![#(#encoded),*]; + Ok(__writer.push(__wire::SchemaValueNode::TupleValue(elements))) + }} + } + SchemaType::Result { spec, .. } => { + let ok = match spec.ok.as_deref() { + Some(item) => { + let encode = self.guest_wire_encode_expr( + quote! { __payload }, + item, + box_recursive, + next, + )?; + quote! { Ok(__payload) => __wire::ResultValuePayload::OkValue(Some(#encode?)), } + } + None => quote! { Ok(_) => __wire::ResultValuePayload::OkValue(None), }, + }; + let err = match spec.err.as_deref() { + Some(item) => { + let encode = self.guest_wire_encode_expr( + quote! { __payload }, + item, + box_recursive, + next, + )?; + quote! { Err(__payload) => __wire::ResultValuePayload::ErrValue(Some(#encode?)), } + } + None => quote! { Err(_) => __wire::ResultValuePayload::ErrValue(None), }, + }; + quote! {{ + let __payload = match #val { #ok #err }; + Ok(__writer.push(__wire::SchemaValueNode::ResultValue(__payload))) + }} + } + SchemaType::Secret { .. } + | SchemaType::QuotaToken { .. } + | SchemaType::PermissionCard { .. } + | SchemaType::Stream { .. } => quote! { + golem_rust::schema::wit::direct::IntoWire::write_wire(#val, __writer) + .map_err(|__error| __error.to_string()) + }, + SchemaType::Text { .. } => bail!("Bare text rich scalars have no Rust bridge surface"), + SchemaType::Binary { .. } => { + bail!("Bare binary rich scalars have no guest Rust bridge surface") + } + SchemaType::Ref { .. } + | SchemaType::Record { .. } + | SchemaType::Variant { .. } + | SchemaType::Enum { .. } + | SchemaType::Flags { .. } + | SchemaType::Union { .. } => { + bail!("Expected a generated type name for {typ:?} during wire encoding") + } + SchemaType::Quantity { .. } | SchemaType::Future { .. } => { + bail!("SchemaType variant has no guest wire encoding yet; type = {typ:?}") + } + }) + } + + fn guest_wire_decode_structural( + &mut self, + index: TokenStream, + typ: &SchemaType, + box_recursive: bool, + depth: usize, + ) -> anyhow::Result { + let text = unstructured_text_restrictions(self.type_naming.graph(), typ)?.cloned(); + if let Some(restrictions) = text { + let ty = self.unstructured_text_type(&restrictions); + return Ok(quote! { + <#ty as golem_rust::schema::wit::direct::FromWire>::read_wire(__reader, #index) + .map_err(|__error| __error.to_string()) + }); + } + let binary = unstructured_binary_restrictions(self.type_naming.graph(), typ)?.cloned(); + if let Some(restrictions) = binary { + let ty = self.unstructured_binary_type(&restrictions); + return Ok(quote! { + <#ty as golem_rust::schema::wit::direct::FromWire>::read_wire(__reader, #index) + .map_err(|__error| __error.to_string()) + }); + } + + let next = depth + 1; + macro_rules! scalar { + ($variant:ident, $expected:literal) => { + quote! { match __reader.take(#index).map_err(|e| e.to_string())? { + __wire::SchemaValueNode::$variant(__value) => Ok(__value), + __other => Err(format!(concat!("Expected ", $expected, " value, got {:?}"), __other)), + }} + }; + } + Ok(match typ { + SchemaType::Bool { .. } => scalar!(BoolValue, "bool"), + SchemaType::S8 { .. } => scalar!(S8Value, "s8"), + SchemaType::S16 { .. } => scalar!(S16Value, "s16"), + SchemaType::S32 { .. } => scalar!(S32Value, "s32"), + SchemaType::S64 { .. } => scalar!(S64Value, "s64"), + SchemaType::U8 { .. } => scalar!(U8Value, "u8"), + SchemaType::U16 { .. } => scalar!(U16Value, "u16"), + SchemaType::U32 { .. } => scalar!(U32Value, "u32"), + SchemaType::U64 { .. } => scalar!(U64Value, "u64"), + SchemaType::F32 { .. } => scalar!(F32Value, "f32"), + SchemaType::F64 { .. } => scalar!(F64Value, "f64"), + SchemaType::Char { .. } => scalar!(CharValue, "char"), + SchemaType::String { .. } => scalar!(StringValue, "string"), + SchemaType::Path { .. } => scalar!(PathValue, "path"), + SchemaType::Url { .. } => scalar!(UrlValue, "url"), + SchemaType::Duration { .. } => { + quote! { match __reader.take(#index).map_err(|e| e.to_string())? { + __wire::SchemaValueNode::DurationValue(__value) => Ok(__value.nanoseconds), + __other => Err(format!("Expected duration value, got {:?}", __other)), + }} + } + SchemaType::Datetime { .. } => { + quote! { match __reader.take(#index).map_err(|e| e.to_string())? { + __wire::SchemaValueNode::DatetimeValue(__value) => + chrono::DateTime::::from_timestamp(__value.seconds, __value.nanoseconds) + .map(|__value| __value.to_rfc3339()) + .ok_or_else(|| "Expected valid datetime value".to_string()), + __other => Err(format!("Expected datetime value, got {:?}", __other)), + }} + } + SchemaType::Option { inner, .. } => { + let decode = + self.guest_wire_decode_expr(quote! { __inner }, inner, box_recursive, next)?; + quote! { match __reader.take(#index).map_err(|e| e.to_string())? { + __wire::SchemaValueNode::OptionValue(Some(__inner)) => Ok(Some(#decode?)), + __wire::SchemaValueNode::OptionValue(None) => Ok(None), + __other => Err(format!("Expected option value, got {:?}", __other)), + }} + } + SchemaType::List { element, .. } | SchemaType::FixedList { element, .. } => { + let decode = + self.guest_wire_decode_expr(quote! { __child }, element, false, next)?; + let pattern = if matches!(typ, SchemaType::List { .. }) { + quote! { __wire::SchemaValueNode::ListValue(__children) } + } else { + quote! { __wire::SchemaValueNode::FixedListValue(__children) } + }; + let expected = if matches!(typ, SchemaType::List { .. }) { + "list" + } else { + "fixed-list" + }; + let check = if let SchemaType::FixedList { length, .. } = typ { + let length = *length as usize; + quote! { if __children.len() != #length { return Err(format!("Expected fixed-list of length {}, got {}", #length, __children.len())); } } + } else { + quote! {} + }; + quote! { match __reader.take(#index).map_err(|e| e.to_string())? { + #pattern => { #check __children.into_iter().map(|__child| #decode).collect::, String>>() } + __other => Err(format!("Expected {} value, got {:?}", #expected, __other)), + }} + } + SchemaType::Map { key, value, .. } => { + let decode_key = + self.guest_wire_decode_expr(quote! { __entry.key }, key, false, next)?; + let decode_value = + self.guest_wire_decode_expr(quote! { __entry.value }, value, false, next)?; + quote! { match __reader.take(#index).map_err(|e| e.to_string())? { + __wire::SchemaValueNode::MapValue(__entries) => __entries.into_iter().map(|__entry| Ok::<_, String>((#decode_key?, #decode_value?))).collect(), + __other => Err(format!("Expected map value, got {:?}", __other)), + }} + } + SchemaType::Tuple { elements, .. } => { + let count = elements.len(); + let mut decoded = Vec::new(); + for item in elements { + let decode = self.guest_wire_decode_expr( + quote! { __children.next().unwrap() }, + item, + box_recursive, + next, + )?; + decoded.push(quote! { #decode? }); + } + let tuple = if count == 0 { + quote! { () } + } else if count == 1 { + quote! { (#(#decoded),*,) } + } else { + quote! { (#(#decoded),*) } + }; + quote! { match __reader.take(#index).map_err(|e| e.to_string())? { + __wire::SchemaValueNode::TupleValue(__children) => { + if __children.len() != #count { return Err(format!("Expected tuple with {} elements, got {}", #count, __children.len())); } + let mut __children = __children.into_iter(); Ok(#tuple) + } + __other => Err(format!("Expected tuple value, got {:?}", __other)), + }} + } + SchemaType::Result { spec, .. } => { + let ok = match spec.ok.as_deref() { + Some(item) => { + let dec = self.guest_wire_decode_expr( + quote! { __payload }, + item, + box_recursive, + next, + )?; + quote! { Some(__payload) => Ok(Ok(#dec?)), None => Err("Missing ok value".to_string()), } + } + None => { + quote! { None => Ok(Ok(())), Some(_) => Err("Unexpected ok value".to_string()), } + } + }; + let err = match spec.err.as_deref() { + Some(item) => { + let dec = self.guest_wire_decode_expr( + quote! { __payload }, + item, + box_recursive, + next, + )?; + quote! { Some(__payload) => Ok(Err(#dec?)), None => Err("Missing err value".to_string()), } + } + None => { + quote! { None => Ok(Err(())), Some(_) => Err("Unexpected err value".to_string()), } + } + }; + quote! { match __reader.take(#index).map_err(|e| e.to_string())? { + __wire::SchemaValueNode::ResultValue(__wire::ResultValuePayload::OkValue(__payload)) => match __payload { #ok }, + __wire::SchemaValueNode::ResultValue(__wire::ResultValuePayload::ErrValue(__payload)) => match __payload { #err }, + __other => Err(format!("Expected result value, got {:?}", __other)), + }} + } + SchemaType::Secret { .. } => { + quote! { ::read_wire(__reader, #index).map_err(|e| e.to_string()) } + } + SchemaType::QuotaToken { .. } => { + quote! { ::read_wire(__reader, #index).map_err(|e| e.to_string()) } + } + SchemaType::PermissionCard { .. } => { + quote! { ::read_wire(__reader, #index).map_err(|e| e.to_string()) } + } + SchemaType::Stream { inner, .. } => { + let inner = inner + .as_deref() + .ok_or_else(|| anyhow!("Cannot generate an untyped Rust AgentStream"))?; + let decode = self.guest_wire_decode_expr(quote! { __root }, inner, false, next)?; + quote! {{ + let stream = ::read_wire(__reader, #index) + .map_err(|e| e.to_string())?; + Ok(golem_rust::agentic::AgentStream::from_schema_stream_with_wire_decoder(stream, |tree| { + let __root = tree.root; + let mut reader = golem_rust::schema::wit::direct::WireReader::new(tree.value_nodes); + let __reader = &mut reader; + let value = #decode?; + reader.finish().map_err(|e| e.to_string())?; + Ok(value) + })) + }} + } + SchemaType::Text { .. } => bail!("Bare text rich scalars have no Rust bridge surface"), + SchemaType::Binary { .. } => { + bail!("Bare binary rich scalars have no guest Rust bridge surface") + } + SchemaType::Ref { .. } + | SchemaType::Record { .. } + | SchemaType::Variant { .. } + | SchemaType::Enum { .. } + | SchemaType::Flags { .. } + | SchemaType::Union { .. } => { + bail!("Expected a generated type name for {typ:?} during wire decoding") + } + SchemaType::Quantity { .. } | SchemaType::Future { .. } => { + bail!("SchemaType variant has no guest wire decoding yet; type = {typ:?}") + } + }) + } + + pub(super) fn guest_wire_encode_body( + &mut self, + name: &Ident, + resolved: &SchemaType, + ) -> anyhow::Result { + match resolved { + SchemaType::Record { fields, .. } => { + let names = fields + .iter() + .map(|field| Ident::new(&self.to_rust_ident(&field.name), Span::call_site())) + .collect::>(); + let mut encoded = Vec::new(); + for (field, field_name) in fields.iter().zip(&names) { + let enc = + self.guest_wire_encode_expr(quote! { #field_name }, &field.body, true, 0)?; + encoded.push(quote! { #enc? }); + } + Ok( + quote! { let #name { #(#names),* } = value; let fields = vec![#(#encoded),*]; Ok(__writer.push(__wire::SchemaValueNode::RecordValue(fields))) }, + ) + } + SchemaType::Variant { cases, .. } => { + let mut complete = Vec::new(); + for (case_index, case) in cases.iter().enumerate() { + let case_name = + Ident::new(&self.to_rust_case_name(&case.name), Span::call_site()); + let i = case_index as u32; + if let Some(payload) = &case.payload { + let enc = + self.guest_wire_encode_expr(quote! { __payload }, payload, true, 0)?; + complete + .push(quote! { #name::#case_name(__payload) => (#i, Some(#enc?)), }); + } else { + complete.push(quote! { #name::#case_name => (#i, None), }); + } + } + Ok( + quote! { let (__case, __payload) = match value { #(#complete)* }; Ok(__writer.push(__wire::SchemaValueNode::VariantValue(__wire::VariantValuePayload { case: __case, payload: __payload }))) }, + ) + } + SchemaType::Enum { cases, .. } => { + let arms = cases.iter().enumerate().map(|(i, case)| { + let case = Ident::new(&self.to_rust_case_name(case), Span::call_site()); + let i = i as u32; + quote! { #name::#case => #i, } + }); + Ok( + quote! { Ok(__writer.push(__wire::SchemaValueNode::EnumValue(match value { #(#arms)* }))) }, + ) + } + SchemaType::Flags { flags, .. } => { + let names = flags + .iter() + .map(|flag| Ident::new(&self.to_rust_ident(flag), Span::call_site())) + .collect::>(); + Ok( + quote! { let #name { #(#names),* } = value; Ok(__writer.push(__wire::SchemaValueNode::FlagsValue(vec![#(*#names),*]))) }, + ) + } + SchemaType::Union { spec, .. } => { + let mut arms = Vec::new(); + for branch in &spec.branches { + let case = Ident::new(&self.to_rust_case_name(&branch.tag), Span::call_site()); + let tag = &branch.tag; + let enc = + self.guest_wire_encode_expr(quote! { __body }, &branch.body, true, 0)?; + arms.push(quote! { #name::#case(__body) => (#tag.to_string(), #enc?), }); + } + Ok( + quote! { let (__tag, __body) = match value { #(#arms)* }; Ok(__writer.push(__wire::SchemaValueNode::UnionValue(__wire::UnionValuePayload { tag: __tag, body: __body }))) }, + ) + } + other => self.guest_wire_encode_structural(quote! { value }, other, true, 0), + } + } + + pub(super) fn guest_wire_decode_body( + &mut self, + name: &Ident, + resolved: &SchemaType, + ) -> anyhow::Result { + match resolved { + SchemaType::Record { fields, .. } => { + let count = fields.len(); + let mut values = Vec::new(); + for field in fields { + let field_name = + Ident::new(&self.to_rust_ident(&field.name), Span::call_site()); + let dec = self.guest_wire_decode_expr( + quote! { __fields.next().unwrap() }, + &field.body, + true, + 0, + )?; + values.push(quote! { #field_name: #dec? }); + } + Ok( + quote! { match __reader.take(value).map_err(|e| e.to_string())? { __wire::SchemaValueNode::RecordValue(__fields) => { if __fields.len() != #count { return Err(format!("Expected record with {} fields, got {}", #count, __fields.len())); } let mut __fields = __fields.into_iter(); Ok(#name { #(#values),* }) } __other => Err(format!("Expected record value, got {:?}", __other)), } }, + ) + } + SchemaType::Variant { cases, .. } => { + let mut arms = Vec::new(); + for (i, case) in cases.iter().enumerate() { + let case_name = + Ident::new(&self.to_rust_case_name(&case.name), Span::call_site()); + let i = i as u32; + if let Some(payload) = &case.payload { + let dec = + self.guest_wire_decode_expr(quote! { __payload }, payload, true, 0)?; + arms.push(quote! { #i => { let __payload = __payload.ok_or_else(|| format!("Missing payload for variant case {}", #i))?; Ok(#name::#case_name(#dec?)) } }); + } else { + arms.push(quote! { #i => if __payload.is_none() { Ok(#name::#case_name) } else { Err(format!("Unexpected payload for variant case {}", #i)) }, }); + } + } + Ok( + quote! { match __reader.take(value).map_err(|e| e.to_string())? { __wire::SchemaValueNode::VariantValue(__wire::VariantValuePayload { case: __case, payload: __payload }) => match __case { #(#arms)* __other => Err(format!("Invalid variant case index: {}", __other)), }, __other => Err(format!("Expected variant value, got {:?}", __other)), } }, + ) + } + SchemaType::Enum { cases, .. } => { + let arms = cases.iter().enumerate().map(|(i, case)| { + let case = Ident::new(&self.to_rust_case_name(case), Span::call_site()); + let i = i as u32; + quote! { #i => Ok(#name::#case), } + }); + Ok( + quote! { match __reader.take(value).map_err(|e| e.to_string())? { __wire::SchemaValueNode::EnumValue(__case) => match __case { #(#arms)* __other => Err(format!("Invalid enum case index: {}", __other)), }, __other => Err(format!("Expected enum value, got {:?}", __other)), } }, + ) + } + SchemaType::Flags { flags, .. } => { + let count = flags.len(); + let values = flags.iter().enumerate().map(|(i, flag)| { + let flag = Ident::new(&self.to_rust_ident(flag), Span::call_site()); + quote! { #flag: __bits[#i] } + }); + Ok( + quote! { match __reader.take(value).map_err(|e| e.to_string())? { __wire::SchemaValueNode::FlagsValue(__bits) => { if __bits.len() != #count { return Err(format!("Expected flags with {} bits, got {}", #count, __bits.len())); } Ok(#name { #(#values),* }) } __other => Err(format!("Expected flags value, got {:?}", __other)), } }, + ) + } + SchemaType::Union { spec, .. } => { + let mut arms = Vec::new(); + for branch in &spec.branches { + let case = Ident::new(&self.to_rust_case_name(&branch.tag), Span::call_site()); + let tag = &branch.tag; + let dec = + self.guest_wire_decode_expr(quote! { __body }, &branch.body, true, 0)?; + arms.push(quote! { #tag => Ok(#name::#case(#dec?)), }); + } + Ok( + quote! { match __reader.take(value).map_err(|e| e.to_string())? { __wire::SchemaValueNode::UnionValue(__wire::UnionValuePayload { tag: __tag, body: __body }) => match __tag.as_str() { #(#arms)* __other => Err(format!("Unknown union branch tag: {}", __other)), }, __other => Err(format!("Expected union value, got {:?}", __other)), } }, + ) + } + other => self.guest_wire_decode_structural(quote! { value }, other, true, 0), + } + } +} diff --git a/cli/golem-cli/templates/effect/common-on-demand/rollup.config.component.mjs b/cli/golem-cli/templates/effect/common-on-demand/rollup.config.component.mjs index 82afa7afea..7a75781aa0 100644 --- a/cli/golem-cli/templates/effect/common-on-demand/rollup.config.component.mjs +++ b/cli/golem-cli/templates/effect/common-on-demand/rollup.config.component.mjs @@ -3,11 +3,11 @@ import json from "@rollup/plugin-json"; import nodeResolve from "@rollup/plugin-node-resolve"; import typescript from "@rollup/plugin-typescript"; import ts from "typescript"; -import { existsSync } from "node:fs"; import { createRequire } from "node:module"; import path from "node:path"; import process from "node:process"; -import { pathToFileURL } from "node:url"; +import { rollup } from "rollup"; +import { componentConfiguration } from "@golemcloud/effect-golem/build"; const componentName = process.env.GOLEM_COMPONENT_NAME; const golemTemp = process.env.GOLEM_TEMP; @@ -24,19 +24,16 @@ if (!appRootDir) { } const embeddedPackages = new Set([ - "@golemcloud/effect-golem", - "@golemcloud/effect-golem/middleware", - "@golemcloud/effect-golem/sqlite", - "@golemcloud/effect-golem/postgres", - "@golemcloud/effect-golem/mysql", - "@golemcloud/effect-golem/ignite2", "effect", "effect/unstable/http", "agent-guest", ]); const externalPackages = (id) => - embeddedPackages.has(id) || id.startsWith("golem:") || id.startsWith("wasi:"); + embeddedPackages.has(id) || + id === "node:sqlite" || + id.startsWith("golem:") || + id.startsWith("wasi:"); const tsconfigPath = path.resolve("tsconfig.json"); const { config, error } = ts.readConfigFile(tsconfigPath, ts.sys.readFile); @@ -71,126 +68,7 @@ if (actualEffectVersion !== expectedEffectVersion) { `Pin "effect" to "${expectedEffectVersion}" in package.json.`, ); } -const effectDistDir = path.join(effectPackageDir, "dist"); -const effectRootFacadePrefix = "\0golem-effect-root-facade:"; -const effectRedactedFacade = "\0golem-effect-redacted-facade"; - -const stableEffectModuleName = (source, importer) => { - const packageSubpath = - /^effect\/((?:unstable\/(?:http|httpapi)\/)?[A-Za-z_$][A-Za-z0-9_$]*)$/.exec( - source, - ); - if (packageSubpath) { - const moduleName = packageSubpath[1]; - if ( - !moduleName.endsWith("index") && - existsSync(path.join(effectDistDir, `${moduleName}.js`)) - ) { - return moduleName; - } - return undefined; - } - - if (!importer || !source.startsWith(".") || importer.startsWith("\0")) { - return undefined; - } - - const resolved = path.resolve(path.dirname(importer), source); - const relative = path - .relative(effectDistDir, resolved) - .split(path.sep) - .join("/"); - if ( - /^(?:unstable\/(?:http|httpapi)\/)?[A-Za-z_$][A-Za-z0-9_$]*\.js$/.test( - relative, - ) - ) { - return relative.slice(0, -3); - } - - return undefined; -}; - -const resolvesToEffectInternal = (source, importer, internalPath) => { - if (!importer || !source.startsWith(".") || importer.startsWith("\0")) { - return false; - } - - return ( - path.resolve(path.dirname(importer), source) === - path.join(effectDistDir, "internal", internalPath) - ); -}; - -const sharedEffectRuntime = () => ({ - name: "golem-shared-effect-runtime", - resolveId(source, importer) { - const moduleName = stableEffectModuleName(source, importer); - if ( - moduleName === "unstable/httpapi/HttpApiScalar" || - moduleName === "unstable/httpapi/HttpApiSwagger" - ) { - return null; - } - if (moduleName) { - return { - id: `${effectRootFacadePrefix}${moduleName}`, - moduleSideEffects: false, - }; - } - - if (resolvesToEffectInternal(source, importer, "redacted.js")) { - return { id: effectRedactedFacade, moduleSideEffects: false }; - } - - return null; - }, - async load(id) { - if (id === effectRedactedFacade) { - return ` - import { Redacted } from "effect"; - export const value = Redacted.value; - export const stringOrRedacted = (input) => - typeof input === "string" ? input : Redacted.value(input); - `; - } - - if (!id.startsWith(effectRootFacadePrefix)) { - return null; - } - - const moduleName = id.slice(effectRootFacadePrefix.length); - const modulePath = path.join(effectDistDir, `${moduleName}.js`); - let moduleExports; - try { - moduleExports = Object.keys(await import(pathToFileURL(modulePath))); - } catch (error) { - throw new Error( - `Cannot share Effect module ${JSON.stringify(`effect/${moduleName}`)} ` + - `with the embedded runtime: ${String(error)}`, - ); - } - - const validExports = moduleExports.filter( - (name) => name !== "default" && /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name), - ); - const separator = moduleName.lastIndexOf("/"); - const namespace = moduleName.slice(separator + 1); - const barrel = - separator === -1 ? "effect" : `effect/${moduleName.slice(0, separator)}`; - return [ - barrel === "effect/unstable/httpapi" - ? `import { GolemHttpApi } from "effect"; const sharedModule = GolemHttpApi.${namespace};` - : `import { ${namespace} as sharedModule } from ${JSON.stringify(barrel)};`, - ...validExports.map((name, index) => { - const localName = `sharedExport${index}`; - return `const ${localName} = /* @__PURE__ */ (() => sharedModule.${name})(); export { ${localName} as ${name} };`; - }), - ].join("\n"); - }, -}); - -export default { +const configuration = { input: "./src/main.ts", output: { file: `${golemTemp}/ts-dist/${componentName}/main.js`, @@ -200,7 +78,6 @@ export default { }, external: externalPackages, plugins: [ - sharedEffectRuntime(), nodeResolve({ extensions: [".mjs", ".js", ".node", ".ts"], }), @@ -214,3 +91,5 @@ export default { }), ], }; + +export default await componentConfiguration(rollup, configuration); diff --git a/cli/golem-cli/templates/effect/common/AGENTS.md b/cli/golem-cli/templates/effect/common/AGENTS.md index 8594ecebdc..286eacf3ef 100644 --- a/cli/golem-cli/templates/effect/common/AGENTS.md +++ b/cli/golem-cli/templates/effect/common/AGENTS.md @@ -32,7 +32,7 @@ Single-component applications keep `src/` and `tsconfig.json` at the application - Import the SDK from `@golemcloud/effect-golem` and Effect APIs from `effect`. - Use `@golemcloud/effect-golem/sqlite`, `/postgres`, `/mysql`, or `/ignite2` for database access. Native Node database drivers cannot run inside WebAssembly. -- The build externalizes Effect and the Effect SDK because both are embedded in the SDK's base WASM. Keep their versions aligned with the generated `package.json`. +- The build bundles reachable SDK code and generates static exports for discovered capabilities. Effect remains shared in the base WASM. Keep its version aligned with the generated `package.json`. - The runtime reports Effect components as TypeScript source, so CLI values and agent IDs use TypeScript syntax. ## Commands diff --git a/cli/golem-cli/templates/rust/component/component-dir/Cargo.toml._ b/cli/golem-cli/templates/rust/component/component-dir/Cargo.toml._ index 6eed6ddf7c..016c589246 100644 --- a/cli/golem-cli/templates/rust/component/component-dir/Cargo.toml._ +++ b/cli/golem-cli/templates/rust/component/component-dir/Cargo.toml._ @@ -6,6 +6,7 @@ edition = "2024" [profile.release] opt-level = "s" lto = true +strip = "symbols" [lib] crate-type = ["cdylib"] diff --git a/cli/golem-cli/templates/rust/human-in-the-loop/component-dir/src/decision.rs b/cli/golem-cli/templates/rust/human-in-the-loop/component-dir/src/decision.rs index 528fed8ca7..8101453eae 100644 --- a/cli/golem-cli/templates/rust/human-in-the-loop/component-dir/src/decision.rs +++ b/cli/golem-cli/templates/rust/human-in-the-loop/component-dir/src/decision.rs @@ -1,8 +1,19 @@ -use golem_rust::Schema; +use golem_rust::{FromWire, IntoWire, Schema, WireSchema}; pub type WorkflowId = String; -#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, Schema)] +#[derive( + Debug, + Clone, + PartialEq, + Eq, + serde::Serialize, + serde::Deserialize, + Schema, + FromWire, + IntoWire, + WireSchema, +)] pub enum Decision { Approved, Rejected, diff --git a/cli/golem-cli/templates/rust/json/component-dir/src/tasks.rs b/cli/golem-cli/templates/rust/json/component-dir/src/tasks.rs index a4163dd8b1..194f4281e8 100644 --- a/cli/golem-cli/templates/rust/json/component-dir/src/tasks.rs +++ b/cli/golem-cli/templates/rust/json/component-dir/src/tasks.rs @@ -1,7 +1,10 @@ use chrono::{DateTime, Utc}; -use golem_rust::{agent_definition, agent_implementation, description, endpoint, prompt, Schema}; +use golem_rust::{ + FromWire, IntoWire, Schema, WireSchema, agent_definition, agent_implementation, description, + endpoint, prompt, +}; -#[derive(Clone, Schema)] +#[derive(Clone, Schema, FromWire, IntoWire, WireSchema)] pub struct Task { id: usize, title: String, @@ -9,7 +12,7 @@ pub struct Task { created_at: DateTime, } -#[derive(Schema)] +#[derive(Schema, FromWire, IntoWire, WireSchema)] pub struct CreateTaskRequest { title: String, } diff --git a/cli/golem-cli/templates/rust/snapshotting/component-dir/src/counter_with_snapshot_agent.rs b/cli/golem-cli/templates/rust/snapshotting/component-dir/src/counter_with_snapshot_agent.rs index f11b2d1fd1..16c64052b9 100644 --- a/cli/golem-cli/templates/rust/snapshotting/component-dir/src/counter_with_snapshot_agent.rs +++ b/cli/golem-cli/templates/rust/snapshotting/component-dir/src/counter_with_snapshot_agent.rs @@ -38,7 +38,9 @@ impl CounterWithSnapshotAgent for CounterImpl { let arr: [u8; 4] = bytes .try_into() .map_err(|_| "Expected a 4-byte long snapshot")?; - let name = match context.parameters { + let parameters = golem_rust::schema::wit::decode_value(context.parameters) + .map_err(|error| error.to_string())?; + let name = match parameters { SchemaValue::Record { fields } => match fields.as_slice() { [SchemaValue::String(name)] => name.clone(), _ => return Err("Expected a string agent name".to_string()), diff --git a/cli/golem-cli/templates/rust/streaming/component-dir/src/streaming_agent.rs b/cli/golem-cli/templates/rust/streaming/component-dir/src/streaming_agent.rs index c3481504cb..b28033ee2f 100644 --- a/cli/golem-cli/templates/rust/streaming/component-dir/src/streaming_agent.rs +++ b/cli/golem-cli/templates/rust/streaming/component-dir/src/streaming_agent.rs @@ -54,7 +54,7 @@ struct StreamingAgentImpl { fn stream(values: Vec) -> AgentStream where - T: golem_rust::IntoSchema + golem_rust::FromSchema + 'static, + T: golem_rust::IntoWire + golem_rust::FromWire + 'static, { let (mut writer, stream) = AgentStream::new(); spawn_local(async move { diff --git a/cli/golem-cli/templates/ts/common-on-demand/rollup.config.component.mjs b/cli/golem-cli/templates/ts/common-on-demand/rollup.config.component.mjs index cfb2c3b4ea..db71153b5a 100644 --- a/cli/golem-cli/templates/ts/common-on-demand/rollup.config.component.mjs +++ b/cli/golem-cli/templates/ts/common-on-demand/rollup.config.component.mjs @@ -6,6 +6,7 @@ import ts from "typescript"; import fs from "node:fs"; import path from "node:path"; import process from "node:process"; +import { componentPlugin } from "@golemcloud/golem-ts-sdk/component"; // Rollup config for a TypeScript agent component. // @@ -16,11 +17,9 @@ import process from "node:process"; // and the type checker always agree on the same file set and resolution rules. // // The SDK derives agent metadata at runtime from the schemas, so the -// virtual entry only imports the user's main module for its side-effecting -// `defineAgent(...).implement(...)` registrations. The SDK package, its supported -// subpaths, `golem:*`, and `wasm-rquickjs:*` host packages are externalized -// (provided by the selected prebuilt wrapper); user code and the schema library -// are bundled into main.js and injected into that wasm. +// virtual entry runs the user's registrations and supplies capability-specific +// SDK exports. The SDK is bundled so unreachable runtimes can be eliminated; +// host packages remain external and are supplied by the single full-world wrapper. // Read tsconfig.json through the TypeScript compiler API — the same path // @rollup/plugin-typescript takes — so comments and `extends` are honored, and a @@ -201,28 +200,13 @@ function componentRollupConfig() { ? parsedTsConfig.fileNames : ["./src/**/*.ts"]; - const externalSdkModules = new Set([sdkPackage, `${sdkPackage}/middleware`]); const externalPackages = (id) => - externalSdkModules.has(id) || + id.startsWith("node:") || + id.startsWith("wasi:") || id.startsWith("golem:") || id.startsWith("wasm-rquickjs:"); const virtualAgentMainId = "virtual:agent-main"; - const resolvedVirtualAgentMainId = "\0virtual:agent-main"; - const virtualAgentMainPlugin = () => ({ - name: "agent-main", - resolveId(id) { - if (id === virtualAgentMainId) { - return resolvedVirtualAgentMainId; - } - }, - load(id) { - if (id === resolvedVirtualAgentMainId) { - // Async wrapper keeps rollup from reordering the side-effecting import. - return `export default (async () => { return await import("./src/main"); })();`; - } - }, - }); const plugins = [ { @@ -231,7 +215,7 @@ function componentRollupConfig() { validateSdkImports(parsedTsConfig); }, }, - virtualAgentMainPlugin(), + componentPlugin(parsedTsConfig, path.join(componentDir, "src/main.ts")), nodeResolve({ extensions: [".mjs", ".js", ".node", ".ts"] }), commonjs(), json(), diff --git a/cli/golem-cli/test-data/goldenfiles/extracted-agent-types/code_first_snippets_rust.json b/cli/golem-cli/test-data/goldenfiles/extracted-agent-types/code_first_snippets_rust.json index 58c5eabbff..6dc50decc3 100644 --- a/cli/golem-cli/test-data/goldenfiles/extracted-agent-types/code_first_snippets_rust.json +++ b/cli/golem-cli/test-data/goldenfiles/extracted-agent-types/code_first_snippets_rust.json @@ -1924,6 +1924,98 @@ "id": "rust_code_first_rust_main.model.NestedStruct", "name": "NestedStruct" }, + { + "body": { + "kind": "record", + "value": { + "fields": [ + { + "body": { + "kind": "ref", + "value": { + "id": "rust_code_first_rust_main.model.AllPrimitives" + } + }, + "name": "primitives" + }, + { + "body": { + "kind": "ref", + "value": { + "id": "rust_code_first_rust_main.model.OptionResultBound" + } + }, + "name": "options_results_bounds" + }, + { + "body": { + "kind": "ref", + "value": { + "id": "rust_code_first_rust_main.model.Tuples" + } + }, + "name": "tuples" + }, + { + "body": { + "kind": "ref", + "value": { + "id": "rust_code_first_rust_main.model.Collections" + } + }, + "name": "collections" + }, + { + "body": { + "kind": "ref", + "value": { + "id": "rust_code_first_rust_main.model.SimpleStruct" + } + }, + "name": "simple_struct" + }, + { + "body": { + "kind": "ref", + "value": { + "id": "rust_code_first_rust_main.model.NestedStruct" + } + }, + "name": "nested_struct" + }, + { + "body": { + "kind": "ref", + "value": { + "id": "rust_code_first_rust_main.model.SimpleEnum" + } + }, + "name": "enum_simple" + }, + { + "body": { + "kind": "ref", + "value": { + "id": "rust_code_first_rust_main.model.EnumWithCollections" + } + }, + "name": "enum_collections" + }, + { + "body": { + "kind": "ref", + "value": { + "id": "rust_code_first_rust_main.model.ComplexEnum" + } + }, + "name": "enum_complex" + } + ] + } + }, + "id": "rust_code_first_rust_main.model.ComplexStruct", + "name": "ComplexStruct" + }, { "body": { "kind": "record", @@ -2458,98 +2550,6 @@ "id": "rust_code_first_rust_main.model.ComplexEnum", "name": "ComplexEnum" }, - { - "body": { - "kind": "record", - "value": { - "fields": [ - { - "body": { - "kind": "ref", - "value": { - "id": "rust_code_first_rust_main.model.AllPrimitives" - } - }, - "name": "primitives" - }, - { - "body": { - "kind": "ref", - "value": { - "id": "rust_code_first_rust_main.model.OptionResultBound" - } - }, - "name": "options_results_bounds" - }, - { - "body": { - "kind": "ref", - "value": { - "id": "rust_code_first_rust_main.model.Tuples" - } - }, - "name": "tuples" - }, - { - "body": { - "kind": "ref", - "value": { - "id": "rust_code_first_rust_main.model.Collections" - } - }, - "name": "collections" - }, - { - "body": { - "kind": "ref", - "value": { - "id": "rust_code_first_rust_main.model.SimpleStruct" - } - }, - "name": "simple_struct" - }, - { - "body": { - "kind": "ref", - "value": { - "id": "rust_code_first_rust_main.model.NestedStruct" - } - }, - "name": "nested_struct" - }, - { - "body": { - "kind": "ref", - "value": { - "id": "rust_code_first_rust_main.model.SimpleEnum" - } - }, - "name": "enum_simple" - }, - { - "body": { - "kind": "ref", - "value": { - "id": "rust_code_first_rust_main.model.EnumWithCollections" - } - }, - "name": "enum_collections" - }, - { - "body": { - "kind": "ref", - "value": { - "id": "rust_code_first_rust_main.model.ComplexEnum" - } - }, - "name": "enum_complex" - } - ] - } - }, - "id": "rust_code_first_rust_main.model.ComplexStruct", - "name": "ComplexStruct" - }, { "body": { "kind": "enum", @@ -4572,6 +4572,98 @@ "id": "rust_code_first_rust_main.model.NestedStruct", "name": "NestedStruct" }, + { + "body": { + "kind": "record", + "value": { + "fields": [ + { + "body": { + "kind": "ref", + "value": { + "id": "rust_code_first_rust_main.model.AllPrimitives" + } + }, + "name": "primitives" + }, + { + "body": { + "kind": "ref", + "value": { + "id": "rust_code_first_rust_main.model.OptionResultBound" + } + }, + "name": "options_results_bounds" + }, + { + "body": { + "kind": "ref", + "value": { + "id": "rust_code_first_rust_main.model.Tuples" + } + }, + "name": "tuples" + }, + { + "body": { + "kind": "ref", + "value": { + "id": "rust_code_first_rust_main.model.Collections" + } + }, + "name": "collections" + }, + { + "body": { + "kind": "ref", + "value": { + "id": "rust_code_first_rust_main.model.SimpleStruct" + } + }, + "name": "simple_struct" + }, + { + "body": { + "kind": "ref", + "value": { + "id": "rust_code_first_rust_main.model.NestedStruct" + } + }, + "name": "nested_struct" + }, + { + "body": { + "kind": "ref", + "value": { + "id": "rust_code_first_rust_main.model.SimpleEnum" + } + }, + "name": "enum_simple" + }, + { + "body": { + "kind": "ref", + "value": { + "id": "rust_code_first_rust_main.model.EnumWithCollections" + } + }, + "name": "enum_collections" + }, + { + "body": { + "kind": "ref", + "value": { + "id": "rust_code_first_rust_main.model.ComplexEnum" + } + }, + "name": "enum_complex" + } + ] + } + }, + "id": "rust_code_first_rust_main.model.ComplexStruct", + "name": "ComplexStruct" + }, { "body": { "kind": "record", @@ -5106,98 +5198,6 @@ "id": "rust_code_first_rust_main.model.ComplexEnum", "name": "ComplexEnum" }, - { - "body": { - "kind": "record", - "value": { - "fields": [ - { - "body": { - "kind": "ref", - "value": { - "id": "rust_code_first_rust_main.model.AllPrimitives" - } - }, - "name": "primitives" - }, - { - "body": { - "kind": "ref", - "value": { - "id": "rust_code_first_rust_main.model.OptionResultBound" - } - }, - "name": "options_results_bounds" - }, - { - "body": { - "kind": "ref", - "value": { - "id": "rust_code_first_rust_main.model.Tuples" - } - }, - "name": "tuples" - }, - { - "body": { - "kind": "ref", - "value": { - "id": "rust_code_first_rust_main.model.Collections" - } - }, - "name": "collections" - }, - { - "body": { - "kind": "ref", - "value": { - "id": "rust_code_first_rust_main.model.SimpleStruct" - } - }, - "name": "simple_struct" - }, - { - "body": { - "kind": "ref", - "value": { - "id": "rust_code_first_rust_main.model.NestedStruct" - } - }, - "name": "nested_struct" - }, - { - "body": { - "kind": "ref", - "value": { - "id": "rust_code_first_rust_main.model.SimpleEnum" - } - }, - "name": "enum_simple" - }, - { - "body": { - "kind": "ref", - "value": { - "id": "rust_code_first_rust_main.model.EnumWithCollections" - } - }, - "name": "enum_collections" - }, - { - "body": { - "kind": "ref", - "value": { - "id": "rust_code_first_rust_main.model.ComplexEnum" - } - }, - "name": "enum_complex" - } - ] - } - }, - "id": "rust_code_first_rust_main.model.ComplexStruct", - "name": "ComplexStruct" - }, { "body": { "kind": "enum", diff --git a/cli/golem-cli/test-data/goldenfiles/extracted-agent-types/code_first_snippets_ts.json b/cli/golem-cli/test-data/goldenfiles/extracted-agent-types/code_first_snippets_ts.json index 538be5e973..e8c6614cbf 100644 --- a/cli/golem-cli/test-data/goldenfiles/extracted-agent-types/code_first_snippets_ts.json +++ b/cli/golem-cli/test-data/goldenfiles/extracted-agent-types/code_first_snippets_ts.json @@ -3689,9 +3689,31 @@ { "name": "tree", "schema": { - "kind": "ref", + "kind": "record", "value": { - "id": "rec:122" + "fields": [ + { + "body": { + "kind": "string", + "value": {} + }, + "name": "label" + }, + { + "body": { + "kind": "list", + "value": { + "element": { + "kind": "ref", + "value": { + "id": "rec:235" + } + } + } + }, + "name": "children" + } + ] } }, "source": { @@ -3704,9 +3726,31 @@ "output_schema": { "tag": "single", "value": { - "kind": "ref", + "kind": "record", "value": { - "id": "rec:122" + "fields": [ + { + "body": { + "kind": "string", + "value": {} + }, + "name": "label" + }, + { + "body": { + "kind": "list", + "value": { + "element": { + "kind": "ref", + "value": { + "id": "rec:235" + } + } + } + }, + "name": "children" + } + ] } } } @@ -6604,7 +6648,7 @@ "element": { "kind": "ref", "value": { - "id": "rec:122" + "id": "rec:235" } } } @@ -6614,7 +6658,7 @@ ] } }, - "id": "rec:122" + "id": "rec:235" } ], "root": { @@ -10369,9 +10413,31 @@ { "name": "tree", "schema": { - "kind": "ref", + "kind": "record", "value": { - "id": "rec:122" + "fields": [ + { + "body": { + "kind": "string", + "value": {} + }, + "name": "label" + }, + { + "body": { + "kind": "list", + "value": { + "element": { + "kind": "ref", + "value": { + "id": "rec:235" + } + } + } + }, + "name": "children" + } + ] } }, "source": { @@ -10384,9 +10450,31 @@ "output_schema": { "tag": "single", "value": { - "kind": "ref", + "kind": "record", "value": { - "id": "rec:122" + "fields": [ + { + "body": { + "kind": "string", + "value": {} + }, + "name": "label" + }, + { + "body": { + "kind": "list", + "value": { + "element": { + "kind": "ref", + "value": { + "id": "rec:235" + } + } + } + }, + "name": "children" + } + ] } } } @@ -13160,7 +13248,7 @@ "element": { "kind": "ref", "value": { - "id": "rec:122" + "id": "rec:235" } } } @@ -13170,7 +13258,7 @@ ] } }, - "id": "rec:122" + "id": "rec:235" } ], "root": { diff --git a/cli/golem-cli/test-data/rust-code-first-snippets/model.rs b/cli/golem-cli/test-data/rust-code-first-snippets/model.rs index 2e1c397ddc..5da9c84442 100644 --- a/cli/golem-cli/test-data/rust-code-first-snippets/model.rs +++ b/cli/golem-cli/test-data/rust-code-first-snippets/model.rs @@ -2,9 +2,11 @@ use std::collections::Bound; use std::collections::HashMap; -use golem_rust::{AllowedLanguages, AllowedMimeTypes, MultimodalSchema, Schema}; +use golem_rust::{ + AllowedLanguages, AllowedMimeTypes, FromWire, IntoWire, MultimodalSchema, Schema, WireSchema, +}; -#[derive(Schema)] +#[derive(Schema, IntoWire, FromWire, WireSchema)] pub struct AllPrimitives { pub u8v: u8, pub u16v: u16, @@ -21,7 +23,7 @@ pub struct AllPrimitives { pub stringv: String, } -#[derive(Schema)] +#[derive(Schema, IntoWire, FromWire, WireSchema)] pub struct OptionResultBound { pub option_u8: Option, pub option_str: Option, @@ -33,14 +35,14 @@ pub struct OptionResultBound { pub bound_str: Bound, } -#[derive(Schema)] +#[derive(Schema, IntoWire, FromWire, WireSchema)] pub struct Tuples { pub pair: (String, f64), pub triple: (String, f64, bool), pub mixed: (i8, u16, f32), } -#[derive(Schema)] +#[derive(Schema, IntoWire, FromWire, WireSchema)] pub struct Collections { pub list_u8: Vec, pub list_str: Vec, @@ -48,7 +50,7 @@ pub struct Collections { pub map_text: HashMap, } -#[derive(Schema)] +#[derive(Schema, IntoWire, FromWire, WireSchema)] pub struct SimpleStruct { pub name: String, pub value: f64, @@ -56,7 +58,7 @@ pub struct SimpleStruct { pub symbol: char, } -#[derive(Schema)] +#[derive(Schema, IntoWire, FromWire, WireSchema)] pub struct NestedStruct { pub id: String, pub simple: SimpleStruct, @@ -66,7 +68,7 @@ pub struct NestedStruct { pub result: Result, } -#[derive(Schema)] +#[derive(Schema, IntoWire, FromWire, WireSchema)] pub enum SimpleEnum { U8(u8), I64(i64), @@ -79,14 +81,14 @@ pub enum SimpleEnum { Unit, } -#[derive(Schema)] +#[derive(Schema, IntoWire, FromWire, WireSchema)] pub enum EnumWithOnlyLiterals { A, B, C, } -#[derive(Schema)] +#[derive(Schema, IntoWire, FromWire, WireSchema)] pub enum EnumWithCollections { Vec(Vec), Map(HashMap), @@ -94,7 +96,7 @@ pub enum EnumWithCollections { Bound(Bound), } -#[derive(Schema)] +#[derive(Schema, IntoWire, FromWire, WireSchema)] pub enum ComplexEnum { Primitive(SimpleEnum), Struct(NestedStruct), @@ -108,7 +110,7 @@ pub enum ComplexEnum { UnitB, } -#[derive(Schema)] +#[derive(Schema, IntoWire, FromWire, WireSchema)] pub struct ComplexStruct { pub primitives: AllPrimitives, pub options_results_bounds: OptionResultBound, @@ -121,14 +123,14 @@ pub struct ComplexStruct { pub enum_complex: ComplexEnum, } -#[derive(MultimodalSchema)] +#[derive(MultimodalSchema, IntoWire, FromWire, WireSchema)] pub enum TextImageData { Text(String), Image(Vec), Data(Data), } -#[derive(Schema)] +#[derive(Schema, IntoWire, FromWire, WireSchema)] pub struct Data { pub id: u32, pub name: String, diff --git a/cli/golem-cli/test-data/ts-tool-middleware-roles/middleware/src/main.ts b/cli/golem-cli/test-data/ts-tool-middleware-roles/middleware/src/main.ts index ad8d2c1b88..fa53b3facb 100644 --- a/cli/golem-cli/test-data/ts-tool-middleware-roles/middleware/src/main.ts +++ b/cli/golem-cli/test-data/ts-tool-middleware-roles/middleware/src/main.ts @@ -1,7 +1,6 @@ -import { toolMiddlewareGuest } from "@golemcloud/golem-ts-sdk"; -import { universalToolMiddleware } from "@golemcloud/golem-ts-sdk/middleware"; +import * as middlewareSdk from '@golemcloud/golem-ts-sdk'; -export const middleware = universalToolMiddleware({ +export const middleware = middlewareSdk.universalToolMiddleware({ name: "middleware-only", invoke: (request, { underlying }) => underlying.invokeAndAwait( @@ -16,7 +15,7 @@ interface EmbeddedMiddlewareGuest { getToolMiddleware(name: string): { name: string; scope: { tag: string } }; } -const embeddedGuest = toolMiddlewareGuest as EmbeddedMiddlewareGuest; +const embeddedGuest = middlewareSdk.toolMiddlewareGuest as EmbeddedMiddlewareGuest; if (!embeddedGuest) { throw new Error( diff --git a/cli/golem-cli/tests/app/mod.rs b/cli/golem-cli/tests/app/mod.rs index ce3d986195..62140d234d 100644 --- a/cli/golem-cli/tests/app/mod.rs +++ b/cli/golem-cli/tests/app/mod.rs @@ -30,7 +30,7 @@ mod moonbit_http_router; mod moonbit_tool_middleware; mod plugins; mod remote_releases; -mod rust_http_router; +mod rust_minimal_exports; mod rust_streams; mod scala_guest_streams; mod scala_http_router; @@ -62,6 +62,7 @@ tag_suite!(moonbit_guest_streams, agents_guest_bridge); tag_suite!(moonbit_http_router, deploy); tag_suite!(moonbit_tool_middleware, deploy); tag_suite!(plugins, deploy); +tag_suite!(rust_minimal_exports, deploy); tag_suite!(rust_streams, agents_guest_bridge); tag_suite!(scala_guest_streams, agents_guest_bridge); tag_suite!(scala_http_router, agents_guest_bridge); diff --git a/cli/golem-cli/tests/app/moonbit_guest_streams.rs b/cli/golem-cli/tests/app/moonbit_guest_streams.rs index 189abc721f..f402e1d34f 100644 --- a/cli/golem-cli/tests/app/moonbit_guest_streams.rs +++ b/cli/golem-cli/tests/app/moonbit_guest_streams.rs @@ -53,13 +53,13 @@ async fn moonbit_guest_streams_context() -> TestContext { ) .unwrap(); fs::write_str(ctx.cwd_path_join("provider/src/counter_agent.rs"), indoc! {r#" - use golem_rust::{agent_definition, agent_implementation, IntoSchema, FromSchema}; + use golem_rust::{agent_definition, agent_implementation, IntoSchema, FromSchema, IntoWire, FromWire, WireSchema}; use golem_rust::agentic::{AgentStream, spawn_local}; - #[derive(IntoSchema, FromSchema)] + #[derive(IntoSchema, FromSchema, IntoWire, FromWire, WireSchema)] pub struct StreamItem { pub label: String, pub children: Vec } - #[derive(IntoSchema, FromSchema)] + #[derive(IntoSchema, FromSchema, IntoWire, FromWire, WireSchema)] pub struct StreamBundle { pub optional: Option>, pub siblings: Vec>, diff --git a/cli/golem-cli/tests/app/moonbit_tool_middleware.rs b/cli/golem-cli/tests/app/moonbit_tool_middleware.rs index 54feec4fb8..2e7f4bbd46 100644 --- a/cli/golem-cli/tests/app/moonbit_tool_middleware.rs +++ b/cli/golem-cli/tests/app/moonbit_tool_middleware.rs @@ -114,17 +114,19 @@ fn modified_at(path: &PathBuf) -> std::time::SystemTime { } fn assert_component_contracts(ctx: &TestContext, profile: &str) { - let expected_imports = [ + let required_imports = [ "interface:golem:agent/common@2.0.0", - "interface:golem:agent/host@2.0.0", "interface:golem:api/host@1.5.0", "interface:golem:core/types@2.0.0", "interface:golem:tool/common@0.1.0", "interface:golem:tool/host@0.1.0", "interface:golem:tool/streams@0.1.0", "interface:golem:tool/underlying@0.1.0", - "interface:wasi:cli/environment@0.3.0", "interface:wasi:clocks/types@0.3.0", + ]; + let optional_imports = [ + "interface:golem:agent/host@2.0.0", + "interface:wasi:cli/environment@0.3.0", "interface:wasi:logging/logging", ]; let expected_exports = [ @@ -139,7 +141,12 @@ fn assert_component_contracts(ctx: &TestContext, profile: &str) { middleware_component(ctx, profile), combined_component(ctx, profile), ] { - assert_component_contract(&component, &expected_imports, &expected_exports); + assert_component_contract( + &component, + &required_imports, + &optional_imports, + &expected_exports, + ); } } @@ -154,7 +161,8 @@ fn assert_default_package(path: &Path) { fn assert_component_contract( component: &Path, - expected_imports: &[&str], + required_imports: &[&str], + optional_imports: &[&str], expected_exports: &[&str], ) { let bytes = std::fs::read(component).unwrap(); @@ -176,16 +184,23 @@ fn assert_component_contract( .map(|(key, item)| normalized_world_item(&resolve, key, item)) .collect::>(); imports.sort(); - let mut expected_imports = expected_imports + let mut allowed_imports = required_imports .iter() + .chain(optional_imports) .map(|item| (*item).to_string()) .collect::>(); - expected_imports.sort(); - assert_eq!( - imports, - expected_imports, - "unexpected direct imports in {}", - component.display() + allowed_imports.sort(); + assert!( + imports.iter().all(|item| allowed_imports.contains(item)), + "unexpected direct imports in {}: {imports:?}", + component.display(), + ); + assert!( + required_imports + .iter() + .all(|item| imports.contains(&(*item).to_string())), + "missing required direct imports in {}: {imports:?}", + component.display(), ); let mut exports = world diff --git a/sdks/rust/golem-rust/tests/tool_middleware_component.rs b/cli/golem-cli/tests/app/rust_minimal_exports.rs similarity index 65% rename from sdks/rust/golem-rust/tests/tool_middleware_component.rs rename to cli/golem-cli/tests/app/rust_minimal_exports.rs index 89759743b9..3072cf5f2f 100644 --- a/sdks/rust/golem-rust/tests/tool_middleware_component.rs +++ b/cli/golem-cli/tests/app/rust_minimal_exports.rs @@ -12,8 +12,6 @@ // See the License for the specific language governing permissions and // limitations under the License. -test_r::enable!(); - use std::collections::BTreeSet; use std::fs; use std::path::{Path, PathBuf}; @@ -24,6 +22,8 @@ const AGENT_GUEST: &str = "golem:agent/guest@2.0.0"; const TOOL_GUEST: &str = "golem:tool/guest@0.1.0"; const TOOL_HOST: &str = "golem:tool/host@0.1.0"; const TOOL_MIDDLEWARE_GUEST: &str = "golem:tool/tool-middleware-guest@0.1.0"; +const LOAD_SNAPSHOT: &str = "golem:api/load-snapshot@1.5.0"; +const SAVE_SNAPSHOT: &str = "golem:api/save-snapshot@1.5.0"; const CONSTRUCTOR_DIAGNOSTIC: &str = "tool middleware `constructor` must be synchronous, infallible, zero-argument, and return the middleware implementation type (`fn() -> Self`)"; const CONSTRUCTOR_DIAGNOSTIC_SYMBOL: &str = "constructor_must_be_synchronous_infallible_zero_argument_and_return_self"; @@ -44,7 +44,7 @@ impl Drop for FixtureLockfile { } #[test] -fn tool_middleware_cross_crate_components_and_compile_failures() { +async fn tool_middleware_cross_crate_components_and_compile_failures() { let fixture = fixture_root(); let _lockfile = FixtureLockfile(fixture.join("Cargo.lock")); let target = target_dir(); @@ -52,18 +52,124 @@ fn tool_middleware_cross_crate_components_and_compile_failures() { assert_feature_fixture_contract(&fixture, &target); check_fixture(&fixture, &target, "all-sdk-features-native"); - let pure = build_component(&fixture, &target, "pure-middleware-component"); - assert_component_contract(&component_wit(&pure), true, true, true, true); + let pure = build_component(&fixture, &target, "pure-middleware-component", ""); + assert_component_contract(&component_wit(&pure), false); - let ordinary = build_component(&fixture, &target, "ordinary-agentic-component"); - assert_component_contract(&component_wit(&ordinary), true, true, true, true); + let ordinary = build_component(&fixture, &target, "ordinary-agentic-component", ""); + assert_component_contract(&component_wit(&ordinary), true); - let combined = build_component(&fixture, &target, "combined-agentic-middleware-component"); - assert_component_contract(&component_wit(&combined), true, true, true, true); + let combined = build_component( + &fixture, + &target, + "combined-agentic-middleware-component", + "", + ); + assert_component_contract(&component_wit(&combined), true); - let all_wasi_features = - build_component(&fixture, &target, "all-wasi-compatible-features-component"); - assert_component_contract(&component_wit(&all_wasi_features), true, true, true, true); + let all_wasi_features = build_component( + &fixture, + &target, + "all-wasi-compatible-features-component", + "", + ); + assert_component_contract(&component_wit(&all_wasi_features), true); + + let matrix = target.join("minimal-exports"); + fs::create_dir_all(&matrix).unwrap(); + for (name, features) in [ + ("empty", ""), + ("tool", "tool"), + ("agent", "agent"), + ("middleware", "middleware"), + ("mixed", "agent,tool,middleware"), + ("empty-rich", "golem-rust/rich-validation"), + ("tool-rich", "tool,golem-rust/rich-validation"), + ("agent-rich", "agent,golem-rust/rich-validation"), + ("middleware-rich", "middleware,golem-rust/rich-validation"), + ( + "mixed-rich", + "agent,tool,middleware,golem-rust/rich-validation", + ), + ] { + let output = cargo( + &fixture, + &target, + [ + "test", + "-p", + "minimal-exports-component", + "--lib", + "--features", + features, + "--", + "--report-time", + ], + ); + assert_success(&output, &format!("checking {name} export behavior")); + let component = build_component(&fixture, &target, "minimal-exports-component", features); + assert_component_contract(&component_wit(&component), false); + let metadata = golem_common::model::agent::extraction::extract_component_metadata( + &component, true, false, + ) + .await + .unwrap(); + let enabled = + |capability| usize::from(features.split(',').any(|feature| feature == capability)); + assert_eq!(metadata.agent_types.len(), enabled("agent"), "{name}"); + assert_eq!(metadata.tools.len(), enabled("tool"), "{name}"); + assert_eq!( + metadata.tool_middlewares.len(), + enabled("middleware"), + "{name}" + ); + if let Some(agent) = metadata.agent_types.first() { + assert_eq!(agent.type_name.0, "MinimalAgent"); + } + if let Some(tool) = metadata.tools.first() { + assert_eq!(tool.commands.nodes[0].name, "public-echo"); + } + if let Some(middleware) = metadata.tool_middlewares.first() { + assert_eq!(middleware.name, "minimal-policy"); + } + let bytes = fs::read(&component).unwrap(); + // These symbols are in the unstripped name section, including functions + // retained only through ctor-installed tables and generated client code. + let symbols = String::from_utf8_lossy(&bytes); + for symbol in ["ambient_tool_rpc", "tool_client"] { + assert!(!symbols.contains(symbol), "{name} retained unused {symbol}"); + } + for (capabilities, symbol) in [ + (&["agent"][..], "agent_impl"), + (&["agent"][..], "agent_registry"), + (&["agent"][..], "principal_serde"), + (&["agent", "tool"][..], "tool_registry"), + (&["middleware"][..], "tool_middleware_impl"), + (&["middleware"][..], "tool_middleware_registry"), + ] { + if !features + .split(',') + .any(|feature| capabilities.contains(&feature)) + { + assert!(!symbols.contains(symbol), "{name} retained {symbol}"); + } + } + fs::copy(&component, matrix.join(format!("{name}.wasm"))).unwrap(); + let stripped = matrix.join(format!("{name}.stripped.wasm")); + let output = Command::new("wasm-tools") + .arg("strip") + .arg("--all") + .arg(&component) + .arg("-o") + .arg(&stripped) + .output() + .unwrap(); + assert_success(&output, "stripping the component"); + eprintln!( + "{name}: release={} stripped={}", + bytes.len(), + fs::metadata(stripped).unwrap().len() + ); + } for (binary, fragments) in [ ( @@ -93,18 +199,14 @@ fn tool_middleware_cross_crate_components_and_compile_failures() { } fn fixture_root() -> PathBuf { - Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/tool-middleware") + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../sdks/rust/golem-rust/tests/fixtures/tool-middleware") } fn target_dir() -> PathBuf { std::env::var_os("CARGO_TARGET_DIR") .map(PathBuf::from) - .unwrap_or_else(|| { - Path::new(env!("CARGO_MANIFEST_DIR")) - .parent() - .expect("golem-rust crate has an SDK workspace parent") - .join("target") - }) + .unwrap_or_else(|| fixture_root().join("target")) } fn assert_feature_fixture_contract(fixture: &Path, target: &Path) { @@ -187,14 +289,17 @@ fn check_fixture(fixture: &Path, target: &Path, package: &str) { assert_success(&output, &format!("checking fixture package `{package}`")); } -fn build_component(fixture: &Path, target: &Path, package: &str) -> PathBuf { +fn build_component(fixture: &Path, target: &Path, package: &str, features: &str) -> PathBuf { let output = cargo( fixture, target, [ "build", + "--release", "-p", package, + "--features", + features, "--target", "wasm32-wasip2", "--message-format=json-render-diagnostics", @@ -241,16 +346,12 @@ fn component_wit(component: &Path) -> String { String::from_utf8(output.stdout).expect("wasm-tools emits UTF-8 WIT") } -fn assert_component_contract( - wit: &str, - exports_agent: bool, - exports_tool: bool, - exports_middleware: bool, - imports_tool_host: bool, -) { +fn assert_component_contract(wit: &str, invokes_tool_host: bool) { let (imports, exports) = root_world_interfaces(wit); + assert!(exports.iter().any(|export| export == LOAD_SNAPSHOT)); + assert!(exports.iter().any(|export| export == SAVE_SNAPSHOT)); let relevant = [AGENT_GUEST, TOOL_GUEST, TOOL_MIDDLEWARE_GUEST, TOOL_HOST]; - let mut actual_imports = imports + let actual_imports = imports .into_iter() .filter(|interface| relevant.contains(&interface.as_str())) .collect::>(); @@ -258,39 +359,33 @@ fn assert_component_contract( .into_iter() .filter(|interface| relevant.contains(&interface.as_str())) .collect::>(); - let mut expected_imports = [imports_tool_host.then_some(TOOL_HOST)] + assert!( + actual_imports.is_empty() || actual_imports == [TOOL_HOST], + "unexpected relevant root-world imports in component contract:\n{wit}" + ); + if invokes_tool_host { + assert_eq!(actual_imports, [TOOL_HOST]); + } + let mut expected_exports = [AGENT_GUEST, TOOL_GUEST, TOOL_MIDDLEWARE_GUEST] .into_iter() - .flatten() .map(str::to_string) .collect::>(); - let mut expected_exports = [ - exports_agent.then_some(AGENT_GUEST), - exports_tool.then_some(TOOL_GUEST), - exports_middleware.then_some(TOOL_MIDDLEWARE_GUEST), - ] - .into_iter() - .flatten() - .map(str::to_string) - .collect::>(); - - actual_imports.sort(); + actual_exports.sort(); - expected_imports.sort(); expected_exports.sort(); - assert_eq!( - actual_imports, expected_imports, - "unexpected relevant root-world imports in component contract:\n{wit}" - ); assert_eq!( actual_exports, expected_exports, "unexpected relevant root-world exports in component contract:\n{wit}" ); - if !imports_tool_host { + if !invokes_tool_host { + // Some linker/toolchain combinations retain the host interface because + // the mandatory stream signatures share its binding vtable and type aliases. + // The RPC resource itself must still be absent. assert!( - !wit.contains(TOOL_HOST), - "component contract unexpectedly contains `{TOOL_HOST}`:\n{wit}" + !wit.contains("resource tool-rpc"), + "component contract unexpectedly retains ambient tool RPC:\n{wit}" ); } } diff --git a/cli/golem-cli/tests/app/rust_streams.rs b/cli/golem-cli/tests/app/rust_streams.rs index 3c0245fb1f..691a9546f4 100644 --- a/cli/golem-cli/tests/app/rust_streams.rs +++ b/cli/golem-cli/tests/app/rust_streams.rs @@ -48,11 +48,34 @@ async fn rust_generated_native_stream_bridge_e2e() { ) .unwrap(); fs::write_str(ctx.cwd_path_join("provider/src/counter_agent.rs"), indoc! {r#" - use golem_rust::{agent_definition, agent_implementation, IntoSchema, FromSchema}; + use golem_rust::{agent_definition, agent_implementation, IntoSchema, FromSchema, IntoWire, FromWire, WireSchema}; use golem_rust::agentic::{AgentStream, spawn_local}; use golem_rust::schema::{SchemaValue, SchemaType, SchemaBuilder, TypeId, FromSchemaError}; + use golem_rust::schema::wit::{wire, direct::{WireReader, WireWriter, WireError, WireSchemaBuilder}}; pub struct FixedPair(Vec); + impl WireSchema for FixedPair { + fn append_schema(builder: &mut WireSchemaBuilder) -> i32 { + let element = u32::append_schema(builder); + builder.push(wire::SchemaTypeBody::FixedListType(wire::FixedListSpec { element, length: 2 })) + } + } + impl IntoWire for FixedPair { + fn write_wire(&self, writer: &mut WireWriter) -> Result { + if self.0.len() != 2 { return Err(WireError::Shape("fixed pair")); } + let elements = self.0.iter().map(|value| value.write_wire(writer)).collect::, _>>()?; + Ok(writer.push(wire::SchemaValueNode::FixedListValue(elements))) + } + } + impl FromWire for FixedPair { + fn read_wire(reader: &mut WireReader, index: i32) -> Result { + let wire::SchemaValueNode::FixedListValue(elements) = reader.take(index)? else { + return Err(WireError::Shape("fixed pair")); + }; + if elements.len() != 2 { return Err(WireError::Shape("fixed pair")); } + Ok(Self(elements.into_iter().map(|index| u32::read_wire(reader, index)).collect::, _>>()?)) + } + } impl IntoSchema for FixedPair { fn type_id() -> TypeId { TypeId::new("FixedPair") } fn register_in(_: &mut SchemaBuilder) -> SchemaType { @@ -70,7 +93,7 @@ async fn rust_generated_native_stream_bridge_e2e() { } } } - #[derive(IntoSchema, FromSchema)] + #[derive(IntoSchema, FromSchema, IntoWire, FromWire, WireSchema)] pub struct FallibleItem { pub first: AgentStream, pub tail: FixedPair, @@ -224,7 +247,7 @@ async fn rust_generated_native_stream_bridge_e2e() { spawn_local(async move { writer.write_one(vec![1, 2]).await.unwrap(); }); assert_eq!(provider.fixed_echo(input).await.unwrap().collect().await.unwrap(), vec![vec![1, 2]]); - // The generated item encoder acquires first before rejecting tail. + // Reject an invalid tail before preparing the nested stream endpoint. let (mut nested_writer, nested) = new_u32_stream(); let (mut writer, output) = new_fallible_item_stream(); let error = writer.write_one(FallibleItem { first: nested, tail: vec![1] }).await.unwrap_err(); diff --git a/cli/golem-cli/tests/app/scala_guest_streams.rs b/cli/golem-cli/tests/app/scala_guest_streams.rs index bba48b14b2..7ccb8e6e00 100644 --- a/cli/golem-cli/tests/app/scala_guest_streams.rs +++ b/cli/golem-cli/tests/app/scala_guest_streams.rs @@ -49,12 +49,12 @@ async fn deployed_scala_streams_context() -> TestContext { ) .unwrap(); fs::write_str(ctx.cwd_path_join("provider/src/lib.rs"), indoc! {r#" - use golem_rust::{agent_definition, agent_implementation, IntoSchema, FromSchema}; + use golem_rust::{agent_definition, agent_implementation, IntoSchema, FromSchema, IntoWire, FromWire, WireSchema}; use golem_rust::agentic::{AgentStream, spawn_local}; - #[derive(IntoSchema, FromSchema)] + #[derive(IntoSchema, FromSchema, IntoWire, FromWire, WireSchema)] pub struct Item { pub label: String, pub children: Vec } - #[derive(IntoSchema, FromSchema)] + #[derive(IntoSchema, FromSchema, IntoWire, FromWire, WireSchema)] pub struct Bundle { pub optional: Option>, pub siblings: Vec> } #[agent_definition] diff --git a/cli/golem-cli/tests/bridge_gen/moonbit.rs b/cli/golem-cli/tests/bridge_gen/moonbit.rs index a5138cfe85..2ba525ac79 100644 --- a/cli/golem-cli/tests/bridge_gen/moonbit.rs +++ b/cli/golem-cli/tests/bridge_gen/moonbit.rs @@ -312,7 +312,7 @@ test "native custom streams are lazy recursive and directly forwardable" { } ///| -test "generated batch release traverses failing siblings and unconverted items" { +test "generated batch release traverses failing fields and unconverted items" { let drops = Ref(0) fn endpoint() -> @schema.AgentStream[Int] { @schema.AgentStream::produce(async fn(_) { fail("must not pull") }, @@ -321,7 +321,6 @@ test "generated batch release traverses failing siblings and unconverted items" run_stream_test(async fn() { let (writer, stream) = new_failure_input_0_stream() let items : Array[FailureItem] = [ - { first: endpoint(), narrow: 1, last: [endpoint()] }, { first: endpoint(), narrow: 128, last: [endpoint(), endpoint()] }, { first: endpoint(), narrow: 2, last: [endpoint()] }, ] @@ -329,7 +328,7 @@ test "generated batch release traverses failing siblings and unconverted items" CodecError(message) => assert_eq(message, "s8 value out of range") error => fail(repr(error)) } noraise { _ => fail("expected encoding failure") } - assert_eq(drops.val, 7) + assert_eq(drops.val, 5) writer.close() stream.drop() }) @@ -338,7 +337,7 @@ test "generated batch release traverses failing siblings and unconverted items" CodecError(_) => () error => fail(repr(error)) } noraise { _ => fail("expected decoding failure") } - assert_eq(drops.val, 9) + assert_eq(drops.val, 7) } "#).unwrap(); let output = std::process::Command::new( @@ -793,7 +792,7 @@ fn guest_tool_mode_generates_name_aware_error_decoder() { let source = std::fs::read_to_string(target.join("client/client.mbt")).unwrap(); for expected in [ - "(name : String, value : @model.TypedSchemaValue) -> Result[NewError, String]?", + "(name : String, value : @types.TypedSchemaValue) -> Result[NewError, String]?", "\"first-text\" => {", "\"second-text\" => {", "\"empty\" => {", @@ -806,7 +805,6 @@ fn guest_tool_mode_generates_name_aware_error_decoder() { moon_check_wasm(dir.path()); } -// PROVISIONAL bug_finder reproducer — remove if the finding is rejected. #[test] fn guest_tool_mode_marks_substring_collision_parameter_used() { let dir = TempDir::new().unwrap(); diff --git a/cli/golem-cli/tests/bridge_gen/rust.rs b/cli/golem-cli/tests/bridge_gen/rust.rs index 4abfb5b16e..0ca71f83c9 100644 --- a/cli/golem-cli/tests/bridge_gen/rust.rs +++ b/cli/golem-cli/tests/bridge_gen/rust.rs @@ -91,7 +91,9 @@ fn guest_rust_streaming_matrix_compiles_native_producers_and_consumers() { let path = target.join("src/lib.rs"); let mut source = std::fs::read_to_string(&path).unwrap(); assert!(source.contains("golem_rust::agentic::AgentStream<")); - assert!(source.contains("encode_schema_value_async(&method_parameters)")); + assert!(source.contains("new_with_wire_codecs")); + assert!(!source.contains("encode_schema_value")); + assert!(!source.contains("schema::SchemaValue::")); for method in [ "consume", "produce", @@ -147,22 +149,22 @@ fn guest_rust_streaming_matrix_compiles_native_producers_and_consumers() { let item_type = quote::quote!(#item_type).to_string(); let reader = quote::quote!(#reader).to_string(); let codec_test = match name.as_str() { - "new_string_stream" => Some(("String::from(\"value\")", "String(_)")), + "new_string_stream" => Some(("String::from(\"value\")", "StringValue(_)")), "new_stream_item_stream" => Some(( "StreamItem { label: String::from(\"root\"), children: vec![StreamItem { label: String::from(\"child\"), children: vec![] }] }", - "Record { .. }", + "RecordValue(_)", )), - "new_path_stream" => Some(("String::from(\"value\")", "Path { .. }")), + "new_path_stream" => Some(("String::from(\"value\")", "PathValue(_)")), "new_list_stream" => Some(( "vec![String::from(\"a\"), String::from(\"b\")]", - "List { .. }", + "ListValue(_)", )), "new_fixed_list_stream" => Some(( "vec![String::from(\"a\"), String::from(\"b\")]", - "FixedList { .. }", + "FixedListValue(_)", )), - "new_map_stream" => Some(("vec![(String::from(\"a\"), 1u32)]", "Map { .. }")), - "new_list_stream1" => Some(("vec![(String::from(\"a\"), 1u32)]", "List { .. }")), + "new_map_stream" => Some(("vec![(String::from(\"a\"), 1u32)]", "MapValue(_)")), + "new_list_stream1" => Some(("vec![(String::from(\"a\"), 1u32)]", "ListValue(_)")), _ => None, }; if let Some((value, kind)) = codec_test { @@ -177,13 +179,23 @@ fn guest_rust_streaming_matrix_compiles_native_producers_and_consumers() { source.push_str(&format!(r#" #[test] fn codec_{name}() {{ - let encode: fn({item_type}) -> Result = {encode}; + fn complete(future: F) -> F::Output {{ + let mut future = std::pin::pin!(future); + let mut context = std::task::Context::from_waker(std::task::Waker::noop()); + match std::future::Future::poll(future.as_mut(), &mut context) {{ + std::task::Poll::Ready(value) => value, + std::task::Poll::Pending => panic!("pure codec unexpectedly suspended"), + }} + }} + let encode = |value: {item_type}| ({encode})(value); let decode = {decode}; let original = {value}; - let wire = encode(original.clone()).unwrap(); - assert!(matches!(wire, crate::__golem_bridge_runtime::schema::SchemaValue::{kind})); - assert_eq!(encode(decode(wire.clone()).unwrap()).unwrap(), wire); - assert!(decode(crate::__golem_bridge_runtime::schema::SchemaValue::Bool(false)).is_err()); + let wire = complete(encode(original.clone())).unwrap(); + assert!(matches!(wire.value_nodes[wire.root as usize], __wire::SchemaValueNode::{kind})); + let expected = format!("{{wire:?}}"); + let actual = complete(encode(decode(wire).unwrap())).unwrap(); + assert_eq!(format!("{{actual:?}}"), expected); + assert!(decode(__wire::SchemaValueTree {{ value_nodes: vec![__wire::SchemaValueNode::BoolValue(false)], root: 0 }}).is_err()); }} "#)); } @@ -763,8 +775,9 @@ fn guest_runtime_prelude_compiles_with_generated_golem_rust_dependency_flags() { "the relocated prelude test no longer exercises unrestricted binary values" ); assert!( - lib_rs.contains("::__golem_bridge_runtime::agentic::UnstructuredText as crate") - && lib_rs.contains("::to_schema_value(input)"), + lib_rs.contains("::__golem_bridge_runtime::agentic::UnstructuredText as") + && lib_rs.contains("golem_rust::schema::wit::direct::IntoWire") + && lib_rs.contains("::write_wire(__source, __writer)"), "the relocated prelude test no longer exercises unrestricted text encoding:\n{lib_rs}" ); @@ -819,7 +832,7 @@ fn guest_generation_emits_wasm_rpc_cargo_dependencies_and_api_shape() { "pub fn schedule_run(\n &self,\n value: i32,\n golem_bridge_scheduled_time: golem_rust::ScheduledTime,", "pub fn schedule_cancelable_run(\n &self,\n value: i32,\n golem_bridge_scheduled_time: golem_rust::ScheduledTime,", "async_invoke_and_await", - "await_invoke_schema_value_result", + "WireReader::new", ".invoke(", "schedule_invocation", "schedule_cancelable_invocation", @@ -1394,12 +1407,12 @@ fn guest_generation_emits_self_contained_typed_config_schema_values() { "generated typed config schema graph must include referenced definitions:\n{lib_rs}" ); assert!( - lib_rs.contains("TypedSchemaValue::new"), - "generated typed config encoding must build a typed value:\n{lib_rs}" + lib_rs.contains("__wire::TypedSchemaValue"), + "generated typed config encoding must build a wire typed value:\n{lib_rs}" ); assert!( - lib_rs.contains("golem_rust::encode_typed_schema_value"), - "generated typed config encoding must use guest golem-rust wire encoding:\n{lib_rs}" + !lib_rs.contains("golem_rust::encode_typed_schema_value"), + "generated typed config encoding must not build owned models:\n{lib_rs}" ); let output = std::process::Command::new("cargo") @@ -1567,13 +1580,13 @@ fn tool_generation_compiles() { "{lib_rs}" ); assert!( - lib_rs.contains("agentic::start_tool_invocation("), + lib_rs.contains("agentic::start_tool_invocation_direct_input("), "{lib_rs}" ); assert!(lib_rs.contains(")\n .await"), "{lib_rs}"); for shape in [ - "__name: String", - "__value: golem_rust::TypedSchemaValue", + "__name: &str", + "__value: i32", "Result, String>", "\"bad-pattern\" =>", "Some(GrepError::BadPattern(__payload))", @@ -1587,6 +1600,19 @@ fn tool_generation_compiles() { ] { assert!(lib_rs.contains(shape), "missing {shape}:\n{lib_rs}"); } + for forbidden in [ + "FromSchema", + "IntoSchema", + "schema::SchemaValue", + "golem_rust::TypedSchemaValue", + "decode_canonical_input_record", + "try_into_schema_graph", + ] { + assert!( + !lib_rs.contains(forbidden), + "retained {forbidden}:\n{lib_rs}" + ); + } assert!(!lib_rs.contains("expect_stdout"), "{lib_rs}"); cargo_check(&target_path); } diff --git a/cli/golem-cli/tests/bridge_gen/schema_graph_literals.rs b/cli/golem-cli/tests/bridge_gen/schema_graph_literals.rs index a59470a56c..b4f182d04c 100644 --- a/cli/golem-cli/tests/bridge_gen/schema_graph_literals.rs +++ b/cli/golem-cli/tests/bridge_gen/schema_graph_literals.rs @@ -73,6 +73,7 @@ fn exhaustive_rust_literal_compiles_executes_and_round_trips_through_wit() { let workspace = workspace_root().unwrap(); let graph = exhaustive_schema_graph(); let literal = rust_emitter::emit_schema_graph_literal(&graph); + let expected = format!("{graph:?}"); let (node_count, root, defs) = canonical_carrier_shape(); let def_assertions = defs.iter().map(|(id, body)| { format!("assert!(wire.defs.iter().any(|def| def.id == {id:?} && def.body == {body}));") @@ -90,7 +91,7 @@ fn exhaustive_rust_literal_compiles_executes_and_round_trips_through_wit() { std::fs::write( dir.path().join("src/main.rs"), format!( - "fn main() {{\n let graph: golem_rust::SchemaGraph = {literal};\n let wire = golem_rust::encode_schema_graph(&graph).unwrap();\n assert_eq!(wire.type_nodes.len(), {node_count});\n assert_eq!(wire.root, {root});\n assert_eq!(wire.defs.len(), {});\n {}\n assert_eq!(golem_rust::decode_schema_graph(&wire).unwrap(), graph);\n}}\n", + "fn main() {{\n let wire: golem_rust::schema::wit::wire::SchemaGraph = {literal};\n assert_eq!(wire.type_nodes.len(), {node_count});\n assert_eq!(wire.root, {root});\n assert_eq!(wire.defs.len(), {});\n {}\n assert_eq!(format!(\"{{:?}}\", golem_rust::decode_schema_graph(&wire).unwrap()), {expected:?});\n}}\n", defs.len(), def_assertions.collect::>().join("\n ") ), diff --git a/golem-common/Cargo.toml b/golem-common/Cargo.toml index 8015a6eb8f..902cb77348 100644 --- a/golem-common/Cargo.toml +++ b/golem-common/Cargo.toml @@ -77,7 +77,7 @@ full = [ [dependencies] golem-api-grpc = { workspace = true, optional = true } -golem-schema = { workspace = true } +golem-schema = { workspace = true, features = ["rich-validation"] } golem-schema-derive = { workspace = true } proptest = { workspace = true, optional = true } diff --git a/golem-registry-service/src/services/builtin_tool_provisioner.rs b/golem-registry-service/src/services/builtin_tool_provisioner.rs index 7c5fae5a4d..6d0a848807 100644 --- a/golem-registry-service/src/services/builtin_tool_provisioner.rs +++ b/golem-registry-service/src/services/builtin_tool_provisioner.rs @@ -48,9 +48,44 @@ pub struct BuiltinToolDescriptor { pub wasm_bytes: &'static [u8], } -// There is intentionally no production tool inventory yet. Future artifacts are embedded in -// descriptors here rather than loaded from registry-service filesystem paths. -static BUILTIN_TOOLS: &[BuiltinToolDescriptor] = &[]; +static BUILTIN_TOOLS: &[BuiltinToolDescriptor] = &[ + BuiltinToolDescriptor { + component_name: "filesystem-tools-rust", + tool_name: "read-file-rust", + release_version: "0.1.0-rust", + wasm_bytes: include_bytes!("../../../plugins/filesystem-tools-rust.wasm"), + }, + BuiltinToolDescriptor { + component_name: "filesystem-tools-rust", + tool_name: "write-file-rust", + release_version: "0.1.0-rust", + wasm_bytes: include_bytes!("../../../plugins/filesystem-tools-rust.wasm"), + }, + BuiltinToolDescriptor { + component_name: "filesystem-tools-rust", + tool_name: "edit-file-rust", + release_version: "0.1.0-rust", + wasm_bytes: include_bytes!("../../../plugins/filesystem-tools-rust.wasm"), + }, + BuiltinToolDescriptor { + component_name: "filesystem-tools-moonbit", + tool_name: "read-file-moonbit", + release_version: "0.1.0-moonbit", + wasm_bytes: include_bytes!("../../../plugins/filesystem-tools-moonbit.wasm"), + }, + BuiltinToolDescriptor { + component_name: "filesystem-tools-moonbit", + tool_name: "write-file-moonbit", + release_version: "0.1.0-moonbit", + wasm_bytes: include_bytes!("../../../plugins/filesystem-tools-moonbit.wasm"), + }, + BuiltinToolDescriptor { + component_name: "filesystem-tools-moonbit", + tool_name: "edit-file-moonbit", + release_version: "0.1.0-moonbit", + wasm_bytes: include_bytes!("../../../plugins/filesystem-tools-moonbit.wasm"), + }, +]; #[allow(clippy::too_many_arguments)] pub async fn provision_builtin_tools( @@ -97,7 +132,7 @@ pub async fn provision_descriptors( } let mut extracted = Vec::with_capacity(descriptors.len()); let mut coordinates = std::collections::BTreeSet::new(); - let mut component_names = std::collections::BTreeSet::new(); + let mut component_hashes = BTreeMap::new(); for descriptor in descriptors { if !coordinates.insert((descriptor.tool_name, descriptor.release_version)) { anyhow::bail!( @@ -106,9 +141,12 @@ pub async fn provision_descriptors( descriptor.release_version ); } - if !component_names.insert(descriptor.component_name) { + let wasm_hash = blake3::hash(descriptor.wasm_bytes); + if let Some(existing_hash) = component_hashes.insert(descriptor.component_name, wasm_hash) + && existing_hash != wasm_hash + { anyhow::bail!( - "duplicate built-in tool component name '{}'", + "built-in tool component '{}' has conflicting embedded artifacts", descriptor.component_name ); } @@ -153,34 +191,37 @@ pub async fn provision_descriptors( } let auth = auth_service.builtin_owner_auth(owner).await?; let app = get_or_create_application(applications, owner, &auth).await?; - let env = get_or_create_environment(environments, app.id, &auth).await?; - let mut staged = Vec::new(); + let environment = get_or_create_environment(environments, app.id, &auth).await?; + let mut component_tools = BTreeMap::<_, Vec<_>>::new(); for (descriptor, tool) in descriptors.iter().zip(extracted) { + component_tools + .entry(descriptor.component_name) + .or_default() + .push(tool); + } + let mut staged = BTreeMap::new(); + for (component_name, tools) in component_tools { + let descriptor = descriptors + .iter() + .find(|descriptor| descriptor.component_name == component_name) + .expect("component group came from descriptors"); let component = upload_component( component_writes, components, - env.id, + environment.id, descriptor, - tool, + tools, &auth, ) .await?; - staged.push((descriptor, component)); - } - let mut deployment_current = true; - for (_, staged_component) in &staged { - match components - .get_deployed_component(staged_component.id, &auth) - .await - { - Ok(deployed) if deployed.revision == staged_component.revision => {} - _ => deployment_current = false, + match components.get_deployed_component(component.id, &auth).await { + Ok(deployed) if deployed.revision == component.revision => {} + _ => deploy(deployments, deployment_writes, environment.id, &auth).await?, } + staged.insert(component_name, component); } - if !deployment_current { - deploy(deployments, deployment_writes, env.id, &auth).await?; - } - for (descriptor, staged_component) in staged { + for descriptor in descriptors { + let staged_component = &staged[descriptor.component_name]; let component = components .get_deployed_component(staged_component.id, &auth) .await?; @@ -285,25 +326,34 @@ async fn upload_component( reads: &Arc, env: EnvironmentId, descriptor: &BuiltinToolDescriptor, - tool: Tool, + tools: Vec, auth: &AuthCtx, ) -> anyhow::Result { let name = ComponentName(descriptor.component_name.into()); - let tool_name = ToolName::try_from(descriptor.tool_name).map_err(anyhow::Error::msg)?; - let tool_deployment_configs = BTreeMap::from([( - tool_name, - ToolDeploymentConfigCreation { - provision: ToolProvisionConfigCreation { - config: serde_json::json!({}).into(), - env: BTreeMap::new(), - plugin_installations: Vec::new(), - files: BTreeMap::new(), - }, - environment_binding: None, - agent_bindings: BTreeMap::new(), - component_bindings: BTreeMap::new(), - }, - )]); + let tool_deployment_configs = tools + .iter() + .map(|tool| { + let tool_name = ToolName::try_from( + tool.name() + .expect("built-in tool metadata was matched by a valid name"), + ) + .expect("built-in tool metadata name was already validated"); + ( + tool_name, + ToolDeploymentConfigCreation { + provision: ToolProvisionConfigCreation { + config: serde_json::json!({}).into(), + env: BTreeMap::new(), + plugin_installations: Vec::new(), + files: BTreeMap::new(), + }, + environment_binding: None, + agent_bindings: BTreeMap::new(), + component_bindings: BTreeMap::new(), + }, + ) + }) + .collect(); match writes .create( env, @@ -313,7 +363,7 @@ async fn upload_component( component_provision_config: Default::default(), agent_types: vec![], agent_type_provision_configs: BTreeMap::new(), - tools: vec![tool], + tools, tool_deployment_configs, tool_middlewares: vec![], tool_middleware_provision_configs: BTreeMap::new(), diff --git a/golem-registry-service/src/services/mcp_import/tests.rs b/golem-registry-service/src/services/mcp_import/tests.rs index 7a40853c65..3e809aa929 100644 --- a/golem-registry-service/src/services/mcp_import/tests.rs +++ b/golem-registry-service/src/services/mcp_import/tests.rs @@ -595,6 +595,21 @@ async fn deployment_without_oauth_consent_succeeds_with_discovery_warning() { )) .await .unwrap(); + db.execute(sqlx::query("DELETE FROM deployment_tool_bindings")) + .await + .unwrap(); + db.execute(sqlx::query("DELETE FROM deployment_registered_tools")) + .await + .unwrap(); + db.execute(sqlx::query("DELETE FROM deployment_component_revisions")) + .await + .unwrap(); + db.execute(sqlx::query("DELETE FROM current_deployments")) + .await + .unwrap(); + db.execute(sqlx::query("DELETE FROM current_deployment_revisions")) + .await + .unwrap(); db.execute(sqlx::query("DELETE FROM deployment_revisions")) .await .unwrap(); @@ -700,6 +715,21 @@ async fn preview_before_deployment_paginates_merges_and_does_not_cache() { )) .await .unwrap(); + db.execute(sqlx::query("DELETE FROM deployment_tool_bindings")) + .await + .unwrap(); + db.execute(sqlx::query("DELETE FROM deployment_registered_tools")) + .await + .unwrap(); + db.execute(sqlx::query("DELETE FROM deployment_component_revisions")) + .await + .unwrap(); + db.execute(sqlx::query("DELETE FROM current_deployments")) + .await + .unwrap(); + db.execute(sqlx::query("DELETE FROM current_deployment_revisions")) + .await + .unwrap(); db.execute(sqlx::query("DELETE FROM deployment_revisions")) .await .unwrap(); diff --git a/golem-registry-service/src/services/mcp_oauth/tests.rs b/golem-registry-service/src/services/mcp_oauth/tests.rs index 9d9302d77e..e5c1cb4237 100644 --- a/golem-registry-service/src/services/mcp_oauth/tests.rs +++ b/golem-registry-service/src/services/mcp_oauth/tests.rs @@ -1114,6 +1114,21 @@ async fn declared_consent_and_refresh_work_before_first_deployment_and_grant_is_ )) .await .unwrap(); + db.execute(sqlx::query("DELETE FROM deployment_tool_bindings")) + .await + .unwrap(); + db.execute(sqlx::query("DELETE FROM deployment_registered_tools")) + .await + .unwrap(); + db.execute(sqlx::query("DELETE FROM deployment_component_revisions")) + .await + .unwrap(); + db.execute(sqlx::query("DELETE FROM current_deployments")) + .await + .unwrap(); + db.execute(sqlx::query("DELETE FROM current_deployment_revisions")) + .await + .unwrap(); db.execute(sqlx::query("DELETE FROM deployment_revisions")) .await .unwrap(); diff --git a/golem-registry-service/tests/builtin_tools/mod.rs b/golem-registry-service/tests/builtin_tools/mod.rs index 340c1f240b..e63b50c5f2 100644 --- a/golem-registry-service/tests/builtin_tools/mod.rs +++ b/golem-registry-service/tests/builtin_tools/mod.rs @@ -56,7 +56,6 @@ async fn provisions_component_tool_release_idempotently_and_rejects_mismatch_wit }; let auth = AuthCtx::system(); - provision(&services, owner, std::slice::from_ref(&descriptor)).await; let app = services .application_service .get_in_account(owner, &ApplicationName("golem-system".into()), &auth) @@ -67,6 +66,20 @@ async fn provisions_component_tool_release_idempotently_and_rejects_mismatch_wit .get_in_application(app.id, &EnvironmentName("builtin-tools".into()), &auth) .await .unwrap(); + let baseline_component_count = services + .component_service + .list_staged_components_for_environment(&env, &auth) + .await + .unwrap() + .len(); + let baseline_deployment_count = services + .deployment_service + .list_deployments(env.id, None, &auth) + .await + .unwrap() + .len(); + + provision(&services, owner, std::slice::from_ref(&descriptor)).await; let first_component = services .component_service .get_staged_component_by_name( @@ -76,14 +89,14 @@ async fn provisions_component_tool_release_idempotently_and_rejects_mismatch_wit ) .await .unwrap(); - let first_release = only_release(&services, owner).await; + let first_release = release_named(&services, owner, descriptor.tool_name).await; let first_deployments = services .deployment_service .list_deployments(env.id, None, &auth) .await .unwrap(); assert_eq!(first_component.revision, ComponentRevision::INITIAL); - assert_eq!(first_deployments.len(), 1); + assert_eq!(first_deployments.len(), baseline_deployment_count + 1); provision(&services, owner, std::slice::from_ref(&descriptor)).await; let repeated_component = services @@ -95,7 +108,7 @@ async fn provisions_component_tool_release_idempotently_and_rejects_mismatch_wit ) .await .unwrap(); - let repeated_release = only_release(&services, owner).await; + let repeated_release = release_named(&services, owner, descriptor.tool_name).await; assert_eq!(repeated_component.id, first_component.id); assert_eq!(repeated_component.revision, first_component.revision); assert_eq!(repeated_release.id, first_release.id); @@ -106,7 +119,7 @@ async fn provisions_component_tool_release_idempotently_and_rejects_mismatch_wit .await .unwrap() .len(), - 1 + baseline_deployment_count + 1 ); let mismatch = BuiltinToolDescriptor { @@ -128,7 +141,12 @@ async fn provisions_component_tool_release_idempotently_and_rejects_mismatch_wit .await .unwrap_err(); assert!(error.to_string().contains("immutable"), "{error:#}"); - assert_eq!(only_release(&services, owner).await.id, first_release.id); + assert_eq!( + release_named(&services, owner, descriptor.tool_name) + .await + .id, + first_release.id + ); assert_eq!( services .component_service @@ -136,7 +154,7 @@ async fn provisions_component_tool_release_idempotently_and_rejects_mismatch_wit .await .unwrap() .len(), - 1 + baseline_component_count + 1 ); assert_eq!( services @@ -145,7 +163,7 @@ async fn provisions_component_tool_release_idempotently_and_rejects_mismatch_wit .await .unwrap() .len(), - 1 + baseline_deployment_count + 1 ); } @@ -166,12 +184,13 @@ async fn provision(services: &Services, owner: AccountId, descriptors: &[Builtin .unwrap(); } -async fn only_release(services: &Services, owner: AccountId) -> ToolRelease { - let releases = services +async fn release_named(services: &Services, owner: AccountId, name: &str) -> ToolRelease { + services .tool_release_service .list_in_account(owner, &AuthCtx::system()) .await - .unwrap(); - assert_eq!(releases.len(), 1); - releases.into_iter().next().unwrap() + .unwrap() + .into_iter() + .find(|release| release.name.as_str() == name) + .unwrap() } diff --git a/golem-rust-macro/src/agentic/agent_definition_impl.rs b/golem-rust-macro/src/agentic/agent_definition_impl.rs index b5c0533cda..f3abfa167e 100644 --- a/golem-rust-macro/src/agentic/agent_definition_impl.rs +++ b/golem-rust-macro/src/agentic/agent_definition_impl.rs @@ -103,30 +103,17 @@ pub(crate) fn expand_agent_definition( } = agent_type_with_remote_client; let registration_function: syn::TraitItem = syn::parse_quote! { - fn __register_agent_type() { - let agent_type = #agent_type; - let principal_input_parameters = agent_type.principal_params_in_constructor(); - - if let Some(http_mount) = &agent_type.http_mount { - golem_rust::agentic::validate_http_mount( - &agent_type.type_name, - &http_mount, - &agent_type.constructor, - &principal_input_parameters - ).expect("HTTP mount validation failed"); - } - - for method in &agent_type.methods { - golem_rust::agentic::validate_http_endpoint( - &agent_type.type_name, - method, - agent_type.http_mount.as_ref(), - ).expect("Agent method HTTP endpoint validation failed"); - } - - golem_rust::agentic::register_agent_type( - golem_rust::agentic::AgentTypeName(agent_type.type_name.to_string()), + fn __register_agent_type() where Self: Sized { + fn descriptor() -> golem_rust::golem_agentic::golem::agent::common::AgentType { + use golem_rust::agentic::{AgentParameterSchema as _, AgentParameterStreams as _}; + let agent_type = #agent_type; + golem_rust::agentic::validate_wire_agent_http(&agent_type) + .expect("Agent HTTP configuration validation failed"); agent_type + } + golem_rust::agentic::register_wire_agent_type( + golem_rust::agentic::AgentTypeName(Self::__golem_agent_type_name().to_string()), + descriptor, ); } }; @@ -511,7 +498,7 @@ fn get_agent_type_with_remote_client( }; let output_schema_token = quote! { - let mut default_outputs = vec![]; + let mut output = golem_rust::golem_agentic::golem::agent::common::OutputSchema::Unit; }; for input in &trait_fn.sig.inputs { @@ -535,15 +522,8 @@ fn get_agent_type_with_remote_client( if !has_agent_config_attr(pat_type) { let ty = &pat_type.ty; input_schema_logic.push(quote! { - let schema: golem_rust::agentic::StructuredSchema = <#ty as golem_rust::agentic::Schema>::get_type(); - match schema { - golem_rust::agentic::StructuredSchema::Default(schema) => { - default_inputs.push((#param_name.to_string(), golem_rust::agentic::EnrichedParameterSchema::Value(schema))); - }, - golem_rust::agentic::StructuredSchema::AutoInject(auto_inject_schema) => { - default_inputs.push((#param_name.to_string(), golem_rust::agentic::EnrichedParameterSchema::AutoInject(auto_inject_schema))); - } - } + default_inputs.push((&golem_rust::agentic::AgentParameterProbe::<#ty>(::std::marker::PhantomData)) + .parameter_schema(#param_name, &mut __golem_schema)); }); } } @@ -568,14 +548,10 @@ fn get_agent_type_with_remote_client( if !is_unit { output_schema_logic.push(quote! { - let schema = <#ty as golem_rust::agentic::Schema>::get_type(); - match schema { - golem_rust::agentic::StructuredSchema::Default(schema) => { - default_outputs.push(("return_value".to_string(), schema)); - }, - golem_rust::agentic::StructuredSchema::AutoInject(_) => { - panic!("Auto-injected types cannot be used as agent method return values"); - } + if !<#ty as golem_rust::WireSchema>::IS_UNIT { + output = golem_rust::golem_agentic::golem::agent::common::OutputSchema::Single( + <#ty as golem_rust::WireSchema>::append_schema(&mut __golem_schema) + ); } }); } @@ -586,7 +562,7 @@ fn get_agent_type_with_remote_client( { #input_schema_token #(#input_schema_logic)* - default_inputs + golem_rust::golem_agentic::golem::agent::common::InputSchema::Parameters(default_inputs) } }; @@ -594,12 +570,12 @@ fn get_agent_type_with_remote_client( { #output_schema_token #(#output_schema_logic)* - default_outputs + output } }; Some(quote! { - golem_rust::agentic::EnrichedAgentMethod { + golem_rust::golem_agentic::golem::agent::common::AgentMethod { name: #method_name.to_string(), description: #method_description.to_string(), prompt_hint: { @@ -698,18 +674,11 @@ fn get_agent_type_with_remote_client( let ty = &pat_type.ty; constructor_parameters_with_schema.push(quote! { assert!( - !<#ty as golem_rust::agentic::Schema>::contains_stream(), + !(&golem_rust::agentic::AgentParameterProbe::<#ty>(::std::marker::PhantomData)).parameter_contains_stream(), "AgentStream cannot be used in an agent constructor parameter" ); - let schema: golem_rust::agentic::StructuredSchema = <#ty as golem_rust::agentic::Schema>::get_type(); - match schema { - golem_rust::agentic::StructuredSchema::Default(schema) => { - constructor_default_inputs.push((#param_name.to_string(), golem_rust::agentic::EnrichedParameterSchema::Value(schema))); - }, - golem_rust::agentic::StructuredSchema::AutoInject(auto_inject_schema) => { - constructor_default_inputs.push((#param_name.to_string(), golem_rust::agentic::EnrichedParameterSchema::AutoInject(auto_inject_schema))); - } - } + constructor_default_inputs.push((&golem_rust::agentic::AgentParameterProbe::<#ty>(::std::marker::PhantomData)) + .parameter_schema(#param_name, &mut __golem_schema)); }); } } @@ -720,7 +689,7 @@ fn get_agent_type_with_remote_client( { #constructor_schema_init #(#constructor_parameters_with_schema)* - constructor_default_inputs + golem_rust::golem_agentic::golem::agent::common::InputSchema::Parameters(constructor_default_inputs) } }; @@ -760,7 +729,7 @@ fn get_agent_type_with_remote_client( { #constructor_data_schema_token - golem_rust::agentic::ExtendedAgentConstructor { + golem_rust::golem_agentic::golem::agent::common::AgentConstructor { name: #constructor_name, description: #constructor_description.to_string(), prompt_hint: #constructor_prompt_hint, @@ -776,9 +745,9 @@ fn get_agent_type_with_remote_client( }; let config_impl = { - let add_type_config_entries = agent_config_types - .iter() - .map(|ct| quote! { result.append(&mut <#ct>::config_entries()); }); + let add_type_config_entries = agent_config_types.iter().map( + |ct| quote! { result.append(&mut <#ct>::wire_config_entries(&mut __golem_schema)); }, + ); quote! { { @@ -791,19 +760,26 @@ fn get_agent_type_with_remote_client( Ok(AgentTypeWithRemoteClient { agent_type: quote! { - golem_rust::agentic::ExtendedAgentType { + { + let mut __golem_schema = golem_rust::schema::wit::direct::WireSchemaBuilder::default(); + let methods = vec![#(#methods),*]; + let constructor = #agent_constructor; + let config = #config_impl; + let root = __golem_schema.push(golem_rust::schema::wit::wire::SchemaTypeBody::RecordType(vec![])); + golem_rust::golem_agentic::golem::agent::common::AgentType { type_name: #agent_trait_name.to_string(), kind: golem_rust::golem_agentic::golem::agent::common::AgentTypeKind::#kind_value, description: #high_level_description_ident.to_string(), source_language: "rust".to_string(), - methods: vec![#(#methods),*], + methods, dependencies: vec![], - constructor: #agent_constructor, + constructor, mode: #mode_value, http_mount: #http_options, snapshotting: #snapshotting_value, - config: #config_impl, - sorted_method_indices: vec![], + config, + schema: __golem_schema.finish(root), + } } }, remote_client, diff --git a/golem-rust-macro/src/agentic/agent_implementation_impl.rs b/golem-rust-macro/src/agentic/agent_implementation_impl.rs index 97c1fe0377..66eeee11fa 100644 --- a/golem-rust-macro/src/agentic/agent_implementation_impl.rs +++ b/golem-rust-macro/src/agentic/agent_implementation_impl.rs @@ -48,7 +48,7 @@ pub fn agent_implementation_impl(_attrs: TokenStream, item: TokenStream) -> Toke <#self_ty as #trait_path>::__golem_agent_type_name() }; - let (match_arms, constructor_method) = build_match_arms(&impl_block, agent_type_name.clone()); + let (match_arms, constructor_method) = build_match_arms(&impl_block); let constructor_method = match constructor_method { Some(m) => m, @@ -206,7 +206,6 @@ fn extract_param_idents(method: &syn::ImplItemFn) -> Vec<(syn::Ident, syn::PatTy fn build_match_arms( impl_block: &ItemImpl, - agent_type_name: proc_macro2::TokenStream, ) -> (Vec, Option<&syn::ImplItemFn>) { let mut match_arms = Vec::new(); let mut constructor_method = None; @@ -256,11 +255,9 @@ fn build_match_arms( } } - // Sort by method name to assign deterministic indices matching the registry's sorted_method_indices eligible_methods.sort_by(|a, b| a.name.cmp(&b.name)); - // Second pass: generate match arms with sorted method indices - for (sorted_method_index, info) in eligible_methods.iter().enumerate() { + for info in &eligible_methods { let method_name = &info.name; let param_idents = info .params @@ -273,7 +270,8 @@ fn build_match_arms( let post_method_param_extraction_logic = match fn_output_info.async_ness { Asyncness::Future if !fn_output_info.is_unit => quote! { let result = ::#ident(self, #(#param_idents),*).await; - golem_rust::agentic::Schema::into_agent_invocation_result(result).map_err(|e| { + golem_rust::schema::wit::direct::encode_async(&result).await + .map(|value| golem_rust::agentic::AgentInvocationResult { value: Some(value) }).map_err(|e| { golem_rust::agentic::custom_error(format!( "Failed serializing return value for method {}: {}", #method_name, e @@ -286,7 +284,8 @@ fn build_match_arms( }, Asyncness::Immediate if !fn_output_info.is_unit => quote! { let result = ::#ident(self, #(#param_idents),*); - golem_rust::agentic::Schema::into_agent_invocation_result(result).map_err(|e| { + golem_rust::schema::wit::direct::encode_async(&result).await + .map(|value| golem_rust::agentic::AgentInvocationResult { value: Some(value) }).map_err(|e| { golem_rust::agentic::custom_error(format!( "Failed serializing return value for method {}: {}", #method_name, e @@ -301,9 +300,7 @@ fn build_match_arms( let method_param_extraction = generate_method_param_extraction( &info.params, - agent_type_name.clone(), method_name.as_str(), - sorted_method_index, post_method_param_extraction_logic, ); @@ -320,117 +317,30 @@ fn build_match_arms( fn generate_method_param_extraction( params: &[(syn::Ident, syn::PatType)], - agent_type_name: proc_macro2::TokenStream, method_name: &str, - sorted_method_index: usize, post_method_param_extraction_logic: proc_macro2::TokenStream, ) -> proc_macro2::TokenStream { - let input_param_index = fresh_method_local(params, "__golem_input_param_index"); - let agent_type_name_raw = fresh_method_local(params, "__golem_agent_type_name_raw"); - let agent_type_name_local = fresh_method_local(params, "__golem_agent_type_name"); - let param_schemas = fresh_method_local(params, "__golem_param_schemas"); let input_variant = fresh_method_local(params, "__golem_input_variant"); let invocation_principal = fresh_method_local(params, "__golem_invocation_principal"); - let input_param_index_init = quote! { - let mut #input_param_index: usize = 0; - let #agent_type_name_raw = #agent_type_name; - let #agent_type_name_local = golem_rust::agentic::AgentTypeName(#agent_type_name_raw.to_string()); - let #param_schemas = golem_rust::agentic::get_method_parameter_types_by_index( - &#agent_type_name_local, - #sorted_method_index - ).ok_or_else(|| { - golem_rust::agentic::custom_error(format!( - "Internal Error: Parameter schemas not found for agent: {}, method index: {}", - #agent_type_name_raw, #sorted_method_index - )) - })?; - let #invocation_principal = &principal; - }; - - let extraction: Vec = params.iter().enumerate().map(|(original_method_param_idx, (ident, pat_type))| { - let ident_result = format_ident!("{}_result", ident); + let extraction = params.iter().enumerate().map(|(index, (ident, pat_type))| { let ty = &pat_type.ty; quote! { - let #ident_result = match &mut #input_variant { - __InputVariant::Tuple(values) => { - let enriched_schema = #param_schemas.get(#original_method_param_idx) - .cloned() - .ok_or_else(|| { - golem_rust::agentic::custom_error(format!( - "Internal Error: Parameter schema not found for agent: {}, method: {}, parameter index: {}", - #agent_type_name_raw, #method_name, #original_method_param_idx - )) - })?; - - match enriched_schema { - golem_rust::agentic::EnrichedParameterSchema::AutoInject(auto_injected_schema) => { - match auto_injected_schema { - golem_rust::agentic::AutoInjectedParamType::Principal => { - golem_rust::agentic::Schema::from_principal((*#invocation_principal).clone()).map_err(|e| { - golem_rust::agentic::invalid_input_error(format!("Failed parsing arg {} for method {}: {}", #original_method_param_idx, #method_name, e)) - }) - } - } - } - - golem_rust::agentic::EnrichedParameterSchema::Value(schema) => { - let schema_value = if #input_param_index < values.len() { - values[#input_param_index].take().ok_or_else(|| { - golem_rust::agentic::invalid_input_error(format!("Argument already consumed in method {}", #method_name)) - })? - } else { - return Err(golem_rust::agentic::invalid_input_error(format!("Missing arguments in method {}", #method_name))); - }; - - // only increment the input_param_index for non auto-injected parameters - #input_param_index += 1; - - <#ty as golem_rust::agentic::Schema>::from_schema_value( - schema_value, - golem_rust::agentic::StructuredSchema::Default(schema), - ).map_err(|e| { - golem_rust::agentic::invalid_input_error(format!("Failed parsing arg {} for method {}: {}", #original_method_param_idx, #method_name, e)) - }) - } - } - } - }; - - let #ident = #ident_result?; + let #ident = (&golem_rust::agentic::AgentParameterProbe::<#ty>(::std::marker::PhantomData)) + .read_parameter(&mut #input_variant, #invocation_principal) + .map_err(|error| golem_rust::agentic::invalid_input_error(format!( + "Failed parsing arg {} for method {}: {}", #index, #method_name, error + )))?; } - }).collect(); + }); quote! { - enum __InputVariant { - Tuple(Vec>), - } - - #input_param_index_init - - let mut #input_variant = match input { - golem_rust::SchemaValue::Record { fields: values } => { - __InputVariant::Tuple(values.into_iter().map(Some).collect()) - }, - other => { - return Err(golem_rust::agentic::invalid_input_error(format!("Failed decoding method {} input: expected record, got {:?}", #method_name, other))); - }, - }; - + use golem_rust::agentic::ReadAgentParameter as _; + let #invocation_principal = &principal; + let mut #input_variant = golem_rust::agentic::DirectAgentInput::new(input) + .map_err(|error| golem_rust::agentic::invalid_input_error(error.to_string()))?; #(#extraction)* - if let __InputVariant::Tuple(values) = &#input_variant { - if #input_param_index != values.len() { - return Err(golem_rust::agentic::invalid_input_error(format!( - "Unexpected extra arguments in method {}: expected {}, got {}", - #method_name, - #input_param_index, - values.len() - ))); - } - } - drop(#input_variant); - drop(#param_schemas); - drop(#agent_type_name_local); - + #input_variant.finish() + .map_err(|error| golem_rust::agentic::invalid_input_error(error.to_string()))?; #post_method_param_extraction_logic } } @@ -492,7 +402,7 @@ fn generate_base_agent_impl( golem_rust::agentic::get_agent_id().agent_id } - async fn invoke(&mut self, method_name: String, input: golem_rust::SchemaValue, principal: golem_rust::golem_agentic::golem::agent::common::Principal) + async fn invoke(&mut self, method_name: String, input: golem_rust::schema::wit::wire::SchemaValueTree, principal: golem_rust::golem_agentic::golem::agent::common::Principal) -> Result { match method_name.as_str() { #(#match_arms,)* @@ -517,104 +427,33 @@ fn generate_constructor_extraction( agent_type_name: proc_macro2::TokenStream, call_back: proc_macro2::TokenStream, ) -> proc_macro2::TokenStream { - let mut config_extractions = Vec::new(); - let mut predecls = Vec::new(); - let mut tuple_extractions = Vec::new(); - - let mut schema_param_index: usize = 0; - for (ident, pat_type) in ctor_params { + let input = fresh_method_local(ctor_params, "__golem_constructor_input"); + let caller = fresh_method_local(ctor_params, "__golem_constructor_principal"); + let extractions = ctor_params.iter().enumerate().map(|(index, (ident, pat_type))| { + let ty = &pat_type.ty; if has_agent_config_attr(pat_type) { - let ty = &pat_type.ty; - config_extractions.push(quote! { + quote! { let #ident: #ty = ::golem_rust::agentic::Config::new(); - }); + } } else { - let ty = &pat_type.ty; - let idx = schema_param_index; - predecls.push(quote! { - let #ident: #ty; - }); - tuple_extractions.push(quote! { - { - let enriched_schema = __ctor_schemas.get(#idx) - .cloned() - .ok_or_else(|| { - golem_rust::agentic::internal_error(format!( - "Constructor parameter schema not found for agent: {}, parameter index: {}", - __agent_type_name_raw, #idx - )) - })?; - - match enriched_schema { - golem_rust::agentic::EnrichedParameterSchema::AutoInject(auto_injected_schema) => { - match auto_injected_schema { - golem_rust::agentic::AutoInjectedParamType::Principal => { - #ident = golem_rust::agentic::Schema::from_principal(principal.clone()).map_err(|e| { - golem_rust::agentic::invalid_input_error(format!("Failed parsing constructor arg {}: {}", #idx, e)) - })?; - } - } - } - - golem_rust::agentic::EnrichedParameterSchema::Value(schema) => { - let schema_value = if input_param_index < values.len() { - values[input_param_index].take().ok_or_else(|| { - golem_rust::agentic::invalid_input_error(format!("Constructor argument already consumed for agent {}", __agent_type_name_raw)) - })? - } else { - return Err(golem_rust::agentic::invalid_input_error(format!("Missing constructor arguments for agent {}", __agent_type_name_raw))); - }; - - input_param_index += 1; - - #ident = <#ty as golem_rust::agentic::Schema>::from_schema_value( - schema_value, - golem_rust::agentic::StructuredSchema::Default(schema), - ).map_err(|e| { - golem_rust::agentic::invalid_input_error(format!("Failed parsing constructor arg {}: {}", #idx, e)) - })?; - } - } - } - }); - schema_param_index += 1; - } - } - - quote! { - let __agent_type_name_raw = #agent_type_name; - let __agent_type_name = golem_rust::agentic::AgentTypeName(__agent_type_name_raw.to_string()); - - #(#config_extractions)* - #(#predecls)* - - let mut values: Vec> = match params { - golem_rust::SchemaValue::Record { fields } => fields.into_iter().map(Some).collect(), - other => { - return Err(golem_rust::agentic::invalid_input_error(format!("Failed decoding constructor input for agent {}: expected record, got {:?}", __agent_type_name_raw, other))); + quote! { + let #ident = (&golem_rust::agentic::AgentParameterProbe::<#ty>(::std::marker::PhantomData)) + .read_parameter(&mut #input, #caller) + .map_err(|error| golem_rust::agentic::invalid_input_error(format!( + "Failed parsing constructor arg {} for agent {}: {}", #index, #agent_type_name, error + )))?; } - }; - let mut input_param_index: usize = 0; - - let __ctor_schemas = golem_rust::agentic::get_constructor_parameter_types( - &__agent_type_name, - ).ok_or_else(|| { - golem_rust::agentic::internal_error(format!( - "Constructor parameter schemas not found for agent: {}", - __agent_type_name_raw - )) - })?; - - #(#tuple_extractions)* - if input_param_index != values.len() { - return Err(golem_rust::agentic::invalid_input_error(format!( - "Unexpected extra constructor arguments for agent {}: expected {}, got {}", - __agent_type_name_raw, - input_param_index, - values.len() - ))); } + }); + quote! { + use golem_rust::agentic::ReadAgentParameter as _; + let #caller = &principal; + let mut #input = golem_rust::agentic::DirectAgentInput::new(params) + .map_err(|error| golem_rust::agentic::invalid_input_error(error.to_string()))?; + #(#extractions)* + #input.finish() + .map_err(|error| golem_rust::agentic::invalid_input_error(error.to_string()))?; #call_back } } @@ -638,7 +477,7 @@ fn generate_initiator_impl( #[golem_rust::async_trait::async_trait(?Send)] impl golem_rust::agentic::AgentInitiator for #initiator_ident { - async fn initiate(&self, params: golem_rust::SchemaValue, principal: golem_rust::golem_agentic::golem::agent::common::Principal) + async fn initiate(&self, params: golem_rust::schema::wit::wire::SchemaValueTree, principal: golem_rust::golem_agentic::golem::agent::common::Principal) -> Result { #constructor_param_extraction } @@ -672,6 +511,8 @@ fn generate_register_initiator_fn( quote! { ::golem_rust::ctor::__support::ctor_parse!( #[ctor] fn #register_initiator_fn_name() { + golem_rust::agentic::install_agent_exports(); + golem_rust::agentic::install_tool_exports(); <#self_ty as #agent_trait_path>::__register_agent_type(); golem_rust::agentic::register_agent_initiator( diff --git a/golem-rust-macro/src/agentic/client_generation/mod.rs b/golem-rust-macro/src/agentic/client_generation/mod.rs index a87b4db76a..2a33745249 100644 --- a/golem-rust-macro/src/agentic/client_generation/mod.rs +++ b/golem-rust-macro/src/agentic/client_generation/mod.rs @@ -98,8 +98,6 @@ pub fn get_remote_client_for_type( let phantom_id_param_ident = fresh_param_ident(&constructor_param_idents, "phantom_id"); let rpc_config_params_ident = fresh_param_ident(&constructor_param_idents, "__golem_rpc_config_params"); - let remote_agent_type_ident = - fresh_param_ident(&constructor_param_idents, "__golem_agent_type"); let phantom_uuid_ident = fresh_param_ident(&constructor_param_idents, "phantom_uuid"); let constructor_value_ident = fresh_param_ident(&constructor_param_idents, "constructor_value"); let agent_id_ident = fresh_param_ident(&constructor_param_idents, "agent_id"); @@ -109,15 +107,16 @@ pub fn get_remote_client_for_type( construction_fallible, ); let constructor_schema_fields = constructor_data_value_param_defs; - let constructor_schema_definition = agent_type_parameter_names.is_empty().then(|| { - quote! { - #[doc(hidden)] - #[derive(golem_rust::IntoSchema)] - struct #constructor_schema_type_name { - #(#constructor_schema_fields,)* + let constructor_schema_definition = + (construction_fallible && agent_type_parameter_names.is_empty()).then(|| { + quote! { + #[doc(hidden)] + #[derive(golem_rust::IntoSchema)] + struct #constructor_schema_type_name { + #(#constructor_schema_fields,)* + } } - } - }); + }); let agent_id_body = quote! {{ #encode_constructor #constructor_value_ident @@ -159,9 +158,6 @@ pub fn get_remote_client_for_type( #prelude - let #remote_agent_type_ident = - golem_rust::golem_agentic::golem::agent::host::get_agent_type(#type_name) - .ok_or_else(|| golem_rust::GolemReflectError::AgentTypeNotFound(#type_name.to_string()))?; let __golem_constructor = golem_rust::encode_schema_value(&#constructor_value_ident) .map_err(|error| golem_rust::GolemReflectError::SchemaEncode(error.to_string()))?; let #agent_id_ident = golem_rust::golem_agentic::golem::agent::host::make_agent_id( @@ -183,42 +179,35 @@ pub fn get_remote_client_for_type( Ok(#remote_client_type_name { agent_id: #agent_id_ident, phantom_id: #phantom_struct, - component_id: #remote_agent_type_ident.implemented_by, wasm_rpc, }) } } else { quote! { - #encode_constructor + #encode_constructor - #prelude + #prelude - let #remote_agent_type_ident = - golem_rust::golem_agentic::golem::agent::host::get_agent_type(#type_name) - .expect("Internal Error: Agent type not registered"); + let #agent_id_ident = golem_rust::golem_agentic::golem::agent::host::make_agent_id( + #type_name, + #constructor_value_ident(), + #phantom_wire, + ) + .expect("Internal Error: Failed to make agent id"); - let #agent_id_ident = golem_rust::golem_agentic::golem::agent::host::make_agent_id( - #type_name, - golem_rust::encode_schema_value(&#constructor_value_ident) - .expect("Failed to encode constructor parameters for agent id"), - #phantom_wire, - ) - .expect("Internal Error: Failed to make agent id"); - - let wasm_rpc = golem_rust::golem_agentic::golem::agent::host::WasmRpc::new( - #type_name, - golem_rust::encode_schema_value(&#constructor_value_ident) - .expect("Failed to encode constructor parameters"), - #phantom_wire, - #config, - ); - - #remote_client_type_name { - agent_id: #agent_id_ident, - phantom_id: #phantom_struct, - component_id: #remote_agent_type_ident.implemented_by, - wasm_rpc, - } + let wasm_rpc = golem_rust::golem_agentic::golem::agent::host::WasmRpc::create( + #type_name, + #constructor_value_ident(), + #phantom_wire, + #config, + ) + .unwrap_or_else(|error| panic!("Internal Error: Agent type not registered: {error:?}")); + + #remote_client_type_name { + agent_id: #agent_id_ident, + phantom_id: #phantom_struct, + wasm_rpc, + } } } }; @@ -374,7 +363,6 @@ pub fn get_remote_client_for_type( quote! { agent_id: String, phantom_id: Option, - component_id: golem_rust::schema::wit::wire::ComponentId, } }); let get_phantom_impl = quote! { @@ -408,7 +396,10 @@ pub fn get_remote_client_for_type( ); } }; - let bind_impl = (agent_is_durable && agent_type_parameter_names.is_empty()).then(|| { + let bind_impl = (construction_fallible + && agent_is_durable + && agent_type_parameter_names.is_empty()) + .then(|| { quote! { pub fn #bind_helper_ident(agent_id: &golem_rust::ParsedAgentId) -> Result @@ -424,18 +415,24 @@ pub fn get_remote_client_for_type( let expected = golem_rust::schema::try_into_schema_graph::<#constructor_schema_type_name>() .map_err(|error| golem_rust::GolemReflectError::InvalidType(error.to_string()))?; golem_rust::SchemaRef::new(expected).validate_value(&parts.constructor_value)?; - let agent_type = golem_rust::golem_agentic::golem::agent::host::get_agent_type(#type_name) - .ok_or_else(|| golem_rust::GolemReflectError::AgentTypeNotFound(#type_name.to_string()))?; #create_bound_transport Ok(Self { agent_id: agent_id.as_str().to_string(), phantom_id: parts.phantom_id, - component_id: agent_type.implemented_by, wasm_rpc, }) } } }); + let agent_id_helper_impl = construction_fallible.then(|| quote! { + pub fn #agent_id_helper_ident( + #(#constructor_data_value_param_defs,)* + #phantom_id_param_ident: Option, + ) -> Result { + let constructor = #agent_id_body; + golem_rust::ParsedAgentId::from_value(#type_name, constructor, #phantom_id_param_ident) + } + }); quote! { #constructor_schema_definition @@ -445,13 +442,7 @@ pub fn get_remote_client_for_type( } impl #remote_client_type_name { - pub fn #agent_id_helper_ident( - #(#constructor_data_value_param_defs,)* - #phantom_id_param_ident: Option, - ) -> Result { - let constructor = #agent_id_body; - golem_rust::ParsedAgentId::from_value(#type_name, constructor, #phantom_id_param_ident) - } + #agent_id_helper_impl #bind_impl @@ -503,13 +494,13 @@ fn build_ephemeral_constructor_body( } else { quote! { #encode_constructor - let wasm_rpc = golem_rust::golem_agentic::golem::agent::host::WasmRpc::new( + let wasm_rpc = golem_rust::golem_agentic::golem::agent::host::WasmRpc::create( #type_name, - golem_rust::encode_schema_value(&#constructor_value) - .expect("Failed to encode constructor parameters"), + #constructor_value(), #phantom_id, #config, - ); + ) + .unwrap_or_else(|error| panic!("Internal Error: Agent type not registered: {error:?}")); #client { wasm_rpc } } } @@ -523,6 +514,13 @@ fn generate_constructor_data_value_params_encoding( constructor_value_ident: &proc_macro2::Ident, fallible: bool, ) -> proc_macro2::TokenStream { + if !fallible { + let constructor_record = encode_parameters(param_idents, false, true); + return quote! { + let #constructor_value_ident = || #constructor_record; + }; + } + let constructor_record = positional_record_schema_value(param_idents, "Failed to convert constructor parameter"); let validate = if fallible { @@ -542,6 +540,38 @@ fn generate_constructor_data_value_params_encoding( } } +fn encode_parameters( + idents: &[syn::Ident], + asynchronous: bool, + constructor: bool, +) -> proc_macro2::TokenStream { + let resources = fresh_param_ident(idents, "__golem_resources"); + let writer = fresh_param_ident(idents, "__golem_writer"); + let fields = fresh_param_ident(idents, "__golem_fields"); + let preflight = if asynchronous { + quote! { golem_rust::schema::wit::direct::WirePreflight::asynchronous() } + } else { + quote! { golem_rust::schema::wit::direct::WirePreflight::default() } + }; + let prepare = asynchronous.then(|| quote! { + #((&golem_rust::agentic::AgentArgument(&#idents)).prepare_parameter().await.expect("Failed to prepare parameter"));*; + }); + let reject_quota = constructor.then(|| quote! { + #resources.reject_quota_tokens().expect("quota tokens are not allowed in agent constructor parameters"); + }); + quote! {{ + use golem_rust::agentic::WriteAgentParameter as _; + let mut #resources = #preflight; + #((&golem_rust::agentic::AgentArgument(&#idents)).preflight_parameter(&mut #resources).expect("Failed to preflight parameter"));*; + #reject_quota + #prepare + let mut #writer = golem_rust::schema::wit::direct::WireWriter::default(); + let #fields: Vec> = vec![#((&golem_rust::agentic::AgentArgument(&#idents)).write_parameter(&mut #writer).expect("Failed to encode parameter")),*]; + let root = #writer.push(golem_rust::schema::wit::wire::SchemaValueNode::RecordValue(#fields.into_iter().flatten().collect())); + #writer.finish(root) + }} +} + fn get_remote_agent_methods_info( tr: &ItemTrait, type_parameter_names: &[String], @@ -635,6 +665,19 @@ fn generate_method_code( agent_is_durable: bool, fallible: bool, ) -> proc_macro2::TokenStream { + if !fallible { + return generate_direct_method_code( + method_name, + trigger_name, + schedule_name, + schedule_cancelable_name, + input_defs, + input_idents, + sig, + agent_is_durable, + ); + } + let remote_method_name = method_name.to_string(); let remote_token = quote! { #remote_method_name }; let fn_output_info = FunctionOutputInfo::from_signature(sig); @@ -982,6 +1025,143 @@ fn generate_method_code( } } +fn generate_direct_method_code( + method_name: &syn::Ident, + trigger_name: &syn::Ident, + schedule_name: &syn::Ident, + schedule_cancelable_name: &syn::Ident, + input_defs: &[&syn::FnArg], + input_idents: &[syn::Ident], + sig: &syn::Signature, + agent_is_durable: bool, +) -> proc_macro2::TokenStream { + let remote_method_name = method_name.to_string(); + let remote_token = quote! { #remote_method_name }; + let fn_output_info = FunctionOutputInfo::from_signature(sig); + let return_type = match &sig.output { + syn::ReturnType::Type(_, ty) => quote! { #ty }, + syn::ReturnType::Default => quote! { () }, + }; + let stream_types = sig + .inputs + .iter() + .filter_map(|arg| match arg { + syn::FnArg::Typed(param) => Some(param.ty.as_ref()), + syn::FnArg::Receiver(_) => None, + }) + .chain(match &sig.output { + syn::ReturnType::Type(_, ty) => Some(ty.as_ref()), + syn::ReturnType::Default => None, + }) + .collect::>(); + let reject_non_awaited_stream_invocation = quote! { + use golem_rust::agentic::AgentParameterStreams as _; + if false #(|| (&golem_rust::agentic::AgentParameterProbe::<#stream_types>(::std::marker::PhantomData)).parameter_contains_stream())* { + let _ = (#(&#input_idents),*); + panic!("live streams cannot cross remote or scheduled agent invocation boundaries") + } + }; + let process_invoke_result = match &sig.output { + syn::ReturnType::Type(_, ty) if !fn_output_info.is_unit => quote! { + golem_rust::schema::wit::direct::decode::<#ty>(rpc_result_ok.expect("remote method returned no value")) + .expect("Failed to deserialize rpc result to return type") + }, + _ => quote! {}, + }; + + let encoded_input = encode_parameters(input_idents, false, false); + let encode_input = quote! { let input = #encoded_input; }; + let encoded_input_async = encode_parameters(input_idents, true, false); + let encode_input_async = quote! { let input = #encoded_input_async; }; + let scheduled_time_param = fresh_param_ident(input_idents, "scheduled_time"); + if agent_is_durable { + return quote! { + pub async fn #method_name(#(#input_defs),*) -> #return_type { + #encode_input_async + + let rpc_result_future = self.wasm_rpc.async_invoke_and_await( + #remote_token, + input, + None + ).future; + + let rpc_result = rpc_result_future.get().await; + + let rpc_result_ok = + rpc_result.unwrap_or_else(|e| panic!("rpc call to {} failed: {:?}", #remote_token, e)); + + #process_invoke_result + } + + pub fn #trigger_name(#(#input_defs),*) { + #reject_non_awaited_stream_invocation + #encode_input + + let rpc_result: Result<(), golem_rust::golem_agentic::golem::agent::host::RpcError> = + self.wasm_rpc.invoke(#remote_token, input, None).map(|_| ()); + + rpc_result.unwrap_or_else(|e| panic!("rpc call to trigger {} failed: {:?}", #remote_token, e)); + } + + pub fn #schedule_name(#(#input_defs,)* #scheduled_time_param: golem_rust::ScheduledTime) -> Result<(), golem_rust::golem_agentic::golem::agent::host::RpcError> { + #reject_non_awaited_stream_invocation + #encode_input + + self.wasm_rpc.schedule_invocation( + #scheduled_time_param, + #remote_token, + input, + None + ).map(|_| ()) + } + + pub fn #schedule_cancelable_name(#(#input_defs,)* #scheduled_time_param: golem_rust::ScheduledTime) -> Result { + #reject_non_awaited_stream_invocation + #encode_input + + self.wasm_rpc.schedule_cancelable_invocation( + #scheduled_time_param, + #remote_token, + input, + None + ).map(|receipt| receipt.cancellation_token) + } + }; + } + + quote! { + pub async fn #method_name(#(#input_defs),*) -> golem_rust::agentic::EphemeralInvocationResult<#return_type> { + #encode_input_async + let invocation = self.wasm_rpc.async_invoke_and_await(#remote_token, input, None); + let metadata = invocation.metadata; + let rpc_result = invocation.future.get().await; + let rpc_result_ok = rpc_result.unwrap_or_else(|e| panic!("rpc call to {} failed: {:?}", #remote_token, e)); + let value = { #process_invoke_result }; + golem_rust::agentic::EphemeralInvocationResult { metadata, value } + } + + pub fn #trigger_name(#(#input_defs),*) -> golem_rust::golem_agentic::golem::agent::host::InvocationMetadata { + #reject_non_awaited_stream_invocation + #encode_input + self.wasm_rpc.invoke(#remote_token, input, None) + .unwrap_or_else(|e| panic!("rpc call to trigger {} failed: {:?}", #remote_token, e)) + } + + pub fn #schedule_name(#(#input_defs,)* #scheduled_time_param: golem_rust::ScheduledTime) -> Result { + #reject_non_awaited_stream_invocation + #encode_input + self.wasm_rpc.schedule_invocation(#scheduled_time_param, #remote_token, input, None) + .map(|receipt| receipt.metadata) + } + + pub fn #schedule_cancelable_name(#(#input_defs,)* #scheduled_time_param: golem_rust::ScheduledTime) -> Result { + #reject_non_awaited_stream_invocation + #encode_input + self.wasm_rpc.schedule_cancelable_invocation(#scheduled_time_param, #remote_token, input, None) + } + } +} + fn fresh_param_ident(occupied: &[syn::Ident], preferred_name: &str) -> syn::Ident { let occupied = occupied .iter() diff --git a/golem-rust-macro/src/agentic/client_generation/tests.rs b/golem-rust-macro/src/agentic/client_generation/tests.rs index 73bbdfbc76..69b587b9db 100644 --- a/golem-rust-macro/src/agentic/client_generation/tests.rs +++ b/golem-rust-macro/src/agentic/client_generation/tests.rs @@ -43,6 +43,10 @@ fn durable_agents_generate_getters() { assert!(rendered.contains("pub fn get (")); assert!(rendered.contains("get_with_config")); + assert!(!rendered.contains("get_agent_type")); + assert!(!rendered.contains("component_id")); + assert!(rendered.contains("WasmRpc :: create")); + assert!(!rendered.contains("WasmRpc :: new")); } #[test] @@ -64,6 +68,32 @@ fn ephemeral_agents_generate_known_and_fresh_phantom_getters() { assert!(!rendered.contains("fn get_agent_id (")); } +#[test] +fn caller_defined_clients_do_not_load_reflection_metadata() { + let item_trait = parse_quote! { + trait ExampleAgent { + fn new(name: String) -> Self; + fn ping(&self); + } + }; + let rendered = super::get_remote_client_for_type( + &item_trait, + "remote:example/agent", + &[quote! { name: String }], + &[format_ident!("name")], + &[], + &[], + &[], + true, + true, + ) + .to_string(); + + assert!(!rendered.contains("get_agent_type")); + assert!(!rendered.contains("component_id")); + assert!(rendered.contains("WasmRpc :: create")); +} + #[test] fn awaited_streaming_methods_use_async_value_encoding_only() { let item_trait = parse_quote! { @@ -79,7 +109,12 @@ fn awaited_streaming_methods_use_async_value_encoding_only() { for durable in [true, false] { let rendered = get_remote_client(&item_trait, &[], &[], &[], &[], &[], durable).to_string(); - assert_eq!(rendered.matches("encode_schema_value_async").count(), 1); + assert_eq!(rendered.matches("prepare_parameter").count(), 1); + assert!(!rendered.contains("encode_schema_value")); + assert!(!rendered.contains("from_schema_value")); + assert!(!rendered.contains("get_schema_graph")); + assert!(!rendered.contains("Schema > :: contains_stream")); + assert!(rendered.contains("parameter_contains_stream")); assert_eq!( rendered .matches( @@ -499,5 +534,5 @@ fn client_does_not_store_affine_constructor_tree() { assert!(rendered.contains("make_agent_id")); assert!(rendered.contains("agent_id")); // Quota tokens are rejected in constructor parameters before any encode. - assert!(rendered.contains("__reject_quota_tokens_in_agent_constructor")); + assert!(rendered.contains("reject_quota_tokens")); } diff --git a/golem-rust-macro/src/agentic/config_schema_impl.rs b/golem-rust-macro/src/agentic/config_schema_impl.rs index dd02f83bfb..2bad9bcc05 100644 --- a/golem-rust-macro/src/agentic/config_schema_impl.rs +++ b/golem-rust-macro/src/agentic/config_schema_impl.rs @@ -70,6 +70,7 @@ fn generate_config_schema_impl( fields: &syn::punctuated::Punctuated, ) -> proc_macro2::TokenStream { let mut append_config_entries = Vec::new(); + let mut append_wire_entries = Vec::new(); let mut load_entries = Vec::new(); for field in fields { @@ -77,6 +78,41 @@ fn generate_config_schema_impl( let field_name_str = field_ident.to_string(); let field_ty = &field.ty; + let wire_entry = if has_nested_attr(field) { + quote::quote! { + config_entries.extend(<#field_ty as #golem_rust_crate_ident::agentic::ConfigSchema>::describe_wire_config(&field_path, builder)); + } + } else { + let (source, value_type) = if has_secret_attr(field) { + ( + quote::quote! { Secret }, + quote::quote! {{ + let inner = <<#field_ty as #golem_rust_crate_ident::agentic::InnerTypeHelper>::Type as #golem_rust_crate_ident::WireSchema>::append_schema(builder); + builder.push(#golem_rust_crate_ident::schema::wit::wire::SchemaTypeBody::SecretType( + #golem_rust_crate_ident::schema::wit::wire::SecretSpec { inner, category: None } + )) + }}, + ) + } else { + ( + quote::quote! { Local }, + quote::quote! { <#field_ty as #golem_rust_crate_ident::WireSchema>::append_schema(builder) }, + ) + }; + quote::quote! { + config_entries.push(#golem_rust_crate_ident::golem_agentic::golem::agent::common::AgentConfigDeclaration { + source: #golem_rust_crate_ident::golem_agentic::golem::agent::common::AgentConfigSource::#source, + path: field_path, + value_type: #value_type, + }); + } + }; + append_wire_entries.push(quote::quote! {{ + let mut field_path = path.to_vec(); + field_path.push(#field_name_str.to_string()); + #wire_entry + }}); + if has_nested_attr(field) { append_config_entries.push(quote::quote! { { @@ -132,15 +168,12 @@ fn generate_config_schema_impl( #field_ident: { let mut field_path = path.to_vec(); field_path.push(#field_name_str.to_string()); - let graph = #golem_rust_crate_ident::schema::try_into_schema_graph::<#field_ty>() - .expect("failed to build config schema graph"); + let graph = #golem_rust_crate_ident::schema::wit::direct::schema::<#field_ty>(); let value = #golem_rust_crate_ident::golem_agentic::golem::agent::host::get_config_value( &field_path, - &#golem_rust_crate_ident::encode_schema_graph(&graph).expect("failed to encode config schema graph"), + &graph, )?; - let value = #golem_rust_crate_ident::decode_schema_value(value) - .expect("failed to decode config schema value"); - #golem_rust_crate_ident::schema::FromSchema::from_value(&value) + #golem_rust_crate_ident::schema::wit::direct::decode::<#field_ty>(value) .expect("failed deserializing config value") } }); @@ -157,6 +190,13 @@ fn generate_config_schema_impl( config_entries } + fn describe_wire_config(path: &[String], builder: &mut #golem_rust_crate_ident::schema::wit::direct::WireSchemaBuilder) + -> Vec<#golem_rust_crate_ident::golem_agentic::golem::agent::common::AgentConfigDeclaration> { + let mut config_entries = Vec::new(); + #(#append_wire_entries)* + config_entries + } + fn load(path: &[String]) -> Result { Ok(Self { #(#load_entries),* @@ -208,6 +248,7 @@ fn generate_into_rpc_config_param_impl( for field in fields { let field_ident = field.ident.as_ref().unwrap(); let field_name_str = field_ident.to_string(); + let field_ty = &field.ty; if has_secret_attr(field) { continue; // secrets omitted @@ -228,12 +269,13 @@ fn generate_into_rpc_config_param_impl( let mut field_path = path.to_vec(); field_path.push(#field_name_str.to_string()); - let typed = #golem_rust_crate_ident::schema::IntoTypedSchemaValue::into_typed_schema_value(&value) - .expect("failed to build config value"); result.push(#golem_rust_crate_ident::golem_agentic::golem::agent::common::TypedAgentConfigValue { path: field_path, - value: #golem_rust_crate_ident::encode_typed_schema_value(&typed) - .expect("failed to encode config value"), + value: #golem_rust_crate_ident::schema::wit::wire::TypedSchemaValue { + graph: #golem_rust_crate_ident::schema::wit::direct::schema::<#field_ty>(), + value: #golem_rust_crate_ident::schema::wit::direct::encode(&value) + .expect("failed to encode config value"), + }, }); } } diff --git a/golem-rust-macro/src/agentic/multimodal_derivation.rs b/golem-rust-macro/src/agentic/multimodal_derivation.rs index 610b855fc1..61920c9ede 100644 --- a/golem-rust-macro/src/agentic/multimodal_derivation.rs +++ b/golem-rust-macro/src/agentic/multimodal_derivation.rs @@ -33,6 +33,8 @@ pub fn derive_multimodal(input: TokenStream) -> TokenStream { let mut serialize_match_arms = Vec::new(); let mut get_name_match_arms = Vec::new(); let mut from_schema_value_match_arms = Vec::new(); + let mut wire_cases = Vec::new(); + let mut stream_types = Vec::new(); for variant in data_enum.variants.iter() { let variant_ident = &variant.ident; @@ -42,6 +44,15 @@ pub fn derive_multimodal(input: TokenStream) -> TokenStream { match &variant.fields { Fields::Unnamed(fields) if fields.unnamed.len() == 1 => { let field_type = &fields.unnamed[0].ty; + stream_types.push(field_type); + + wire_cases.push(quote! { + golem_rust::schema::wit::wire::VariantCaseType { + name: #variant_name.to_string(), + payload: Some(<#field_type as golem_rust::WireSchema>::append_schema(builder)), + metadata: golem_rust::schema::wit::direct::empty_metadata(), + } + }); get_type_pairs.push(quote! { (#variant_name.to_string(), <#field_type as golem_rust::agentic::Schema>::get_type().get_schema_graph().expect("multimodal types cannot be nested")) @@ -79,6 +90,21 @@ pub fn derive_multimodal(input: TokenStream) -> TokenStream { } let expanded = quote! { + impl golem_rust::agentic::MultimodalWire for #enum_name { + fn contains_stream(seen: &mut ::std::collections::HashSet<&'static str>) -> bool { + if !seen.insert(::core::any::type_name::()) { + return false; + } + false #(|| <#stream_types as golem_rust::WireSchema>::contains_stream(seen))* + } + + fn append_modality_cases( + builder: &mut golem_rust::schema::wit::direct::WireSchemaBuilder, + ) -> Vec { + vec![#(#wire_cases),*] + } + } + impl golem_rust::agentic::MultimodalSchema for #enum_name { fn get_multimodal_schema() -> Vec<(String, golem_rust::schema::SchemaGraph)> { vec![ diff --git a/golem-rust-macro/src/lib.rs b/golem-rust-macro/src/lib.rs index b8e36cb5e3..544d64b143 100644 --- a/golem-rust-macro/src/lib.rs +++ b/golem-rust-macro/src/lib.rs @@ -168,7 +168,8 @@ pub fn universal_tool_middleware(attr: TokenStream, item: TokenStream) -> TokenS #[cfg(not(test))] #[proc_macro_derive(ToolError, attributes(tool_error, example))] pub fn derive_tool_error(input: TokenStream) -> TokenStream { - tool::derive_tool_error_impl(input, &get_tool_schema_crate_ident()) + let guest = crate_name("golem-rust").is_ok() || crate_name("golem-native-tool").is_err(); + tool::derive_tool_error_impl(input, &get_tool_schema_crate_ident(), guest) } fn get_tool_schema_crate_ident() -> syn::Ident { diff --git a/golem-rust-macro/src/rpc_client_common.rs b/golem-rust-macro/src/rpc_client_common.rs index af08fb3f61..7be21c7f4d 100644 --- a/golem-rust-macro/src/rpc_client_common.rs +++ b/golem-rust-macro/src/rpc_client_common.rs @@ -14,12 +14,6 @@ //! Generator-neutral primitives shared by the agent remote-client generator //! and the tool client generator. -//! -//! Both generators turn a trait method into a call site that encodes its -//! parameters into the schema wire model, performs an RPC, and decodes the -//! result. Keeping the encode/decode primitives here is what keeps the two -//! generators' wire conventions from drifting: neither side hand-rolls the -//! positional record packing or the result graph handling. use proc_macro2::{Span, TokenStream}; use quote::quote; @@ -201,18 +195,6 @@ pub fn collect_kept_args( .collect() } -// ===================================================================== -// Input encoding -// ===================================================================== - -/// Emits a `SchemaValue::Record` expression whose positional fields are the -/// given parameter identifiers, in the order supplied by the caller. -/// -/// Each field is produced by moving the parameter into -/// `Schema::to_schema_value`, so there are no field-name strings on the wire -/// and no clones of the parameter values. The caller is responsible for -/// supplying the identifiers in the encoding order required by its carrier -/// (declaration order for agents, canonical order for tools). pub fn positional_record_schema_value(idents: &[syn::Ident], field_expect: &str) -> TokenStream { quote! { golem_rust::SchemaValue::Record { @@ -224,8 +206,6 @@ pub fn positional_record_schema_value(idents: &[syn::Ident], field_expect: &str) } } -/// Wraps a positional input record in the value-only carrier used by the agent -/// remote client: the record is encoded directly into a `schema-value-tree`. pub fn encode_value_only_carrier(record_expr: TokenStream) -> TokenStream { quote! { golem_rust::encode_schema_value(&#record_expr) @@ -233,33 +213,6 @@ pub fn encode_value_only_carrier(record_expr: TokenStream) -> TokenStream { } } -/// Wraps a positional input record together with its schema graph in the -/// self-contained `typed-schema-value` carrier used by the tool client. -#[allow(dead_code)] -pub fn encode_typed_carrier(graph_expr: TokenStream, record_expr: TokenStream) -> TokenStream { - quote! { - golem_rust::encode_typed_schema_value( - &golem_rust::TypedSchemaValue::new(#graph_expr, #record_expr) - ) - .expect("Failed to encode parameters") - } -} - -// ===================================================================== -// Result decoding (memoized schema graph) -// ===================================================================== - -/// Emits an expression evaluating to a `&'static SchemaGraph` that lazily -/// builds `build_expr` once and reuses it on every subsequent call. -/// -/// The cache is a `OnceLock` declared inline at the call site, so each -/// expansion gets its own static. This is only correct when the enclosing -/// generated method is non-generic: a block `static` inside a generic function -/// is shared across all of that function's instantiations rather than being -/// per-monomorphization, so a type-dependent `build_expr` would be cached -/// against the wrong type. Callers must therefore only use this in non-generic -/// generated methods, and it must never be hoisted into a generic runtime -/// helper where the single static would be shared across all callers. pub fn memoized_graph_access(build_expr: TokenStream) -> TokenStream { quote! { { @@ -270,12 +223,6 @@ pub fn memoized_graph_access(build_expr: TokenStream) -> TokenStream { } } -/// Emits the decoding of an RPC result `SchemaValue` (`value_expr`) into the -/// method's return type `ty`, using a memoized schema graph for the type. -/// -/// The return type's schema graph is built once and cached; the cached graph -/// is reused for each decode instead of rebuilding and revalidating it on -/// every call. pub fn decode_result_value(ty: &Type, value_expr: TokenStream) -> TokenStream { let graph = memoized_graph_access(quote! { <#ty as golem_rust::agentic::Schema>::get_type() diff --git a/golem-rust-macro/src/tool/client.rs b/golem-rust-macro/src/tool/client.rs index aeb7cb07c5..b0ac78947c 100644 --- a/golem-rust-macro/src/tool/client.rs +++ b/golem-rust-macro/src/tool/client.rs @@ -43,7 +43,7 @@ pub fn synthesize_client(ir: &ToolDefinitionIr) -> TokenStream { root_tool_name: ::std::string::String, command_path: ::std::vec::Vec<::std::string::String>, schema_path: ::std::vec::Vec<::std::string::String>, - inherited_prefix: ::std::vec::Vec, + inherited_prefix: ::std::vec::Vec, } impl #client_ident { @@ -71,7 +71,7 @@ pub fn synthesize_client(ir: &ToolDefinitionIr) -> TokenStream { root_tool_name: ::std::string::String, command_path: ::std::vec::Vec<::std::string::String>, schema_path: ::std::vec::Vec<::std::string::String>, - inherited_prefix: ::std::vec::Vec, + inherited_prefix: ::std::vec::Vec, ) -> Self { Self { rpc: golem_rust::agentic::ambient_tool_rpc::AmbientToolRpc::new(&root_tool_name), @@ -93,44 +93,53 @@ pub fn synthesize_client(ir: &ToolDefinitionIr) -> TokenStream { } } -/// The generated expression building the invocation's input record. The fast -/// path (no inherited prefix, root schema path) resolves the command's -/// canonical input model once per method through a `OnceLock`; the general -/// path recomputes it per call from the descriptor plus the inherited prefix. -/// Record assembly itself is shared runtime code in `golem_rust::agentic`. -fn input_build_expr(descriptor_fn_ident: &Ident, param_values: TokenStream) -> TokenStream { +/// Encodes the compiled canonical field order from concrete captured values. +fn input_build_expr( + ir: &ToolDefinitionIr, + cmd: &CommandIr, + tool_name: &str, + param_values: TokenStream, +) -> TokenStream { + let mut ordered = Vec::new(); + if let Some(root) = ir + .commands + .iter() + .find(|candidate| to_kebab_case(&candidate.method_ident.to_string()) == tool_name) + { + let mut globals = root + .params + .iter() + .filter(|param| is_global_param(root, param)) + .collect::>(); + globals.sort_by_key(|param| is_flag_param(root, param)); + ordered.extend( + globals + .into_iter() + .map(|param| to_kebab_case(¶m.ident.to_string())), + ); + } + let mut fields = Vec::new(); + for param in &cmd.params { + if is_principal_type(¶m.ty) || is_stream_type(¶m.ty) { + continue; + } + let order = match crate::tool::descriptor::client_surface_order(ir, cmd, param) { + Ok(order) => order, + Err(error) => return error.to_compile_error(), + }; + fields.push((order, canonical_value_name(ir, cmd, param, tool_name))); + } + fields.sort_by_key(|(order, _)| *order); + for (_, name) in fields { + if !ordered.contains(&name) { + ordered.push(name); + } + } quote! { - if __can_use_static_input_model { - static __GOLEM_TOOL_INPUT_MODEL: ::std::sync::OnceLock< - ::std::result::Result - > = - ::std::sync::OnceLock::new(); - let __model = __GOLEM_TOOL_INPUT_MODEL.get_or_init(|| { - let __tool = #descriptor_fn_ident(&mut golem_rust::agentic::ToolBuildCtx::new()) - .expect("tool descriptor build failed"); - let __command_index = __tool.command_index_by_path(&__schema_path).ok_or_else(|| { - format!("invalid generated tool command path `{}`", __schema_path.join(" ")) - })?; - __tool.canonical_input_model(__command_index) - .map_err(|__err| __err.to_string()) - }).as_ref().map_err(|__err| { - golem_rust::agentic::ToolError::Rpc(golem_rust::agentic::RpcError::Protocol(__err.clone())) - })?; - golem_rust::agentic::build_canonical_input(__model, #param_values) - .map_err(|__err| golem_rust::agentic::ToolError::Rpc(golem_rust::agentic::RpcError::Protocol(__err)))? - } else { - let __tool = #descriptor_fn_ident(&mut golem_rust::agentic::ToolBuildCtx::new()) - .expect("tool descriptor build failed"); - let __command_index = __tool.command_index_by_path(&__schema_path).ok_or_else(|| { - golem_rust::agentic::ToolError::Rpc(golem_rust::agentic::RpcError::Protocol( - format!("invalid generated tool command path `{}`", __schema_path.join(" ")) - )) - })?; - golem_rust::agentic::build_canonical_input_with_prefix( - __tool.canonical_input_fields(__command_index), - &self.inherited_prefix, - #param_values, - ) + { + let mut __values = self.inherited_prefix.clone(); + __values.extend(#param_values); + golem_rust::agentic::encode_direct_tool_input(&__values, &[#(#ordered),*]).await .map_err(|__err| golem_rust::agentic::ToolError::Rpc(golem_rust::agentic::RpcError::Protocol(__err)))? } } @@ -151,7 +160,6 @@ fn synthesize_leaf_method( omitted_names: &[String], ) -> TokenStream { let method_ident = &cmd.method_ident; - let descriptor_fn_ident = crate::tool::descriptor::descriptor_fn_ident(&ir.trait_ident); let command_name = command_name(cmd, tool_name); let command_path_part = if command_name == tool_name { quote! {} @@ -174,7 +182,7 @@ fn synthesize_leaf_method( let result_ty = client_result_type(&cmd.output, has_stdout); let decode_result = decode_client_result(&cmd.output); let invoke = invoke_call(&cmd.output, stdin_expr.clone()); - let input_expr = input_build_expr(&descriptor_fn_ident, quote! { __golem_param_values }); + let input_expr = input_build_expr(ir, cmd, tool_name, quote! { __golem_param_values }); if has_stdout { let started_ty = started_result_type(&cmd.output); @@ -183,7 +191,6 @@ fn synthesize_leaf_method( pub async fn #method_ident(&self, #(#input_args),*) -> #started_ty { #(#value_inserts)* - let __can_use_static_input_model = self.inherited_prefix.is_empty() && self.schema_path.is_empty(); let mut __command_path = self.command_path.clone(); let mut __schema_path = self.schema_path.clone(); #command_path_part @@ -197,7 +204,6 @@ fn synthesize_leaf_method( pub async fn #method_ident(&self, #(#input_args),*) -> #result_ty { #(#value_inserts)* - let __can_use_static_input_model = self.inherited_prefix.is_empty() && self.schema_path.is_empty(); let mut __command_path = self.command_path.clone(); let mut __schema_path = self.schema_path.clone(); #command_path_part @@ -262,7 +268,6 @@ fn synthesize_leaf_method_dynamic( param_values: TokenStream, ) -> TokenStream { let method_ident = &cmd.method_ident; - let descriptor_fn_ident = crate::tool::descriptor::descriptor_fn_ident(&ir.trait_ident); let command_name = command_name(cmd, tool_name); let command_path_part = if command_name == tool_name { quote! {} @@ -287,18 +292,16 @@ fn synthesize_leaf_method_dynamic( let result_ty = client_result_type(&cmd.output, has_stdout); let decode_result = decode_client_result(&cmd.output); let invoke = invoke_call(&cmd.output, stdin_expr.clone()); - let input_expr = input_build_expr(&descriptor_fn_ident, param_values.clone()); + let input_expr = input_build_expr(ir, cmd, tool_name, param_values.clone()); if has_stdout { let result_ty = started_result_type(&cmd.output); let start = start_call(&cmd.output, stdin_expr); return quote! { pub async fn #method_ident(&self #input_args) -> #result_ty { - let mut #param_values: ::std::vec::Vec<(&'static str, golem_rust::SchemaValue)> = - ::std::vec::Vec::new(); - #value_inserts + let mut #param_values: ::std::vec::Vec = + ::std::vec![#value_inserts]; - let __can_use_static_input_model = self.inherited_prefix.is_empty() && self.schema_path.is_empty(); let mut __command_path = self.command_path.clone(); let mut __schema_path = self.schema_path.clone(); #command_path_part @@ -310,11 +313,9 @@ fn synthesize_leaf_method_dynamic( quote! { pub async fn #method_ident(&self #input_args) -> #result_ty { - let mut #param_values: ::std::vec::Vec<(&'static str, golem_rust::SchemaValue)> = - ::std::vec::Vec::new(); - #value_inserts + let mut #param_values: ::std::vec::Vec = + ::std::vec![#value_inserts]; - let __can_use_static_input_model = self.inherited_prefix.is_empty() && self.schema_path.is_empty(); let mut __command_path = self.command_path.clone(); let mut __schema_path = self.schema_path.clone(); #command_path_part @@ -386,7 +387,7 @@ fn synthesize_subtree_wrapper(ir: &ToolDefinitionIr, cmd: &CommandIr) -> Option< root_tool_name: ::std::string::String, command_path: ::std::vec::Vec<::std::string::String>, schema_path: ::std::vec::Vec<::std::string::String>, - inherited_prefix: ::std::vec::Vec, + inherited_prefix: ::std::vec::Vec, _omitted: ::std::marker::PhantomData __GOLEM_OMITTED>, } @@ -635,27 +636,25 @@ fn subtree_client_macro_keep_param( quote! {} } else if is_subtree_command { let name = canonical_value_name(ir, cmd, param, tool_name); - let from_root = !cmd.params.iter().any(|own| own.ident == param.ident); - let schema = captured_schema_expr(ir, cmd, tool_name, &name, from_root); let aliases = canonical_param_aliases(ir, cmd, param, tool_name); let aliases = aliases.iter(); let short = option_char_tokens(canonical_param_short(ir, cmd, param, tool_name)); + let option_carrier = direct_input_needs_option_carrier(ir, cmd, param, tool_name); quote! { - $inherited_prefix.push(golem_rust::agentic::CanonicalInputValue { - name: #name.to_string(), - aliases: ::std::vec![#(#aliases.to_string()),*], - short: #short, - schema: #schema, - value: <#ty as golem_rust::agentic::Schema>::to_schema_value(#ident) - .expect("failed to encode tool parameter"), - }); + $inherited_prefix.push(golem_rust::agentic::DirectInputValue::new( + #name, ::std::vec![#(#aliases.to_string()),*], #short, #ident + ).with_option_carrier(#option_carrier)); } } else { let name = canonical_value_name(ir, cmd, param, tool_name); + let aliases = canonical_param_aliases(ir, cmd, param, tool_name); + let aliases = aliases.iter(); + let short = option_char_tokens(canonical_param_short(ir, cmd, param, tool_name)); + let option_carrier = direct_input_needs_option_carrier(ir, cmd, param, tool_name); quote! { - let __golem_value = <_ as golem_rust::agentic::Schema>::to_schema_value(#ident) - .expect("failed to encode tool parameter"); - $param_values.push((#name, __golem_value)); + golem_rust::agentic::DirectInputValue::new( + #name, ::std::vec![#(#aliases.to_string()),*], #short, #ident + ).with_option_carrier(#option_carrier), } }; @@ -680,34 +679,6 @@ fn subtree_client_macro_keep_param( } } -fn captured_schema_expr( - ir: &ToolDefinitionIr, - cmd: &CommandIr, - tool_name: &str, - name: &str, - from_root: bool, -) -> TokenStream { - let descriptor_fn_ident = crate::tool::descriptor::descriptor_fn_ident(&ir.trait_ident); - let source_command = command_name(cmd, tool_name); - let source_path = if from_root || source_command == tool_name { - quote! { ::std::vec::Vec::<::std::string::String>::new() } - } else { - quote! { ::std::vec![#source_command.to_string()] } - }; - quote! {{ - let __tool = #descriptor_fn_ident(&mut golem_rust::agentic::ToolBuildCtx::new()) - .expect("tool descriptor build failed"); - let __source_path = #source_path; - let __source_index = __tool.node_index_by_path(&__source_path) - .expect("captured tool command must exist in its descriptor"); - __tool.canonical_input_fields(__source_index) - .into_iter() - .find(|field| field.name == #name) - .expect("captured tool field must exist in its descriptor") - .schema - }} -} - fn subtree_client_macro_omit_param( macro_ident: &Ident, next_state: &Ident, @@ -777,18 +748,20 @@ fn value_inserts( } let ident = ¶m.ident; let name = canonical_value_name(ir, cmd, param, tool_name); + let aliases = canonical_param_aliases(ir, cmd, param, tool_name); + let aliases = aliases.iter(); + let short = option_char_tokens(canonical_param_short(ir, cmd, param, tool_name)); + let option_carrier = direct_input_needs_option_carrier(ir, cmd, param, tool_name); Some(quote! { - let __golem_value = <_ as golem_rust::agentic::Schema>::to_schema_value(#ident) - .expect("failed to encode tool parameter"); - __golem_param_values.push((#name, __golem_value)); + golem_rust::agentic::DirectInputValue::new( + #name, ::std::vec![#(#aliases.to_string()),*], #short, #ident + ).with_option_carrier(#option_carrier) }) }); let inserts: Vec<_> = inserts.collect(); - let capacity = inserts.len(); vec![quote! { - let mut __golem_param_values: ::std::vec::Vec<(&'static str, golem_rust::SchemaValue)> = - ::std::vec::Vec::with_capacity(#capacity); - #(#inserts)* + let mut __golem_param_values: ::std::vec::Vec = + ::std::vec![#(#inserts),*]; }] } @@ -986,7 +959,7 @@ fn prefix_value_builders( inherited .into_iter() .chain(current) - .filter_map(|(param, from_root)| { + .filter_map(|(param, _from_root)| { if is_principal_type(¶m.ty) || is_stream_type(¶m.ty) { return None; } @@ -997,21 +970,15 @@ fn prefix_value_builders( return None; } let ident = ¶m.ident; - let ty = ¶m.ty; let name = canonical_value_name(ir, cmd, param, tool_name); - let schema = captured_schema_expr(ir, cmd, tool_name, &name, from_root); let aliases = canonical_param_aliases(ir, cmd, param, tool_name); let aliases = aliases.iter(); let short = option_char_tokens(canonical_param_short(ir, cmd, param, tool_name)); + let option_carrier = direct_input_needs_option_carrier(ir, cmd, param, tool_name); Some(quote! { - __inherited_prefix.push(golem_rust::agentic::CanonicalInputValue { - name: #name.to_string(), - aliases: ::std::vec![#(#aliases.to_string()),*], - short: #short, - schema: #schema, - value: <#ty as golem_rust::agentic::Schema>::to_schema_value(#ident) - .expect("failed to encode tool parameter"), - }); + __inherited_prefix.push(golem_rust::agentic::DirectInputValue::new( + #name, ::std::vec![#(#aliases.to_string()),*], #short, #ident + ).with_option_carrier(#option_carrier)); }) }) .collect() @@ -1083,6 +1050,45 @@ pub(crate) fn canonical_value_name( own_name } +fn canonical_param_source<'a>( + ir: &'a ToolDefinitionIr, + cmd: &'a CommandIr, + param: &'a ParamIr, + tool_name: &str, +) -> (&'a CommandIr, &'a ParamIr) { + let own_name = to_kebab_case(¶m.ident.to_string()); + if let Some(root) = ir + .commands + .iter() + .find(|candidate| to_kebab_case(&candidate.method_ident.to_string()) == tool_name) + { + for root_param in &root.params { + if is_global_param(root, root_param) + && param_surfaces_intersect( + &to_kebab_case(&root_param.ident.to_string()), + ¶m_aliases(root, root_param), + &own_name, + ¶m_aliases(cmd, param), + ) + { + return (root, root_param); + } + } + } + (cmd, param) +} + +fn direct_input_needs_option_carrier( + ir: &ToolDefinitionIr, + cmd: &CommandIr, + param: &ParamIr, + tool_name: &str, +) -> bool { + let (source_cmd, source_param) = canonical_param_source(ir, cmd, param, tool_name); + crate::tool::descriptor::canonical_field_has_option_carrier(ir, source_cmd, source_param) + .unwrap_or(false) +} + fn canonical_param_aliases( ir: &ToolDefinitionIr, cmd: &CommandIr, @@ -1236,33 +1242,35 @@ fn start_call(output: &ReturnType, stdin_expr: TokenStream) -> TokenStream { let (ok, err) = split_result(output); let decode = match ok { Some(ok) => { - quote! { |__result| golem_rust::agentic::decode_result_value::<#ok, _>(__result) } + quote! { |__result| golem_rust::agentic::decode_direct_result_value::<#ok, _>(__result) } } - None => quote! { |__result| golem_rust::agentic::decode_result_empty(__result) }, + None => quote! { |__result| golem_rust::agentic::decode_direct_result_empty(__result) }, }; match err { Some(err) => quote! { { - fn __golem_assert_tool_error_decodable() {} + fn __golem_assert_tool_error_decodable() {} __golem_assert_tool_error_decodable::<#err>(); - golem_rust::agentic::start_tool_invocation( + golem_rust::agentic::start_tool_invocation_direct_input( &self.rpc, &__command_path, - &__input, + __input, #stdin_expr, #decode, - golem_rust::agentic::decode_declared_tool_error::<#err>, + <#err as golem_rust::agentic::DirectToolError>::recognizes_error_name, + <#err as golem_rust::agentic::DirectToolError>::from_direct_error_reader, ) } }, None => quote! { - golem_rust::agentic::start_tool_invocation( + golem_rust::agentic::start_tool_invocation_direct_input( &self.rpc, &__command_path, - &__input, + __input, #stdin_expr, #decode, - |_, _| ::std::result::Result::Ok(::std::option::Option::None), + |_| false, + |_, _, _| ::std::result::Result::Ok(::std::option::Option::None), ) }, } @@ -1273,23 +1281,22 @@ fn invoke_call(output: &ReturnType, stdin_expr: TokenStream) -> TokenStream { match err { Some(err) => quote! { { - fn __golem_assert_tool_error_decodable() {} + fn __golem_assert_tool_error_decodable() {} __golem_assert_tool_error_decodable::<#err>(); - golem_rust::agentic::invoke_and_await( + golem_rust::agentic::invoke_and_await_direct::<#err, _>( &self.rpc, &__command_path, - &__input, + __input, (#stdin_expr).map(golem_rust::agentic::pump_tool_stdin), ::std::option::Option::None, - golem_rust::agentic::decode_declared_tool_error::<#err>, ).await } }, None => quote! { - golem_rust::agentic::invoke_and_await_infallible( + golem_rust::agentic::invoke_and_await_direct_infallible( &self.rpc, &__command_path, - &__input, + __input, (#stdin_expr).map(golem_rust::agentic::pump_tool_stdin), ::std::option::Option::None, ).await @@ -1303,10 +1310,10 @@ fn decode_client_result(output: &ReturnType) -> TokenStream { let (ok, _) = split_result(output); match ok { Some(ok) => quote! { - golem_rust::agentic::decode_result_value::<#ok, _>(__result) + golem_rust::agentic::decode_direct_result_value::<#ok, _>(__result) }, None => quote! { - golem_rust::agentic::decode_result_empty(__result) + golem_rust::agentic::decode_direct_result_empty(__result) }, } } diff --git a/golem-rust-macro/src/tool/definition.rs b/golem-rust-macro/src/tool/definition.rs index 12b256c9ff..3658921fd1 100644 --- a/golem-rust-macro/src/tool/definition.rs +++ b/golem-rust-macro/src/tool/definition.rs @@ -94,10 +94,18 @@ pub fn tool_definition_impl( Ok(tokens) => resolve_sdk(tokens), Err(err) => return err.to_compile_error().into(), }; + let wire_descriptor_fn = match crate::tool::descriptor::synthesize_wire_descriptor_fn(&ir) { + Ok(tokens) => resolve_sdk(tokens), + Err(err) => return err.to_compile_error().into(), + }; + let wire_fn_ident = + crate::tool::descriptor::standalone_wire_descriptor_fn_ident(&ir.trait_ident); let client = resolve_sdk(crate::tool::client::synthesize_client(&ir)); let middleware_surface = resolve_sdk(crate::tool::middleware_surface::synthesize_middleware_surface(&ir)); - let descriptor_fn_ident = crate::tool::descriptor::descriptor_fn_ident(&ir.trait_ident); + let descriptor_fn_ident = + crate::tool::descriptor::standalone_descriptor_fn_ident(&ir.trait_ident); + let prepared_fn_ident = crate::tool::descriptor::prepared_descriptor_fn_ident(&ir.trait_ident); strip_helper_attrs(&mut item_trait); @@ -109,7 +117,7 @@ pub fn tool_definition_impl( where Self: Sized, { - #descriptor_fn_ident(&mut golem_rust::agentic::ToolBuildCtx::new()) + #descriptor_fn_ident() .expect("tool descriptor build failed") } }; @@ -119,6 +127,41 @@ pub fn tool_definition_impl( }; item_trait.items.push(descriptor_item); + let prepared_item = quote! { + #[doc(hidden)] + fn __tool_prepared_descriptor() -> golem_rust::agentic::PreparedToolDescriptor + where + Self: Sized, + { + #prepared_fn_ident().expect("tool descriptor build failed") + } + }; + match syn::parse2::(resolve_sdk(prepared_item)) { + Ok(item) => item_trait.items.push(item), + Err(error) => return error.into_compile_error().into(), + } + + let tool_name = to_kebab_case(&ir.trait_ident.to_string()); + item_trait.items.push(syn::parse_quote! { + #[doc(hidden)] + fn __tool_name() -> &'static str where Self: Sized { #tool_name } + }); + let wire_item = quote! { + #[doc(hidden)] + fn __tool_wire_descriptor() -> golem_rust::schema::tool::wit::wire::Tool + where Self: Sized, + { + let schema = golem_rust::agentic::WireToolSchema::default(); + let descriptor = #wire_fn_ident(&schema) + .expect("tool descriptor build failed"); + descriptor.into_wire(schema).expect("tool descriptor lowering failed") + } + }; + match syn::parse2::(resolve_sdk(wire_item)) { + Ok(item) => item_trait.items.push(item), + Err(error) => return error.into_compile_error().into(), + } + let method_paths = tool_method_paths(&ir); let method_paths_item: TraitItem = syn::parse_quote! { #[doc(hidden)] @@ -183,6 +226,7 @@ pub fn tool_definition_impl( #item_trait #descriptor_fn + #wire_descriptor_fn #client @@ -363,9 +407,27 @@ fn tool_subtree_paths(ir: &ToolDefinitionIr) -> Vec { .collect() } -fn synthesize_tool_invokers(ir: &ToolDefinitionIr) -> [proc_macro2::TokenStream; 4] { +fn synthesize_tool_invokers(ir: &ToolDefinitionIr) -> [proc_macro2::TokenStream; 3] { let invoke_arms = synthesize_invoke_arms(ir); - let decode_ident = fresh_tool_trait_method_ident(ir, "__tool_decode_invocation"); + let subtree_aliases = ir.commands.iter().filter(|command| command.subtree.is_some()).flat_map(|command| { + let command_name = command.name_override.clone() + .unwrap_or_else(|| to_kebab_case(&command.method_ident.to_string())); + let paths = ::std::iter::once(&command_name).chain(&command.aliases) + .map(|name| quote! { __command_path.first().is_some_and(|segment| segment == #name) }) + .collect::>(); + command.params.iter().filter_map(|param| { + let aliases = param_aliases(command, param); + if aliases.is_empty() { return None; } + let name = to_kebab_case(¶m.ident.to_string()); + Some(quote! { + if false #(|| #paths)* { + golem_rust::agentic::DirectToolInput::add_root_aliases( + &mut __input.graph, #name, &[#(#aliases),*], + ).map_err(golem_rust::golem_agentic::exports::golem::tool::guest::ToolError::InvalidInput)?; + } + }) + }).collect::>() + }).collect::>(); let instance_ident = fresh_tool_trait_method_ident(ir, "__tool_invoke_on"); let decoded_ident = fresh_tool_trait_method_ident(ir, "__tool_invoke_decoded"); let entry = quote! { @@ -381,8 +443,6 @@ fn synthesize_tool_invokers(ir: &ToolDefinitionIr) -> [proc_macro2::TokenStream; Self: Sized + 'static, { ::std::boxed::Box::pin(async move { - let (__tool, __command_index, __input_graph, __input_fields) = - Self::#decode_ident(&__command_path, __input)?; if ::std::mem::size_of::() != 0 { return ::std::result::Result::Err( golem_rust::golem_agentic::exports::golem::tool::guest::ToolError::InvalidInput( @@ -396,10 +456,7 @@ fn synthesize_tool_invokers(ir: &ToolDefinitionIr) -> [proc_macro2::TokenStream; __impl .#decoded_ident( __command_path, - __tool, - __command_index, - __input_graph, - __input_fields, + __input, __stdin, __stdout, __principal, @@ -409,43 +466,6 @@ fn synthesize_tool_invokers(ir: &ToolDefinitionIr) -> [proc_macro2::TokenStream; } }; - let decode = quote! { - #[doc(hidden)] - fn #decode_ident( - __command_path: &[::std::string::String], - __input: golem_rust::golem_agentic::exports::golem::tool::guest::TypedSchemaValue, - ) -> ::std::result::Result< - ( - golem_rust::agentic::ExtendedToolType, - usize, - golem_rust::SchemaGraph, - ::std::vec::Vec, - ), - golem_rust::golem_agentic::exports::golem::tool::guest::ToolError, - > - where - Self: Sized, - { - let __tool = Self::__tool_descriptor(); - let __command_index = __tool.command_index_by_path(__command_path).ok_or_else(|| { - golem_rust::golem_agentic::exports::golem::tool::guest::ToolError::InvalidCommandPath( - __command_path.to_vec() - ) - })?; - let __input = golem_rust::decode_typed_schema_value_owned(__input) - .map_err(|__err| golem_rust::golem_agentic::exports::golem::tool::guest::ToolError::InvalidInput(__err.to_string()))?; - let (__input_graph, __input_value) = __input.into_parts(); - let __input_fields = __tool.decode_canonical_input_record(__command_index, __input_value) - .map_err(|__err| golem_rust::golem_agentic::exports::golem::tool::guest::ToolError::InvalidInput(__err.to_string()))?; - ::std::result::Result::Ok(( - __tool, - __command_index, - __input_graph, - __input_fields, - )) - } - }; - let instance = quote! { #[doc(hidden)] fn #instance_ident<'a>( @@ -460,14 +480,9 @@ fn synthesize_tool_invokers(ir: &ToolDefinitionIr) -> [proc_macro2::TokenStream; Self: Sized + 'a, { ::std::boxed::Box::pin(async move { - let (__tool, __command_index, __input_graph, __input_fields) = - Self::#decode_ident(&__command_path, __input)?; self.#decoded_ident( __command_path, - __tool, - __command_index, - __input_graph, - __input_fields, + __input, __stdin, __stdout, __principal, @@ -482,10 +497,7 @@ fn synthesize_tool_invokers(ir: &ToolDefinitionIr) -> [proc_macro2::TokenStream; fn #decoded_ident<'a>( &'a self, __command_path: ::std::vec::Vec<::std::string::String>, - __tool: golem_rust::agentic::ExtendedToolType, - __command_index: usize, - __input_graph: golem_rust::SchemaGraph, - mut __input_fields: ::std::vec::Vec, + __input: golem_rust::golem_agentic::exports::golem::tool::guest::TypedSchemaValue, mut __stdin: ::std::option::Option, mut __stdout: ::std::option::Option, __principal: golem_rust::golem_agentic::golem::agent::common::Principal, @@ -494,15 +506,13 @@ fn synthesize_tool_invokers(ir: &ToolDefinitionIr) -> [proc_macro2::TokenStream; Self: Sized + 'a, { ::std::boxed::Box::pin(async move { - async fn __encode_success_value( + async fn __encode_success_value( __value: &T, ) -> ::std::result::Result< golem_rust::golem_agentic::exports::golem::tool::guest::InvocationResult, golem_rust::golem_agentic::exports::golem::tool::guest::ToolError, > { - let __value = golem_rust::IntoTypedSchemaValue::into_typed_schema_value(__value) - .map_err(|__err| golem_rust::golem_agentic::exports::golem::tool::guest::ToolError::InvalidResult(__err.to_string()))?; - let __value = golem_rust::encode_typed_schema_value_async(&__value).await + let __value = golem_rust::agentic::encode_direct_tool_value(__value).await .map_err(|__err| golem_rust::golem_agentic::exports::golem::tool::guest::ToolError::InvalidResult(__err.to_string()))?; ::std::result::Result::Ok(golem_rust::golem_agentic::exports::golem::tool::guest::InvocationResult { result: ::std::option::Option::Some(__value), @@ -520,15 +530,13 @@ fn synthesize_tool_invokers(ir: &ToolDefinitionIr) -> [proc_macro2::TokenStream; }) } - async fn __encode_custom_error( + async fn __encode_custom_error( __error: &T, ) -> ::std::result::Result< golem_rust::golem_agentic::exports::golem::tool::guest::ToolError, golem_rust::golem_agentic::exports::golem::tool::guest::ToolError, > { - let (__name, __value) = golem_rust::agentic::ToolErrorSchema::to_error_payload_value(__error) - .map_err(|__err| golem_rust::golem_agentic::exports::golem::tool::guest::ToolError::InvalidResult(__err.to_string()))?; - let __value = golem_rust::encode_typed_schema_value_async(&__value).await + let (__name, __value) = golem_rust::agentic::DirectToolError::direct_error_payload(__error).await .map_err(|__err| golem_rust::golem_agentic::exports::golem::tool::guest::ToolError::InvalidResult(__err.to_string()))?; ::std::result::Result::Ok( golem_rust::golem_agentic::exports::golem::tool::guest::ToolError::CustomError( @@ -541,6 +549,9 @@ fn synthesize_tool_invokers(ir: &ToolDefinitionIr) -> [proc_macro2::TokenStream; } let __impl = self; + let mut __input = __input; + #(#subtree_aliases)* + let mut __input_fields = ::std::option::Option::Some(__input); #(#invoke_arms)* @@ -551,57 +562,9 @@ fn synthesize_tool_invokers(ir: &ToolDefinitionIr) -> [proc_macro2::TokenStream; let __subtool_path = __command_path[__subtree_path.len()..].to_vec(); let __subtool_invoker = golem_rust::agentic::get_tool_invoker_by_name(__subtool_name) .ok_or_else(|| golem_rust::golem_agentic::exports::golem::tool::guest::ToolError::InvalidToolName((*__subtool_name).to_string()))?; - let __subtool_input = if let ::std::option::Option::Some(__subtool) = golem_rust::agentic::get_extended_tool_by_name(__subtool_name) { - let __subtool_command_index = __subtool.command_index_by_path(&__subtool_path).ok_or_else(|| { - golem_rust::golem_agentic::exports::golem::tool::guest::ToolError::InvalidCommandPath( - __command_path.clone() - ) - })?; - let __subtool_fields = __subtool.canonical_input_fields(__subtool_command_index); - let __subtool_model = golem_rust::agentic::CanonicalInputModel::from_fields(__subtool_fields.clone()) - .map_err(|__err| golem_rust::golem_agentic::exports::golem::tool::guest::ToolError::InvalidInput(__err.to_string()))?; - let mut __subtool_record_fields = ::std::vec::Vec::new(); - for __field in __subtool_fields.into_iter() { - let __value_index = __input_fields.iter() - .position(|__input_field| { - __input_field.name == __field.name - || __input_field.aliases.iter().any(|__alias| __alias == &__field.name) - || __field.aliases.iter().any(|__alias| { - __input_field.name == *__alias - || __input_field.aliases.iter().any(|__input_alias| __input_alias == __alias) - }) - }) - .ok_or_else(|| { - golem_rust::golem_agentic::exports::golem::tool::guest::ToolError::InvalidInput( - format!("missing canonical tool input field `{}`", __field.name) - ) - })?; - let __value = golem_rust::agentic::adapt_canonical_input_value( - __input_fields.remove(__value_index), - &__field.name, - &__field.schema, - ) - .map_err( - golem_rust::golem_agentic::exports::golem::tool::guest::ToolError::InvalidInput - )?; - __subtool_record_fields.push(__value); - } - golem_rust::TypedSchemaValue::new( - __subtool_model.record_schema, - golem_rust::SchemaValue::Record { fields: __subtool_record_fields }, - ) - } else { - golem_rust::TypedSchemaValue::new( - __input_graph, - golem_rust::SchemaValue::Record { - fields: __input_fields.into_iter().map(|__field| __field.value).collect(), - }, - ) - }; return __subtool_invoker( __subtool_path, - golem_rust::encode_typed_schema_value_async(&__subtool_input).await - .map_err(|__err| golem_rust::golem_agentic::exports::golem::tool::guest::ToolError::InvalidInput(__err.to_string()))?, + __input_fields.take().expect("tool input is forwarded once"), __stdin, __stdout, __principal, @@ -616,7 +579,7 @@ fn synthesize_tool_invokers(ir: &ToolDefinitionIr) -> [proc_macro2::TokenStream; } }; - [entry, decode, instance, decoded] + [entry, instance, decoded] } fn fresh_tool_trait_method_ident(ir: &ToolDefinitionIr, preferred: &str) -> syn::Ident { @@ -639,7 +602,18 @@ fn synthesize_invoke_arms(ir: &ToolDefinitionIr) -> Vec Vec Vec::get_type() - .get_schema_graph() - .expect("tool parameter must have a concrete schema graph"); - let __value = golem_rust::agentic::adapt_canonical_input_value( - __field, - #value_name, - &__expected_graph, - ) - .map_err( - golem_rust::golem_agentic::exports::golem::tool::guest::ToolError::InvalidInput - )?; - <#ty as golem_rust::FromSchema>::from_value(&__value) - .map_err(|__err| golem_rust::golem_agentic::exports::golem::tool::guest::ToolError::InvalidInput(__err.to_string()))? - }; + let #ident = #direct_input.take_any_adapted::<#ty>(&[#value_name, #(#value_aliases),*]) + .map_err(golem_rust::golem_agentic::exports::golem::tool::guest::ToolError::InvalidInput)?; } } }); @@ -743,9 +697,15 @@ fn synthesize_invoke_arms(ir: &ToolDefinitionIr) -> Vec syn:: } fn command_match_arm( - method_name: &str, + ir: &ToolDefinitionIr, + command: &CommandIr, body: proc_macro2::TokenStream, ) -> proc_macro2::TokenStream { + let tool_name = to_kebab_case(&ir.trait_ident.to_string()); + let method_name = command.method_ident.to_string(); + let command_name = if to_kebab_case(&method_name) == tool_name { + tool_name + } else { + command + .name_override + .clone() + .unwrap_or_else(|| to_kebab_case(&method_name)) + }; + let paths = if command_name == to_kebab_case(&ir.trait_ident.to_string()) { + vec![quote! { __command_path.is_empty() }] + } else { + ::std::iter::once(&command_name) + .chain(command.aliases.iter()) + .map(|name| quote! { __command_path.as_slice() == [#name] }) + .collect() + }; quote! { - let __method_command_index = Self::__tool_invoke_method_paths() - .iter() - .find_map(|(__name, __path)| { - if *__name == #method_name { - let __path = __path.iter().map(|__segment| __segment.to_string()).collect::<::std::vec::Vec<_>>(); - __tool.command_index_by_path(&__path) - } else { - ::std::option::Option::None - } - }); - if __method_command_index == ::std::option::Option::Some(__command_index) { + if false #(|| #paths)* { #body } } diff --git a/golem-rust-macro/src/tool/descriptor.rs b/golem-rust-macro/src/tool/descriptor.rs index 023679c268..7692191255 100644 --- a/golem-rust-macro/src/tool/descriptor.rs +++ b/golem-rust-macro/src/tool/descriptor.rs @@ -40,10 +40,112 @@ pub fn descriptor_fn_ident(trait_ident: &Ident) -> Ident { format_ident!("__golem_tool_descriptor_for_{}", trait_ident) } +pub fn standalone_descriptor_fn_ident(trait_ident: &Ident) -> Ident { + format_ident!("__golem_standalone_tool_descriptor_for_{}", trait_ident) +} + +pub fn prepared_descriptor_fn_ident(trait_ident: &Ident) -> Ident { + format_ident!("__golem_prepared_tool_descriptor_for_{}", trait_ident) +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum DescriptorRepr { + Dynamic, + Wire, +} + +pub fn wire_descriptor_fn_ident(trait_ident: &Ident) -> Ident { + format_ident!("__golem_wire_tool_descriptor_for_{}", trait_ident) +} + +pub fn standalone_wire_descriptor_fn_ident(trait_ident: &Ident) -> Ident { + format_ident!( + "__golem_standalone_wire_tool_descriptor_for_{}", + trait_ident + ) +} + +fn needs_composition(ir: &ToolDefinitionIr) -> bool { + ir.commands.iter().any(|cmd| { + cmd.subtree.is_some() + || cmd + .args + .iter() + .any(|arg| arg.placement == Some(ArgPlacement::Global)) + || cmd.constraints.iter().any(constraint_has_value_is) + }) +} + +pub fn synthesize_wire_descriptor_fn(ir: &ToolDefinitionIr) -> Result { + let plan = Plan::analyze(ir)?; + let ident = wire_descriptor_fn_ident(&ir.trait_ident); + let standalone_ident = standalone_wire_descriptor_fn_ident(&ir.trait_ident); + let trait_name = ir.trait_ident.to_string(); + let root = build_root_node(ir, &plan, DescriptorRepr::Wire)?; + let links = ir + .commands + .iter() + .enumerate() + .filter(|(index, _)| Some(*index) != plan.root_idx) + .map(|(_, cmd)| { + if cmd.subtree.is_some() { + build_subtree_link(cmd, &plan, DescriptorRepr::Wire) + } else { + build_leaf_link(cmd, &plan, DescriptorRepr::Wire) + } + }) + .collect::, _>>()?; + let version = match &ir.version { + Some(v) => quote! { #v.to_string() }, + None => quote! { env!("CARGO_PKG_VERSION").to_string() }, + }; + let standalone = if needs_composition(ir) { + quote! { #ident(&mut golem_rust::agentic::ToolBuildCtx::new(), __golem_wire_schema) } + } else { + quote! { + #[allow(unused_mut)] + let mut commands = ::std::vec![#root]; + #(#links)* + ::std::result::Result::Ok(golem_rust::agentic::ExtendedToolType { version: #version, commands }) + } + }; + Ok(quote! { + #[doc(hidden)] + #[allow(non_snake_case)] + pub fn #standalone_ident(__golem_wire_schema: &golem_rust::agentic::WireToolSchema) + -> ::std::result::Result, golem_rust::agentic::ToolBuildError> { + #standalone + } + + #[doc(hidden)] + #[allow(non_snake_case)] + pub fn #ident( + ctx: &mut golem_rust::agentic::ToolBuildCtx, + __golem_wire_schema: &golem_rust::agentic::WireToolSchema, + ) -> ::std::result::Result, golem_rust::agentic::ToolBuildError> { + ctx.with_descriptor(concat!(module_path!(), "::", #trait_name), |ctx| { + #[allow(unused_mut)] + let mut commands = vec![#root]; + ctx.apply_pending_graft_root(&mut commands[0])?; + let name = commands[0].name.clone(); + golem_rust::agentic::reconcile_command_inherited_globals(&mut commands[0], ctx.inherited_globals(), &name)?; + #(#links)* + let mut tool = golem_rust::agentic::ExtendedToolType { version: #version, commands }; + if ctx.is_outermost_descriptor() { + golem_rust::agentic::normalize_inherited_globals(&mut tool)?; + } + Ok(tool) + }) + } + }) +} + /// Emits the module-level `__golem_tool_descriptor_for_` free function. pub fn synthesize_descriptor_fn(ir: &ToolDefinitionIr) -> Result { let plan = Plan::analyze(ir)?; let fn_ident = descriptor_fn_ident(&ir.trait_ident); + let standalone_ident = standalone_descriptor_fn_ident(&ir.trait_ident); + let prepared_ident = prepared_descriptor_fn_ident(&ir.trait_ident); let trait_name = ir.trait_ident.to_string(); let version = match &ir.version { @@ -52,7 +154,7 @@ pub fn synthesize_descriptor_fn(ir: &ToolDefinitionIr) -> Result Result ::std::result::Result< + golem_rust::agentic::ExtendedToolType, + golem_rust::agentic::ToolBuildError, + > { + #standalone + } + + #[doc(hidden)] + #[allow(non_snake_case)] + pub fn #prepared_ident() -> ::std::result::Result< + golem_rust::agentic::PreparedToolDescriptor, + golem_rust::agentic::ToolBuildError, + > { + let __tool = #standalone_ident()?; + #prepare + } + #[doc(hidden)] #[allow(non_snake_case)] pub fn #fn_ident( @@ -110,6 +255,62 @@ pub fn synthesize_descriptor_fn(ir: &ToolDefinitionIr) -> Result bool { + let has = |refs: &[RefIr]| refs.iter().any(|r| matches!(r, RefIr::ValueIs { .. })); + match constraint { + ConstraintIr::RequiresAll(refs) + | ConstraintIr::AllOrNone(refs) + | ConstraintIr::RequiresAny(refs) => has(refs), + ConstraintIr::MutexGroups(groups) => groups.iter().any(|refs| has(refs)), + ConstraintIr::Implies { lhs, rhs, .. } | ConstraintIr::Forbids { lhs, rhs, .. } => { + has(lhs) || has(rhs) + } + } +} + +fn schema_checks(ir: &ToolDefinitionIr) -> TokenStream { + // Refinement attributes change the graph after Rust's Schema implementation + // produced it. Error schemas are supplied by an independent user trait. + if ir.commands.iter().any(|cmd| { + split_result(&cmd.output).1.is_some() + || cmd.args.iter().any(|arg| { + arg.regex.is_some() + || arg.min_length.is_some() + || arg.max_length.is_some() + || arg.path_kind.is_some() + || arg.direction.is_some() + || arg.mime.is_some() + || arg.schemes.is_some() + || arg.raw_min.is_some() + || arg.raw_max.is_some() + || arg.bounds.is_some() + || arg.unit.is_some() + }) + }) { + return quote! { golem_rust::agentic::ToolSchemaChecks::dynamic() }; + } + let mut types = Vec::new(); + for cmd in &ir.commands { + for param in &cmd.params { + if is_auto_injected_principal_type(¶m.ty) || is_stream_type(¶m.ty) { + continue; + } + types.push(unwrap_generic1(¶m.ty, "Option").unwrap_or(¶m.ty)); + } + if let Some(ty) = split_result(&cmd.output).0 { + types.push(ty); + } + } + quote! {{ + use golem_rust::agentic::SelectToolSchemaChecks as _; + let checks: &[::std::option::Option] = &[ + #((&golem_rust::agentic::ToolSchemaProbe::<#types>(::std::marker::PhantomData)).tool_schema_checks()),* + ]; + checks.iter().flatten().copied().next() + .unwrap_or_else(golem_rust::agentic::ToolSchemaChecks::scalar) + }} +} + /// Macro-time facts derived from the trait, with all divergence rules checked. struct Plan { tool_name: String, @@ -257,10 +458,14 @@ fn repeats_inherited_global( /// Builds the index-0 root command node. With an implicit-body method the root /// is a full command (its globals + body); otherwise it is a pure dispatcher. -fn build_root_node(ir: &ToolDefinitionIr, plan: &Plan) -> Result { +fn build_root_node( + ir: &ToolDefinitionIr, + plan: &Plan, + repr: DescriptorRepr, +) -> Result { if let Some(i) = plan.root_idx { let cmd = &ir.commands[i]; - build_command_node(cmd, &plan.tool_name, true, &BTreeSet::new()) + build_command_node(cmd, &plan.tool_name, true, &BTreeSet::new(), repr) } else { let name = &plan.tool_name; let doc = doc_tokens(&ir.doc); @@ -278,8 +483,12 @@ fn build_root_node(ir: &ToolDefinitionIr, plan: &Plan) -> Result Result { - let node = build_command_node(cmd, &plan.tool_name, false, &plan.root_global_names)?; +fn build_leaf_link( + cmd: &CommandIr, + plan: &Plan, + repr: DescriptorRepr, +) -> Result { + let node = build_command_node(cmd, &plan.tool_name, false, &plan.root_global_names, repr)?; Ok(quote! { { let __idx = commands.len() as i32; @@ -293,9 +502,21 @@ fn build_leaf_link(cmd: &CommandIr, plan: &Plan) -> Result { /// links it. The subtree method's params are passed as `parent_globals` so /// `graft_subtree` reconciles the grafted root's body/globals against them and /// prepends them as propagating globals for descendant subcommands. -fn build_subtree_link(cmd: &CommandIr, plan: &Plan) -> Result { +fn build_subtree_link( + cmd: &CommandIr, + plan: &Plan, + repr: DescriptorRepr, +) -> Result { let subtree = cmd.subtree.as_ref().expect("subtree present"); let call_path = subtree_call_path(&subtree.path)?; + let call = if repr == DescriptorRepr::Wire { + let mut path = subtree.path.clone(); + path.segments.last_mut().unwrap().ident = + wire_descriptor_fn_ident(&path.segments.last().unwrap().ident); + quote! { #path(ctx, __golem_wire_schema) } + } else { + quote! { #call_path(ctx) } + }; let expected_name = command_name(cmd, &plan.tool_name, false); let grafted_name = subtree .name_override @@ -333,7 +554,7 @@ fn build_subtree_link(cmd: &CommandIr, plan: &Plan) -> Result opts.push(spec), Projection::Flag(spec) => flags.push(spec), _ => { @@ -378,7 +599,7 @@ fn build_subtree_link(cmd: &CommandIr, plan: &Plan) -> Result Result, + repr: DescriptorRepr, ) -> Result { let name = command_name(cmd, tool_name, is_root); let doc = doc_tokens(&cmd.doc); @@ -559,7 +781,7 @@ fn build_command_node( && last_non_inherited_idx != last_value_idx && arg.and_then(|a| a.placement).is_none() && vec_tail_representable(¶m.ty, arg)); - match classify(¶m.ident, ¶m.ty, arg, is_global, emit_as_tail)? { + match classify(¶m.ident, ¶m.ty, arg, is_global, emit_as_tail, repr)? { Projection::Stdin(spec) => { if stdin.is_some() { return Err(Error::new( @@ -654,7 +876,9 @@ fn build_command_node( })?; let name = to_kebab_case(¶m.ident.to_string()); body_option_decls.push((idx, name.clone())); - body_options.push(inherited_tail_option_surrogate_tokens(&name, item, arg)?); + body_options.push(inherited_tail_option_surrogate_tokens( + &name, item, arg, repr, + )?); continue; } if tail.is_some() { @@ -669,9 +893,9 @@ fn build_command_node( } let constraints = build_constraints(cmd)?; - let (result_spec, errors) = build_result(cmd)?; + let (result_spec, errors) = build_result(cmd, repr)?; let annotations = build_annotations(cmd.annotations.as_ref()); - let positional_plan = build_positional_plan(cmd, &body_option_decls)?; + let positional_plan = build_positional_plan(cmd, &body_option_decls, repr)?; let tail_tokens = match tail { Some(t) => quote! { ::std::option::Option::Some(#t) }, @@ -745,6 +969,7 @@ fn build_command_node( fn build_positional_plan( cmd: &CommandIr, body_option_decls: &[(usize, String)], + repr: DescriptorRepr, ) -> Result, Error> { let mut plan = Vec::new(); for (idx, param) in cmd.params.iter().enumerate() { @@ -801,7 +1026,7 @@ fn build_positional_plan( // and use the reconstruct-from-spec path. let authored_tail_surrogate_tok = match (explicit_tail, vec_item) { (true, Some(item)) => { - let tail_spec = tail_tokens(&name, item, arg)?; + let tail_spec = tail_tokens(&name, item, arg, repr)?; quote! { ::std::option::Option::Some(::std::boxed::Box::new(#tail_spec)) } } _ => quote! { ::std::option::Option::None }, @@ -852,7 +1077,7 @@ fn vec_tail_spec( if let Some(arg) = arg { reject_unconsumed_structural_attrs(arg, SurfaceKind::Tail)?; } - tail_tokens(name, item, arg) + tail_tokens(name, item, arg, DescriptorRepr::Dynamic) } /// Whether a parameter is eligible to become a positional (fixed or tail) in the @@ -900,6 +1125,109 @@ enum Projection { Stdout(TokenStream), } +pub(crate) fn client_surface_order( + ir: &ToolDefinitionIr, + cmd: &CommandIr, + param: &crate::tool::ir::ParamIr, +) -> Result { + let plan = Plan::analyze(ir)?; + let is_root = to_kebab_case(&cmd.method_ident.to_string()) == plan.tool_name; + let arg = arg_for(cmd, ¶m.ident); + let global = arg.and_then(|arg| arg.placement) == Some(ArgPlacement::Global); + let last = cmd + .params + .iter() + .rev() + .find(|candidate| is_positional_candidate(candidate, arg_for(cmd, &candidate.ident))); + let last_non_inherited = cmd.params.iter().rev().find(|candidate| { + let candidate_arg = arg_for(cmd, &candidate.ident); + is_positional_candidate(candidate, candidate_arg) + && (is_root + || !repeats_inherited_global( + &candidate.ident, + candidate_arg, + &plan.root_global_names, + )) + }); + let tail = last.is_some_and(|candidate| candidate.ident == param.ident) + || (last_non_inherited.is_some_and(|candidate| candidate.ident == param.ident) + && arg.and_then(|arg| arg.placement).is_none() + && vec_tail_representable(¶m.ty, arg)); + Ok( + match classify( + ¶m.ident, + ¶m.ty, + arg, + global, + tail, + DescriptorRepr::Wire, + )? { + Projection::Option(_) if global => 0, + Projection::Flag(_) if global => 1, + Projection::Positional { .. } => 2, + Projection::Tail(_) => 3, + Projection::Option(_) => 4, + Projection::Flag(_) => 5, + Projection::Stdin(_) | Projection::Stdout(_) => 6, + }, + ) +} + +pub(crate) fn canonical_field_has_option_carrier( + ir: &ToolDefinitionIr, + cmd: &CommandIr, + param: &crate::tool::ir::ParamIr, +) -> Result { + let plan = Plan::analyze(ir)?; + let is_root = to_kebab_case(&cmd.method_ident.to_string()) == plan.tool_name; + let arg = arg_for(cmd, ¶m.ident); + let global = arg.and_then(|arg| arg.placement) == Some(ArgPlacement::Global); + let last = cmd + .params + .iter() + .rev() + .find(|candidate| is_positional_candidate(candidate, arg_for(cmd, &candidate.ident))); + let last_non_inherited = cmd.params.iter().rev().find(|candidate| { + let candidate_arg = arg_for(cmd, &candidate.ident); + is_positional_candidate(candidate, candidate_arg) + && (is_root + || !repeats_inherited_global( + &candidate.ident, + candidate_arg, + &plan.root_global_names, + )) + }); + let tail = last.is_some_and(|candidate| candidate.ident == param.ident) + || (last_non_inherited.is_some_and(|candidate| candidate.ident == param.ident) + && arg.and_then(|arg| arg.placement).is_none() + && vec_tail_representable(¶m.ty, arg)); + let projection = classify( + ¶m.ident, + ¶m.ty, + arg, + global, + tail, + DescriptorRepr::Wire, + )?; + Ok(match projection { + Projection::Option(_) => { + let (base, optional) = unwrap_generic1(¶m.ty, "Option") + .map(|inner| (inner, true)) + .unwrap_or((¶m.ty, false)); + let collection = unwrap_generic1(base, "Vec").is_some() || is_map_type(base); + let required = !optional && arg.and_then(|arg| arg.required).unwrap_or(false); + !collection && !required && arg.and_then(|arg| arg.default.as_ref()).is_none() + } + Projection::Positional { required, .. } => { + !required && arg.and_then(|arg| arg.default.as_ref()).is_none() + } + Projection::Tail(_) + | Projection::Flag(_) + | Projection::Stdin(_) + | Projection::Stdout(_) => false, + }) +} + /// The concrete command-surface a parameter projects onto, used to validate that /// every authored placement-structural `#[arg]` field is actually lowered by that /// surface. Value-schema refinements (text/path/url/numeric) are validated @@ -1107,6 +1435,7 @@ fn classify( arg: Option<&ArgIr>, is_global: bool, emit_as_tail: bool, + repr: DescriptorRepr, ) -> Result { let name = to_kebab_case(&ident.to_string()); @@ -1255,7 +1584,7 @@ fn classify( if let Some(arg) = arg { reject_unconsumed_structural_attrs(arg, SurfaceKind::Tail)?; } - return Ok(Projection::Tail(tail_tokens(&name, item, arg)?)); + return Ok(Projection::Tail(tail_tokens(&name, item, arg, repr)?)); } // Options: explicit placement, any non-flag global (globals can only be @@ -1274,7 +1603,7 @@ fn classify( }; reject_unconsumed_structural_attrs(arg, kind)?; } - let spec = option_spec_tokens(&name, base_ty, vec_item, map_ty, optional, arg)?; + let spec = option_spec_tokens(&name, base_ty, vec_item, map_ty, optional, arg, repr)?; return Ok(Projection::Option(spec)); } @@ -1285,7 +1614,7 @@ fn classify( } let required = !optional && arg.and_then(|a| a.required).unwrap_or(true); Ok(Projection::Positional { - tokens: positional_tokens(&name, base_ty, optional, arg)?, + tokens: positional_tokens(&name, base_ty, optional, arg, repr)?, required, }) } @@ -1449,6 +1778,7 @@ fn option_spec_tokens( map_ty: Option<&Type>, optional: bool, arg: Option<&ArgIr>, + repr: DescriptorRepr, ) -> Result { let doc = arg_doc_tokens(arg); let short = opt_char(arg.and_then(|a| a.short)); @@ -1458,7 +1788,7 @@ fn option_spec_tokens( let position = format!("option --{name}"); let shape = if let Some(item) = vec_item { - let graph = value_graph_tokens(item, arg, MinMaxRole::Bound, &position)?; + let graph = value_graph_tokens(item, arg, MinMaxRole::Bound, &position, repr)?; let rep = repetition_tokens(arg)?; quote! { golem_rust::agentic::ExtendedOptionShape::RepeatableList( @@ -1472,7 +1802,7 @@ fn option_spec_tokens( if let Some(arg) = arg { reject_map_value_refinements(arg)?; } - let graph = value_graph_tokens(map, arg, MinMaxRole::Forbidden, &position)?; + let graph = value_graph_tokens(map, arg, MinMaxRole::Forbidden, &position, repr)?; let rep = repetition_tokens(arg)?; quote! { golem_rust::agentic::ExtendedOptionShape::RepeatableMap( @@ -1485,7 +1815,7 @@ fn option_spec_tokens( ) } } else { - let graph = value_graph_tokens(base_ty, arg, MinMaxRole::Bound, &position)?; + let graph = value_graph_tokens(base_ty, arg, MinMaxRole::Bound, &position, repr)?; if arg.map(|a| a.optional_scalar).unwrap_or(false) { quote! { golem_rust::agentic::ExtendedOptionShape::OptionalScalar(#graph) } } else { @@ -1497,6 +1827,10 @@ fn option_spec_tokens( let required = !optional && arg.and_then(|a| a.required).unwrap_or(false); let default = match arg.and_then(|a| a.default.as_ref()) { + Some(expr) if repr == DescriptorRepr::Wire => { + let lit = tool_literal_tokens(expr)?; + quote! { Some(golem_rust::agentic::option_collected_graph(&__shape).literal(&#lit)?) } + } Some(expr) => { let lit = tool_literal_tokens(expr)?; quote! { @@ -1533,6 +1867,7 @@ fn positional_tokens( base_ty: &Type, optional: bool, arg: Option<&ArgIr>, + repr: DescriptorRepr, ) -> Result { let doc = arg_doc_tokens(arg); let value_name = opt_str(arg.and_then(|a| a.value_name.as_ref())); @@ -1541,6 +1876,7 @@ fn positional_tokens( arg, MinMaxRole::Bound, &format!("positional {name}"), + repr, )?; let accepts_stdio = arg.map(|a| a.accepts_stdio).unwrap_or(false); // Positionals are required by default; `Option` or `required = false` @@ -1548,6 +1884,10 @@ fn positional_tokens( let required = !optional && arg.and_then(|a| a.required).unwrap_or(true); let default = match arg.and_then(|a| a.default.as_ref()) { + Some(expr) if repr == DescriptorRepr::Wire => { + let lit = tool_literal_tokens(expr)?; + quote! { Some(__type.literal(&#lit)?) } + } Some(expr) => { let lit = tool_literal_tokens(expr)?; quote! { @@ -1576,7 +1916,12 @@ fn positional_tokens( }) } -fn tail_tokens(name: &str, item: &Type, arg: Option<&ArgIr>) -> Result { +fn tail_tokens( + name: &str, + item: &Type, + arg: Option<&ArgIr>, + repr: DescriptorRepr, +) -> Result { // `ExtendedTailPositional` (and the WIT `tail-positional` record) has no // default field: a variadic tail has no single default value. An authored // `default` is rejected by `reject_unconsumed_structural_attrs` before this @@ -1592,6 +1937,7 @@ fn tail_tokens(name: &str, item: &Type, arg: Option<&ArgIr>) -> Result quote! { { let __m: u32 = #expr; __m } }, @@ -1633,6 +1979,7 @@ fn inherited_tail_option_surrogate_tokens( name: &str, item: &Type, arg: Option<&ArgIr>, + repr: DescriptorRepr, ) -> Result { let doc = arg_doc_tokens(arg); let aliases = alias_tokens(arg); @@ -1642,6 +1989,7 @@ fn inherited_tail_option_surrogate_tokens( arg, MinMaxRole::Occurrence, &format!("inherited tail positional {name}"), + repr, )?; Ok(quote! { golem_rust::agentic::ExtendedOptionSpec { @@ -1730,9 +2078,16 @@ fn value_graph_tokens( arg: Option<&ArgIr>, min_max: MinMaxRole, position: &str, + repr: DescriptorRepr, ) -> Result { - let base = quote! { - golem_rust::agentic::tool_value_schema::<#inner_ty>(#position)? + let base = if repr == DescriptorRepr::Wire { + quote! { __golem_wire_schema.schema::<#inner_ty>() } + } else { + quote! {{ + use golem_rust::agentic::SelectToolSchemaChecks as _; + (&golem_rust::agentic::ToolSchemaProbe::<#inner_ty>(::std::marker::PhantomData)) + .tool_value_schema(|| golem_rust::agentic::tool_value_schema::<#inner_ty>(#position))? + }} }; let Some(arg) = arg else { return Ok(base); @@ -1753,8 +2108,14 @@ fn value_graph_tokens( let regex = opt_str(arg.regex.as_ref()); let min_len = opt_u32(arg.min_length); let max_len = opt_u32(arg.max_length); - steps.push(quote! { - __g.root = golem_rust::agentic::refine_text(__g.root, #regex, #min_len, #max_len)?; + steps.push(if repr == DescriptorRepr::Wire { + quote! { + __g = __g.refine_text(#regex, #min_len, #max_len)?; + } + } else { + quote! { + __g.root = golem_rust::agentic::refine_text(__g.root, #regex, #min_len, #max_len)?; + } }); } if arg.path_kind.is_some() || arg.direction.is_some() || arg.mime.is_some() { @@ -1770,8 +2131,14 @@ fn value_graph_tokens( let direction = opt_direction(arg.direction); let kind = opt_path_kind(arg.path_kind); let mime = opt_str_vec(arg.mime.as_ref()); - steps.push(quote! { - __g.root = golem_rust::agentic::refine_path(__g.root, #direction, #kind, #mime)?; + steps.push(if repr == DescriptorRepr::Wire { + quote! { + __g = __g.refine_path(#direction, #kind, #mime)?; + } + } else { + quote! { + __g.root = golem_rust::agentic::refine_path(__g.root, #direction, #kind, #mime)?; + } }); } if arg.schemes.is_some() { @@ -1785,8 +2152,14 @@ fn value_graph_tokens( )); } let schemes = opt_str_vec(arg.schemes.as_ref()); - steps.push(quote! { - __g.root = golem_rust::agentic::refine_url(__g.root, #schemes)?; + steps.push(if repr == DescriptorRepr::Wire { + quote! { + __g = __g.refine_url(#schemes)?; + } + } else { + quote! { + __g.root = golem_rust::agentic::refine_url(__g.root, #schemes)?; + } }); } // `bounds`/`unit` always refine the value's numeric schema. `min`/`max` @@ -1844,8 +2217,14 @@ fn value_graph_tokens( (min, max) }; let unit = opt_str(arg.unit.as_ref()); - steps.push(quote! { - __g.root = golem_rust::agentic::refine_numeric(__g.root, #min, #max, #unit)?; + steps.push(if repr == DescriptorRepr::Wire { + quote! { + __g = __g.refine_numeric(#min, #max, #unit)?; + } + } else { + quote! { + __g.root = golem_rust::agentic::refine_numeric(__g.root, #min, #max, #unit)?; + } }); } @@ -1984,19 +2363,25 @@ fn quantifier_tokens(q: QuantifierIr) -> TokenStream { } /// Builds the `(result_spec, errors)` tokens from the method return type. -fn build_result(cmd: &CommandIr) -> Result<(TokenStream, TokenStream), Error> { +fn build_result( + cmd: &CommandIr, + repr: DescriptorRepr, +) -> Result<(TokenStream, TokenStream), Error> { let (ok_ty, err_ty) = split_result(&cmd.output); let errors = match err_ty { + Some(e) if repr == DescriptorRepr::Wire => quote! { + __golem_wire_schema.error_cases(__golem_wire_schema.with_builder( + <#e as golem_rust::agentic::DirectToolError>::wire_error_cases + )) + }, Some(e) => quote! { <#e as golem_rust::agentic::ToolErrorSchema>::error_cases()? }, None => quote! { ::std::vec::Vec::new() }, }; let result_spec = match ok_ty { Some(t) => { - let graph = quote! { - golem_rust::agentic::tool_value_schema::<#t>("result")? - }; + let graph = value_graph_tokens(t, None, MinMaxRole::Forbidden, "result", repr)?; let (formatters, default_formatter) = build_formatters(cmd.result.as_ref()); let empty_doc = doc_tokens(&DocIr::default()); quote! { @@ -2429,8 +2814,66 @@ fn is_unit(ty: &Type) -> bool { mod tests { use super::*; use crate::tool::definition::build_tool_definition_ir; + use quote::ToTokens; use test_r::test; + fn generated_body(item: syn::ItemTrait, prefix: &str) -> String { + let ir = build_tool_definition_ir(&item, None).unwrap(); + let file: syn::File = syn::parse2(synthesize_descriptor_fn(&ir).unwrap()).unwrap(); + file.items + .iter() + .find_map(|item| match item { + syn::Item::Fn(f) if f.sig.ident.to_string().starts_with(prefix) => { + Some(f.block.to_token_stream().to_string()) + } + _ => None, + }) + .unwrap() + } + + #[test] + fn standalone_specialization_does_not_retain_composition() { + let item: syn::ItemTrait = syn::parse_quote! { + trait Simple { fn run(&self, input: String) -> u32; } + }; + let standalone = generated_body(item.clone(), "__golem_standalone"); + assert!(!standalone.contains("ToolBuildCtx")); + assert!(!standalone.contains("normalize_inherited_globals")); + let prepared = generated_body(item.clone(), "__golem_prepared"); + assert!(prepared.contains("prepare_without_literals")); + assert!(prepared.contains("ToolSchemaProbe :: < String >")); + assert!(!prepared.contains("dynamic")); + let composable = generated_body(item, "__golem_tool_descriptor"); + assert!(composable.contains("apply_pending_graft_root")); + assert!(composable.contains("normalize_inherited_globals")); + } + + #[test] + fn advanced_definitions_keep_required_runtime_checks() { + for item in [ + syn::parse_quote! { trait Globals { #[arg(input = "global")] fn run(&self, input: String); } }, + syn::parse_quote! { trait Parent { #[command(subtree = Child)] fn child(&self); } }, + syn::parse_quote! { trait Values { #[constraint(requires_all = value_is("input", "x"))] fn run(&self, input: String); } }, + ] { + assert!(generated_body(item, "__golem_standalone").contains("ToolBuildCtx")); + } + for item in [ + syn::parse_quote! { trait Defaults { #[arg(input = "option", default = "x")] fn run(&self, input: String); } }, + syn::parse_quote! { trait Values { #[constraint(requires_all = value_is("input", "x"))] fn run(&self, input: String); } }, + syn::parse_quote! { trait Parent { #[command(subtree = Child)] fn child(&self); } }, + ] { + assert!(generated_body(item, "__golem_prepared").contains("__tool . prepare ()")); + } + for item in [ + syn::parse_quote! { trait Refined { #[arg(input = "option", regex = "x+")] fn run(&self, input: String); } }, + syn::parse_quote! { trait Errors { fn run(&self) -> Result<(), CustomError>; } }, + ] { + assert!( + generated_body(item, "__golem_prepared").contains("ToolSchemaChecks :: dynamic") + ); + } + } + #[test] fn ordinary_tool_types_named_native_tool_cancellation_remain_schema_inputs() { let item: syn::ItemTrait = syn::parse_quote! { diff --git a/golem-rust-macro/src/tool/implementation.rs b/golem-rust-macro/src/tool/implementation.rs index c0585f22fd..d5283fce33 100644 --- a/golem-rust-macro/src/tool/implementation.rs +++ b/golem-rust-macro/src/tool/implementation.rs @@ -65,8 +65,10 @@ pub fn tool_implementation_impl( #golem_rust::ctor::__support::ctor_parse!( #[ctor] fn #register_fn_name() { - #golem_rust::agentic::register_tool_invoker( - <#self_ty as #trait_path>::__tool_descriptor(), + #golem_rust::agentic::install_tool_exports(); + #golem_rust::agentic::register_wire_tool_invoker( + <#self_ty as #trait_path>::__tool_name(), + <#self_ty as #trait_path>::__tool_wire_descriptor, <#self_ty as #trait_path>::__tool_invoke, ); } diff --git a/golem-rust-macro/src/tool/middleware_authoring.rs b/golem-rust-macro/src/tool/middleware_authoring.rs index b3bcc27fdb..580ee313cb 100644 --- a/golem-rust-macro/src/tool/middleware_authoring.rs +++ b/golem-rust-macro/src/tool/middleware_authoring.rs @@ -446,6 +446,7 @@ fn expand_tool_middleware( #golem_rust::ctor::__support::ctor_parse!( #[ctor] fn #register_ident() { + #golem_rust::tool::install_middleware_exports(); #golem_rust::tool::register_tool_middleware( #descriptor_ident(), #invoker_ident, @@ -635,6 +636,7 @@ fn expand_universal_tool_middleware( #golem_rust::ctor::__support::ctor_parse!( #[ctor] fn #register_ident() { + #golem_rust::tool::install_middleware_exports(); #golem_rust::tool::register_tool_middleware( #descriptor_ident(), #invoker_ident, diff --git a/golem-rust-macro/src/tool/middleware_surface.rs b/golem-rust-macro/src/tool/middleware_surface.rs index bc11207dbe..ec8facd719 100644 --- a/golem-rust-macro/src/tool/middleware_surface.rs +++ b/golem-rust-macro/src/tool/middleware_surface.rs @@ -1095,10 +1095,10 @@ fn encode_dispatch_result( ) .map_err(#sdk::tool::ToolInvokeError::InvalidResult)?; return ::std::result::Result::Err( - #sdk::tool::ToolInvokeError::Tool(#sdk::tool::RawCustomToolError { - name: #error_ident, - payload: #payload_ident, - }) + #sdk::tool::ToolInvokeError::Tool(#sdk::tool::RawCustomToolError::from_payload( + #error_ident, + #payload_ident, + )) ); } ::std::result::Result::Err(#error_ident) => { diff --git a/golem-rust-macro/src/tool/synthesis.rs b/golem-rust-macro/src/tool/synthesis.rs index 9e2851e3b3..794f848de7 100644 --- a/golem-rust-macro/src/tool/synthesis.rs +++ b/golem-rust-macro/src/tool/synthesis.rs @@ -21,20 +21,28 @@ use quote::quote; /// Emits a `golem_rust::agentic::Doc` value from a [`DocIr`]. pub fn doc_tokens(doc: &DocIr) -> TokenStream { + doc_tokens_with_namespace(doc, quote! { golem_rust::agentic }) +} + +pub fn wire_doc_tokens(doc: &DocIr) -> TokenStream { + doc_tokens_with_namespace(doc, quote! { golem_rust::schema::tool::wit::wire }) +} + +fn doc_tokens_with_namespace(doc: &DocIr, namespace: TokenStream) -> TokenStream { let summary = &doc.summary; let description = &doc.description; let examples = doc.examples.iter().map(|ex| { let title = &ex.title; let body = &ex.body; quote! { - golem_rust::agentic::Example { + #namespace::Example { title: #title.to_string(), body: #body.to_string(), } } }); quote! { - golem_rust::agentic::Doc { + #namespace::Doc { summary: #summary.to_string(), description: #description.to_string(), examples: vec![ #(#examples),* ], diff --git a/golem-rust-macro/src/tool/tool_error.rs b/golem-rust-macro/src/tool/tool_error.rs index 9769991a29..cb054e0184 100644 --- a/golem-rust-macro/src/tool/tool_error.rs +++ b/golem-rust-macro/src/tool/tool_error.rs @@ -24,14 +24,14 @@ use crate::tool::helpers::{ use crate::tool::ir::{ ErrorKindIr, ToolErrorIr, ToolErrorNoPayloadStyleIr, ToolErrorPayloadIr, ToolErrorVariantIr, }; -use crate::tool::synthesis::{doc_tokens, error_kind_tokens}; +use crate::tool::synthesis::{doc_tokens, error_kind_tokens, wire_doc_tokens}; use proc_macro::TokenStream; use proc_macro2::Span; use quote::quote; use syn::spanned::Spanned; use syn::{Attribute, Data, DeriveInput, Error, Expr, Fields, Ident}; -pub fn derive_tool_error_impl(input: TokenStream, golem_rust: &Ident) -> TokenStream { +pub fn derive_tool_error_impl(input: TokenStream, golem_rust: &Ident, guest: bool) -> TokenStream { let derive_input = syn::parse_macro_input!(input as DeriveInput); let canonical_golem_rust = Ident::new("golem_rust", Span::call_site()); let preserved_golem_rust = fresh_internal_ident( @@ -51,7 +51,7 @@ pub fn derive_tool_error_impl(input: TokenStream, golem_rust: &Ident) -> TokenSt ); match parse_tool_error(&ir_input) { Ok(ir) => resolve_generated_sdk_paths( - synthesize_tool_error(&ir).into(), + synthesize_tool_error(&ir, guest), golem_rust, &canonical_golem_rust, &preserved_golem_rust, @@ -62,7 +62,7 @@ pub fn derive_tool_error_impl(input: TokenStream, golem_rust: &Ident) -> TokenSt } /// Builds `impl golem_rust::agentic::ToolErrorSchema for ` from the IR. -fn synthesize_tool_error(ir: &ToolErrorIr) -> TokenStream { +fn synthesize_tool_error(ir: &ToolErrorIr, guest: bool) -> proc_macro2::TokenStream { let enum_ident = &ir.enum_ident; let type_name = enum_ident.to_string(); let cases = ir.variants.iter().map(|variant| { @@ -218,6 +218,22 @@ fn synthesize_tool_error(ir: &ToolErrorIr) -> TokenStream { }, } }); + let direct_error_payload_arms = ir.variants.iter().map(|variant| { + let variant_ident = &variant.variant_ident; + let name = to_kebab_case(&variant.variant_ident.to_string()); + match &variant.payload { + ToolErrorPayloadIr::None { style } => { + let pattern = no_payload_pattern(variant_ident, *style); + quote! { #pattern => (#name.to_string(), golem_rust::agentic::encode_direct_tool_value(&()).await?) } + } + ToolErrorPayloadIr::Single { field_ident: None, .. } => quote! { + Self::#variant_ident(__payload) => (#name.to_string(), golem_rust::agentic::encode_direct_tool_value(__payload).await?) + }, + ToolErrorPayloadIr::Single { field_ident: Some(field_ident), .. } => quote! { + Self::#variant_ident { #field_ident } => (#name.to_string(), golem_rust::agentic::encode_direct_tool_value(#field_ident).await?) + }, + } + }); let from_error_payload_arms = ir.variants.iter().map(|variant| { let variant_ident = &variant.variant_ident; let name = to_kebab_case(&variant.variant_ident.to_string()); @@ -240,6 +256,99 @@ fn synthesize_tool_error(ir: &ToolErrorIr) -> TokenStream { } }); let variant_count = ir.variants.len() as u32; + let direct_error_impl = guest.then(|| { + let cases = ir.variants.iter().map(|variant| { + let name = to_kebab_case(&variant.variant_ident.to_string()); + let doc = wire_doc_tokens(&variant.doc); + let kind = match variant.kind { + ErrorKindIr::UsageError => quote! { golem_rust::schema::tool::wit::wire::ErrorKind::UsageError }, + ErrorKindIr::RuntimeError => quote! { golem_rust::schema::tool::wit::wire::ErrorKind::RuntimeError }, + }; + let exit_code = variant.exit_code; + let payload = match &variant.payload { + ToolErrorPayloadIr::None { .. } => quote! { ::std::option::Option::None }, + ToolErrorPayloadIr::Single { ty, .. } => quote! { + ::std::option::Option::Some(<#ty as golem_rust::WireSchema>::append_schema(__builder)) + }, + }; + quote! { + golem_rust::schema::tool::wit::wire::ErrorCase { + name: #name.to_string(), doc: #doc, kind: #kind, + exit_code: #exit_code, payload: #payload, + } + } + }); + let decode_arms = ir.variants.iter().map(|variant| { + let name = to_kebab_case(&variant.variant_ident.to_string()); + let ident = &variant.variant_ident; + let (ty, constructor) = match &variant.payload { + ToolErrorPayloadIr::None { style } => ( + quote! { () }, + no_payload_constructor(ident, *style), + ), + ToolErrorPayloadIr::Single { ty, field_ident: None } => ( + quote! { #ty }, quote! { Self::#ident(__payload) }, + ), + ToolErrorPayloadIr::Single { ty, field_ident: Some(field) } => ( + quote! { #ty }, quote! { Self::#ident { #field: __payload } }, + ), + }; + quote! { + #name => { + let __payload = <#ty as golem_rust::schema::wit::direct::FromWire>::read_wire(__reader, __root) + .map_err(|__error| __error.to_string())?; + ::std::result::Result::Ok(::std::option::Option::Some(#constructor)) + } + } + }); + let error_names = ir.variants.iter().map(|variant| { + to_kebab_case(&variant.variant_ident.to_string()) + }); + quote! { + impl golem_rust::agentic::DirectToolError for #enum_ident { + fn wire_error_cases( + __builder: &mut golem_rust::schema::wit::direct::WireSchemaBuilder, + ) -> ::std::vec::Vec { + ::std::vec![#(#cases),*] + } + + fn recognizes_error_name(__name: &str) -> bool { + ::std::matches!(__name, #(#error_names)|*) + } + + fn from_direct_error_payload( + __name: &str, + __value: golem_rust::schema::wit::wire::SchemaValueTree, + ) -> ::std::result::Result<::std::option::Option, ::std::string::String> { + let __root = __value.root; + let mut __reader = golem_rust::schema::wit::direct::WireReader::new(__value.value_nodes); + let __result = Self::from_direct_error_reader(__name, &mut __reader, __root)?; + if __result.is_some() { + __reader.finish().map_err(|__error| __error.to_string())?; + } + Ok(__result) + } + + fn from_direct_error_reader( + __name: &str, + __reader: &mut golem_rust::schema::wit::direct::WireReader, + __root: golem_rust::schema::wit::wire::ValueNodeIndex, + ) -> ::std::result::Result<::std::option::Option, ::std::string::String> { + match __name { + #(#decode_arms),*, + _ => ::std::result::Result::Ok(::std::option::Option::None), + } + } + + async fn direct_error_payload(&self) -> ::std::result::Result< + (::std::string::String, golem_rust::schema::wit::wire::TypedSchemaValue), + ::std::string::String, + > { + ::std::result::Result::Ok(match self { #(#direct_error_payload_arms),* }) + } + } + } + }); quote! { impl golem_rust::agentic::ToolErrorSchema for #enum_ident { fn error_cases() -> ::std::result::Result< @@ -266,6 +375,8 @@ fn synthesize_tool_error(ir: &ToolErrorIr) -> TokenStream { } } + #direct_error_impl + impl golem_rust::IntoSchema for #enum_ident { fn type_id() -> golem_rust::schema::TypeId { golem_rust::schema::TypeId::new( @@ -325,7 +436,6 @@ fn synthesize_tool_error(ir: &ToolErrorIr) -> TokenStream { } } } - .into() } /// Parses a `#[derive(ToolError)]` enum into its IR. @@ -544,6 +654,24 @@ mod tests { parse_tool_error(&input) } + #[test] + fn direct_error_encoding_is_only_emitted_for_guest_sdk() { + let ir = parse( + r#"enum Failure { + #[tool_error(kind = "usage-error", exit_code = 2)] Reason(String), + #[tool_error(kind = "runtime-error", exit_code = 1)] Empty, + }"#, + ) + .unwrap(); + let guest = synthesize_tool_error(&ir, true).to_string(); + let native = synthesize_tool_error(&ir, false).to_string(); + assert!(guest.contains("DirectToolError")); + assert!(guest.contains("encode_direct_tool_value")); + assert!(!native.contains("DirectToolError")); + assert!(!native.contains("encode_direct_tool_value")); + assert!(native.contains("ToolErrorSchema")); + } + #[test] fn parses_variants() { let ir = parse( diff --git a/golem-schema-derive/src/codegen/helpers.rs b/golem-schema-derive/src/codegen/helpers.rs index 77500c3b08..29281c802a 100644 --- a/golem-schema-derive/src/codegen/helpers.rs +++ b/golem-schema-derive/src/codegen/helpers.rs @@ -26,7 +26,7 @@ pub fn private() -> TokenStream { quote! { #schema_crate::schema::derive::__private } } -fn schema_crate_path() -> TokenStream { +pub(crate) fn schema_crate_path() -> TokenStream { crate_path("golem-schema") .or_else(|| crate_path("golem-rust")) .unwrap_or_else(|| crate_path("golem-common").unwrap_or_else(|| quote! { ::golem_common })) diff --git a/golem-schema-derive/src/codegen/mod.rs b/golem-schema-derive/src/codegen/mod.rs index 1f820af1fa..051a7e4be6 100644 --- a/golem-schema-derive/src/codegen/mod.rs +++ b/golem-schema-derive/src/codegen/mod.rs @@ -18,3 +18,4 @@ pub mod poem; pub mod primitives; pub mod r#struct; pub mod union; +pub mod wire; diff --git a/golem-schema-derive/src/codegen/wire.rs b/golem-schema-derive/src/codegen/wire.rs new file mode 100644 index 0000000000..53c6990b99 --- /dev/null +++ b/golem-schema-derive/src/codegen/wire.rs @@ -0,0 +1,820 @@ +// Copyright 2024-2026 Golem Cloud +// +// Licensed under the Golem Source License v1.1 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://license.golem.cloud/LICENSE +// +// 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. + +use super::helpers::{default_name_for, schema_crate_path}; +use crate::parse::{ + DeprecatedMarker, DiscriminatorAttr, ItemAttrs, RichSpec, TypeAttrs, parse_item_attrs, + parse_type_attrs, +}; +use proc_macro2::{TokenStream, TokenTree}; +use quote::{format_ident, quote}; +use syn::{Data, DeriveInput, Fields, GenericParam, Member, Type}; + +pub fn expand_schema(input: &DeriveInput) -> syn::Result { + let schema = schema_crate_path(); + let wire = quote!(#schema::schema::wit::wire); + let direct = quote!(#schema::schema::wit::direct); + let attrs = parse_type_attrs(&input.attrs)?; + let ident = &input.ident; + let mut generics = input.generics.clone(); + let groups = match &input.data { + Data::Struct(data) => vec![&data.fields], + Data::Enum(data) => data.variants.iter().map(|v| &v.fields).collect(), + Data::Union(_) => Vec::new(), + }; + let mut described_types = TokenStream::new(); + let mut stream_types = Vec::new(); + for fields in groups { + for field in fields { + let a = parse_item_attrs(&field.attrs)?; + if matches!(fields, Fields::Named(_)) && (a.skip || a.default_with.is_some()) { + continue; + } + let ty = &field.ty; + described_types.extend(quote!(#ty)); + if a.rich.is_none() { + stream_types.push(ty); + } + } + } + for param in &mut generics.params { + if let GenericParam::Type(param) = param + && (attrs.named.is_some() || contains_ident(described_types.clone(), ¶m.ident)) + { + param.bounds.push(syn::parse_quote!(#direct::WireSchema)); + } + } + let (impl_generics, ty_generics, where_clause) = generics.split_for_impl(); + + if attrs.transparent { + let Data::Struct(data) = &input.data else { + return Err(syn::Error::new_spanned( + input, + "transparent schema requires a tuple struct", + )); + }; + let Fields::Unnamed(fields) = &data.fields else { + return Err(syn::Error::new_spanned( + input, + "transparent schema requires a tuple struct", + )); + }; + if fields.unnamed.len() != 1 { + return Err(syn::Error::new_spanned( + input, + "transparent schema requires one field", + )); + } + let ty = &fields.unnamed[0].ty; + return Ok(quote! { + #[automatically_derived] + impl #impl_generics #direct::WireSchema for #ident #ty_generics #where_clause { + const IS_UNIT: bool = <#ty as #direct::WireSchema>::IS_UNIT; + + fn contains_stream(seen: &mut ::std::collections::HashSet<&'static str>) -> bool { + <#ty as #direct::WireSchema>::contains_stream(seen) + } + + fn append_schema(builder: &mut #direct::WireSchemaBuilder) -> #wire::TypeNodeIndex { + <#ty as #direct::WireSchema>::append_schema(builder) + } + fn wire_type_id() -> ::std::string::String { <#ty as #direct::WireSchema>::wire_type_id() } + } + }); + } + + let body = schema_body(input, &attrs, &wire, &direct)?; + let metadata = metadata( + &attrs.doc, + &attrs.alias, + &attrs.example, + attrs.deprecated.as_ref(), + attrs.role.as_deref(), + &wire, + ); + let display = attrs.named.clone().unwrap_or_else(|| ident.to_string()); + let id = if let Some(name) = &attrs.named { + let type_params: Vec<_> = input.generics.type_params().map(|p| &p.ident).collect(); + if type_params.is_empty() { + quote!(::std::string::String::from(#name)) + } else { + quote!({ + let args = [#(<#type_params as #direct::WireSchema>::wire_type_id()),*]; + ::std::format!("{}<{}>", #name, args.join(", ")) + }) + } + } else { + quote!(::core::any::type_name::().replace("::", ".")) + }; + Ok(quote! { + #[automatically_derived] + impl #impl_generics #direct::WireSchema for #ident #ty_generics #where_clause { + fn contains_stream(seen: &mut ::std::collections::HashSet<&'static str>) -> bool { + if !seen.insert(::core::any::type_name::()) { + return false; + } + false #(|| <#stream_types as #direct::WireSchema>::contains_stream(seen))* + } + + fn append_schema(builder: &mut #direct::WireSchemaBuilder) -> #wire::TypeNodeIndex { + let id = ::wire_type_id(); + let (definition, fresh) = builder.reserve(id, ::core::option::Option::Some(#display.to_string())); + if fresh { + let body = { #body }; + let node = builder.push_with_metadata(body, #metadata); + builder.commit(definition, node); + } + builder.reference(definition) + } + fn wire_type_id() -> ::std::string::String { #id } + } + }) +} + +fn schema_body( + input: &DeriveInput, + attrs: &TypeAttrs, + wire: &TokenStream, + direct: &TokenStream, +) -> syn::Result { + match &input.data { + Data::Struct(data) => fields_schema(&data.fields, attrs, wire, direct), + Data::Enum(data) if attrs.union => { + let branches = data.variants.iter().map(|variant| { + let Fields::Unnamed(fields) = &variant.fields else { + return Err(syn::Error::new_spanned(variant, "wire union branches require one tuple field")); + }; + if fields.unnamed.len() != 1 { + return Err(syn::Error::new_spanned(variant, "wire union branches require one tuple field")); + } + let a = parse_item_attrs(&variant.attrs)?; + let name = a.rename.clone().unwrap_or_else(|| default_name_for(&variant.ident, attrs.rename_all)); + let meta = item_metadata(&a, wire); + let body = field_schema(&fields.unnamed[0], wire, direct)?; + let discriminator = match a.discriminator.as_ref() { + Some(DiscriminatorAttr::Prefix(value)) => quote!(#wire::DiscriminatorRule::Prefix(#value.to_string())), + Some(DiscriminatorAttr::Suffix(value)) => quote!(#wire::DiscriminatorRule::Suffix(#value.to_string())), + Some(DiscriminatorAttr::Contains(value)) => quote!(#wire::DiscriminatorRule::Contains(#value.to_string())), + Some(DiscriminatorAttr::Regex(value)) => quote!(#wire::DiscriminatorRule::Regex(#value.to_string())), + Some(DiscriminatorAttr::FieldAbsent(value)) => quote!(#wire::DiscriminatorRule::FieldAbsent(#value.to_string())), + Some(DiscriminatorAttr::FieldEquals { field, literal }) => { + let literal = option_string(literal.as_deref()); + quote!(#wire::DiscriminatorRule::FieldEquals(#wire::FieldDiscriminator { field_name: #field.to_string(), literal: #literal })) + } + None => return Err(syn::Error::new_spanned(variant, "wire union branches require a discriminator")), + }; + Ok(quote!(#wire::UnionBranch { tag: #name.to_string(), body: #body, discriminator: #discriminator, metadata: #meta })) + }).collect::>>()?; + Ok( + quote!(#wire::SchemaTypeBody::UnionType(#wire::UnionSpec { branches: ::std::vec![#(#branches),*] })), + ) + } + Data::Enum(data) => { + let all_unit = !data.variants.is_empty() + && data + .variants + .iter() + .all(|v| matches!(v.fields, Fields::Unit)); + if all_unit { + let names = data + .variants + .iter() + .map(|v| { + let a = parse_item_attrs(&v.attrs)?; + Ok(a.rename + .unwrap_or_else(|| default_name_for(&v.ident, attrs.rename_all))) + }) + .collect::>>()?; + Ok(quote!(#wire::SchemaTypeBody::EnumType(::std::vec![#(#names.to_string()),*]))) + } else { + let cases = data.variants.iter().map(|v| { + let a = parse_item_attrs(&v.attrs)?; + let name = a.rename.clone().unwrap_or_else(|| default_name_for(&v.ident, attrs.rename_all)); + let meta = item_metadata(&a, wire); + let payload = if matches!(v.fields, Fields::Unit) { + quote!(::core::option::Option::None) + } else if let Fields::Unnamed(fields) = &v.fields + && fields.unnamed.len() == 1 + { + let body = field_schema(&fields.unnamed[0], wire, direct)?; + quote!(::core::option::Option::Some(#body)) + } else { + let body = fields_schema(&v.fields, attrs, wire, direct)?; + quote!({ + let payload_body = #body; + ::core::option::Option::Some(builder.push(payload_body)) + }) + }; + Ok(quote!(#wire::VariantCaseType { name: #name.to_string(), payload: #payload, metadata: #meta })) + }).collect::>>()?; + Ok(quote!(#wire::SchemaTypeBody::VariantType(::std::vec![#(#cases),*]))) + } + } + Data::Union(_) => Err(syn::Error::new_spanned( + input, + "Rust unions have no wire schema", + )), + } +} + +fn fields_schema( + fields: &Fields, + attrs: &TypeAttrs, + wire: &TokenStream, + direct: &TokenStream, +) -> syn::Result { + match fields { + Fields::Named(fields) => { + let values = fields.named.iter().filter_map(|field| { + let a = match parse_item_attrs(&field.attrs) { Ok(a) => a, Err(e) => return Some(Err(e)) }; + if a.skip || a.default_with.is_some() { return None; } + if a.flatten { return Some(Err(syn::Error::new_spanned(field, "flatten field schemas are not supported by WireSchema"))); } + let body = match field_schema(field, wire, direct) { Ok(body) => body, Err(e) => return Some(Err(e)) }; + let ident = field.ident.as_ref().unwrap(); + let name = a.rename.clone().unwrap_or_else(|| default_name_for(ident, attrs.rename_all)); + let meta = item_metadata(&a, wire); + Some(Ok(quote!(#wire::NamedFieldType { name: #name.to_string(), body: #body, metadata: #meta }))) + }).collect::>>()?; + Ok(quote!(#wire::SchemaTypeBody::RecordType(::std::vec![#(#values),*]))) + } + Fields::Unnamed(fields) => { + let values = fields + .unnamed + .iter() + .map(|f| field_schema(f, wire, direct)) + .collect::>>()?; + Ok(quote!(#wire::SchemaTypeBody::TupleType(::std::vec![#(#values),*]))) + } + Fields::Unit => Ok(quote!(#wire::SchemaTypeBody::RecordType(::std::vec::Vec::new()))), + } +} + +fn field_schema( + field: &syn::Field, + wire: &TokenStream, + direct: &TokenStream, +) -> syn::Result { + let attrs = parse_item_attrs(&field.attrs)?; + let ty = &field.ty; + let body = match attrs.rich { + Some(RichSpec::Text(spec)) => { + let languages = option_strings( + spec.languages + .as_deref() + .or_else(|| spec.language.as_ref().map(::std::slice::from_ref)), + ); + let min = option_number(spec.min); + let max = option_number(spec.max); + let regex = option_string(spec.regex.as_deref()); + quote!(#wire::SchemaTypeBody::TextType(#wire::TextRestrictions { languages: #languages, min_length: #min, max_length: #max, regex: #regex })) + } + Some(RichSpec::Binary(spec)) => { + let mimes = option_strings( + spec.mime_types + .as_deref() + .or_else(|| spec.mime_type.as_ref().map(::std::slice::from_ref)), + ); + let min = option_number(spec.min_bytes); + let max = option_number(spec.max_bytes); + quote!(#wire::SchemaTypeBody::BinaryType(#wire::BinaryRestrictions { mime_types: #mimes, min_bytes: #min, max_bytes: #max })) + } + Some(RichSpec::Url(spec)) => { + let schemes = option_strings(spec.allowed_schemes.as_deref()); + let hosts = option_strings(spec.allowed_hosts.as_deref()); + quote!(#wire::SchemaTypeBody::UrlType(#wire::UrlRestrictions { allowed_schemes: #schemes, allowed_hosts: #hosts })) + } + Some(RichSpec::QuotaToken(spec)) => { + let resource_name = option_string(spec.resource_name.as_deref()); + quote!(#wire::SchemaTypeBody::QuotaTokenType(#wire::QuotaTokenSpec { resource_name: #resource_name })) + } + Some(_) => { + return Err(syn::Error::new_spanned( + field, + "this rich field schema is not supported by WireSchema", + )); + } + None => return Ok(quote!(<#ty as #direct::WireSchema>::append_schema(builder))), + }; + Ok(quote!(builder.push(#body))) +} + +fn option_number(value: Option) -> TokenStream { + match value { + Some(value) => quote!(::core::option::Option::Some(#value)), + None => quote!(::core::option::Option::None), + } +} + +fn option_strings(value: Option<&[String]>) -> TokenStream { + match value { + Some(value) => quote!(::core::option::Option::Some( + ::std::vec![#(#value.to_string()),*] + )), + None => quote!(::core::option::Option::None), + } +} + +fn item_metadata(attrs: &ItemAttrs, wire: &TokenStream) -> TokenStream { + metadata( + &attrs.doc, + &attrs.alias, + &attrs.example, + attrs.deprecated.as_ref(), + None, + wire, + ) +} + +fn metadata( + doc: &Option, + aliases: &[String], + examples: &[String], + deprecated: Option<&DeprecatedMarker>, + role: Option<&str>, + wire: &TokenStream, +) -> TokenStream { + let doc = match doc { + Some(value) => quote!(::core::option::Option::Some(#value.to_string())), + None => quote!(::core::option::Option::None), + }; + let deprecated = match deprecated.map(DeprecatedMarker::message) { + Some(value) => quote!(::core::option::Option::Some(#value.to_string())), + None => quote!(::core::option::Option::None), + }; + let role = match role { + Some("multimodal") => quote!(::core::option::Option::Some(#wire::Role::Multimodal)), + Some("unstructured-text") => { + quote!(::core::option::Option::Some(#wire::Role::UnstructuredText)) + } + Some("unstructured-binary") => { + quote!(::core::option::Option::Some(#wire::Role::UnstructuredBinary)) + } + Some(other) => quote!(::core::option::Option::Some(#wire::Role::Other(#other.to_string()))), + None => quote!(::core::option::Option::None), + }; + quote!(#wire::MetadataEnvelope { + doc: #doc, aliases: ::std::vec![#(#aliases.to_string()),*], + examples: ::std::vec![#(#examples.to_string()),*], deprecated: #deprecated, role: #role, + }) +} + +pub fn expand(input: &DeriveInput, encode: bool) -> syn::Result { + let schema = schema_crate_path(); + let wire = quote!(#schema::schema::wit::wire); + let direct = quote!(#schema::schema::wit::direct); + let attrs = parse_type_attrs(&input.attrs)?; + let ident = &input.ident; + let mut generics = input.generics.clone(); + let trait_name = if encode { + quote!(#direct::IntoWire) + } else { + quote!(#direct::FromWire) + }; + let mut encoded_types = TokenStream::new(); + let field_groups = match &input.data { + Data::Struct(data) => vec![(&data.fields, !attrs.transparent)], + Data::Enum(data) => data.variants.iter().map(|v| (&v.fields, false)).collect(), + Data::Union(_) => Vec::new(), + }; + for (fields, is_struct) in field_groups { + for field in fields { + let field_attrs = parse_item_attrs(&field.attrs)?; + let ty = &field.ty; + if is_struct + && matches!(fields, Fields::Named(_)) + && (field_attrs.skip || field_attrs.default_with.is_some()) + { + if !encode && field_attrs.default_with.is_none() { + generics + .make_where_clause() + .predicates + .push(syn::parse_quote!(#ty: ::core::default::Default)); + } + } else { + encoded_types.extend(quote!(#ty)); + } + } + } + for param in &mut generics.params { + if let GenericParam::Type(param) = param + && contains_ident(encoded_types.clone(), ¶m.ident) + { + param.bounds.push(syn::parse2(trait_name.clone())?); + } + } + let (impl_generics, ty_generics, where_clause) = generics.split_for_impl(); + + let (preflight, prepare, body) = match &input.data { + Data::Struct(data) => { + let single = attrs.transparent; + if single + && !matches!(&data.fields, Fields::Unnamed(fields) if fields.unnamed.len() == 1) + { + return Err(syn::Error::new_spanned( + input, + "transparent wire conversion requires a single tuple field", + )); + } + fields( + &data.fields, + quote!(Self), + single, + true, + encode, + &wire, + &direct, + )? + } + Data::Enum(data) => { + let all_unit = !attrs.union + && !data.variants.is_empty() + && data + .variants + .iter() + .all(|variant| matches!(variant.fields, Fields::Unit)); + let mut preflight_arms = Vec::new(); + let mut prepare_arms = Vec::new(); + let mut arms = Vec::new(); + for (case, variant) in data.variants.iter().enumerate() { + let variant_ident = &variant.ident; + let constructor = quote!(Self::#variant_ident); + let single = + matches!(&variant.fields, Fields::Unnamed(fields) if fields.unnamed.len() == 1); + if attrs.union && !single { + return Err(syn::Error::new_spanned( + variant, + "wire union branches require one tuple field", + )); + } + let (preflight, prepare, value) = fields( + &variant.fields, + constructor.clone(), + single, + false, + encode, + &wire, + &direct, + )?; + let pattern = pattern(&variant.fields, constructor); + let case = case as u32; + let variant_attrs = parse_item_attrs(&variant.attrs)?; + let tag = variant_attrs + .rename + .unwrap_or_else(|| default_name_for(variant_ident, attrs.rename_all)); + if encode { + preflight_arms.push(quote!(#pattern => { #preflight })); + prepare_arms.push(quote!(#pattern => { #prepare })); + let node = if all_unit { + quote!(#wire::SchemaValueNode::EnumValue(#case)) + } else if attrs.union { + quote!(#wire::SchemaValueNode::UnionValue(#wire::UnionValuePayload { tag: #tag.to_string(), body: { #value }? })) + } else { + let payload = if matches!(variant.fields, Fields::Unit) { + quote!(::core::option::Option::None) + } else { + quote!(::core::option::Option::Some({ #value }?)) + }; + quote!(#wire::SchemaValueNode::VariantValue(#wire::VariantValuePayload { case: #case, payload: #payload })) + }; + arms.push(quote!(#pattern => { + let node = #node; + ::core::result::Result::Ok(writer.push(node)) + })); + } else { + let key = if attrs.union { + quote!(#tag) + } else { + quote!(#case) + }; + let decoded = if matches!(variant.fields, Fields::Unit) { + if all_unit { + quote!(::core::result::Result::Ok(Self::#variant_ident)) + } else { + quote! { + if payload.is_some() { return ::core::result::Result::Err(#direct::WireError::Shape("absent variant payload")); } + ::core::result::Result::Ok(Self::#variant_ident) + } + } + } else if attrs.union { + value + } else { + quote! { + let index = payload.ok_or(#direct::WireError::Shape("variant payload"))?; + #value + } + }; + arms.push(quote!(#key => { #decoded })); + } + } + if encode { + let subject = if data.variants.is_empty() { + quote!(*self) + } else { + quote!(self) + }; + ( + quote!(match #subject { #(#preflight_arms),* }), + quote!(match #subject { #(#prepare_arms),* }), + quote!(match #subject { #(#arms),* }), + ) + } else { + let (extract, key) = if all_unit { + ( + quote! { + let #wire::SchemaValueNode::EnumValue(case) = reader.take(index)? else { + return ::core::result::Result::Err(#direct::WireError::Shape("enum")); + }; + }, + quote!(case), + ) + } else if attrs.union { + ( + quote! { + let #wire::SchemaValueNode::UnionValue(value) = reader.take(index)? else { + return ::core::result::Result::Err(#direct::WireError::Shape("union")); + }; + let index = value.body; + }, + quote!(value.tag.as_str()), + ) + } else { + ( + quote! { + let #wire::SchemaValueNode::VariantValue(value) = reader.take(index)? else { + return ::core::result::Result::Err(#direct::WireError::Shape("variant")); + }; + let payload = value.payload; + }, + quote!(value.case), + ) + }; + ( + quote!(), + quote!(), + quote! { + #extract + match #key { #(#arms,)* _ => ::core::result::Result::Err(#direct::WireError::Shape("variant case")) } + }, + ) + } + } + Data::Union(_) => { + return Err(syn::Error::new_spanned( + input, + "Rust unions cannot be converted to wire values", + )); + } + }; + + let result_payload = if attrs.transparent { + let Data::Struct(data) = &input.data else { + return Err(syn::Error::new_spanned( + input, + "transparent wire conversion requires a tuple struct", + )); + }; + let ty = &data.fields.iter().next().unwrap().ty; + if encode { + quote! { + fn write_result_payload(&self, writer: &mut #direct::WireWriter) -> ::core::result::Result<::core::option::Option<#wire::ValueNodeIndex>, #direct::WireError> { + <#ty as #direct::IntoWire>::write_result_payload(&self.0, writer) + } + } + } else { + quote! { + fn read_result_payload(reader: &mut #direct::WireReader, index: ::core::option::Option<#wire::ValueNodeIndex>) -> ::core::result::Result { + <#ty as #direct::FromWire>::read_result_payload(reader, index).map(Self) + } + } + } + } else { + quote!() + }; + let methods = if encode { + quote! { + fn preflight(&self, resources: &mut #direct::WirePreflight) -> ::core::result::Result<(), #direct::WireError> { + #preflight + } + async fn prepare_wire(&self) -> ::core::result::Result<(), #direct::WireError> { + #prepare + } + fn write_wire(&self, writer: &mut #direct::WireWriter) -> ::core::result::Result<#wire::ValueNodeIndex, #direct::WireError> { + #body + } + } + } else { + quote! { + fn read_wire(reader: &mut #direct::WireReader, index: #wire::ValueNodeIndex) -> ::core::result::Result { + #body + } + } + }; + Ok(quote! { + #[automatically_derived] + impl #impl_generics #trait_name for #ident #ty_generics #where_clause { #methods #result_payload } + }) +} + +fn contains_ident(tokens: TokenStream, ident: &syn::Ident) -> bool { + tokens.into_iter().any(|token| match token { + TokenTree::Ident(candidate) => candidate == *ident, + TokenTree::Group(group) => contains_ident(group.stream(), ident), + _ => false, + }) +} + +fn pattern(fields: &Fields, constructor: TokenStream) -> TokenStream { + let names = fields + .iter() + .enumerate() + .map(|(i, _)| format_ident!("__field_{i}")) + .collect::>(); + match fields { + Fields::Named(fields) => { + let members = fields + .named + .iter() + .map(|field| field.ident.as_ref().unwrap()); + quote!(#constructor { #(#members: #names),* }) + } + Fields::Unnamed(_) => quote!(#constructor(#(#names),*)), + Fields::Unit => constructor, + } +} + +fn fields( + fields: &Fields, + constructor: TokenStream, + single: bool, + is_struct: bool, + encode: bool, + wire: &TokenStream, + direct: &TokenStream, +) -> syn::Result<(TokenStream, TokenStream, TokenStream)> { + let mut preflight = Vec::new(); + let mut prepare = Vec::new(); + let mut values = Vec::new(); + let mut decoded = Vec::new(); + let mut encoded_count = 0usize; + for (i, field) in fields.iter().enumerate() { + let attrs = if is_struct && single { + ItemAttrs::default() + } else { + parse_item_attrs(&field.attrs)? + }; + let ty = &field.ty; + let binding = format_ident!("__field_{i}"); + let member = field + .ident + .clone() + .map(Member::Named) + .unwrap_or_else(|| Member::Unnamed(i.into())); + let skipped = is_struct + && matches!(fields, Fields::Named(_)) + && (attrs.skip || attrs.default_with.is_some()); + if skipped { + let default = if let Some(path) = attrs.default_with { + let path: syn::Path = syn::parse_str(&path)?; + quote!(#path()) + } else { + quote!(::core::default::Default::default()) + }; + decoded.push(quote!(#member: #default)); + continue; + } + let access = if is_struct { + quote!(&self.#member) + } else { + quote!(#binding) + }; + let pos = encoded_count; + encoded_count += 1; + if encode { + if attrs.rich.is_none() || matches!(attrs.rich, Some(RichSpec::QuotaToken(_))) { + preflight.push(quote!(<#ty as #direct::IntoWire>::preflight(#access, resources)?;)); + prepare.push(quote!(<#ty as #direct::IntoWire>::prepare_wire(#access).await?;)); + } + values.push(write_field(ty, &attrs, access, wire, direct)); + } else { + let index = if single { + quote!(index) + } else { + quote!(indices[#pos]) + }; + let value = read_field(ty, &attrs, index, wire, direct); + decoded.push(if matches!(fields, Fields::Named(_)) { + quote!(#member: #value) + } else { + value + }); + } + } + let preflight = quote! { #(#preflight)* ::core::result::Result::Ok(()) }; + let prepare = quote! { #(#prepare)* ::core::result::Result::Ok(()) }; + let node = if matches!(fields, Fields::Unnamed(_)) { + quote!(TupleValue) + } else { + quote!(RecordValue) + }; + let body = if encode { + if single { + let value = &values[0]; + quote!(#value) + } else { + quote! { + let indices = ::std::vec![#(#values?),*]; + ::core::result::Result::Ok::<_, #direct::WireError>(writer.push(#wire::SchemaValueNode::#node(indices))) + } + } + } else { + let extract = if single { + quote!() + } else { + quote! { + let #wire::SchemaValueNode::#node(indices) = reader.take(index)? else { + return ::core::result::Result::Err(#direct::WireError::Shape(stringify!(#node))); + }; + if indices.len() != #encoded_count { return ::core::result::Result::Err(#direct::WireError::Shape("field count")); } + } + }; + let result = match fields { + Fields::Named(_) => quote!(#constructor { #(#decoded),* }), + Fields::Unnamed(_) => quote!(#constructor(#(#decoded),*)), + Fields::Unit => constructor, + }; + quote! { #extract ::core::result::Result::Ok(#result) } + }; + Ok((preflight, prepare, body)) +} + +fn write_field( + ty: &Type, + attrs: &ItemAttrs, + value: TokenStream, + wire: &TokenStream, + direct: &TokenStream, +) -> TokenStream { + let node = match &attrs.rich { + Some(RichSpec::Text(spec)) => { + let language = option_string(spec.language.as_deref()); + quote!(#wire::SchemaValueNode::TextValue(#wire::TextValuePayload { text: (#value).clone(), language: #language })) + } + Some(RichSpec::Binary(spec)) => { + let mime = option_string(spec.mime_type.as_deref()); + quote!(#wire::SchemaValueNode::BinaryValue(#wire::BinaryValuePayload { bytes: (#value).clone(), mime_type: #mime })) + } + Some(RichSpec::Path(_)) => quote!(#wire::SchemaValueNode::PathValue((#value).clone())), + Some(RichSpec::Url(_)) => quote!(#wire::SchemaValueNode::UrlValue((#value).clone())), + Some(RichSpec::Quantity(_)) => { + quote!(#wire::SchemaValueNode::QuantityValueNode(#wire::QuantityValue { + mantissa: (#value).mantissa, scale: (#value).scale, unit: (#value).unit.clone(), + })) + } + _ => return quote!(<#ty as #direct::IntoWire>::write_wire(#value, writer)), + }; + quote!(::core::result::Result::Ok::<_, #direct::WireError>(writer.push(#node))) +} + +fn read_field( + ty: &Type, + attrs: &ItemAttrs, + index: TokenStream, + wire: &TokenStream, + direct: &TokenStream, +) -> TokenStream { + let (variant, value) = match &attrs.rich { + Some(RichSpec::Text(_)) => (quote!(TextValue), quote!(value.text)), + Some(RichSpec::Binary(_)) => (quote!(BinaryValue), quote!(value.bytes)), + Some(RichSpec::Path(_)) => (quote!(PathValue), quote!(value)), + Some(RichSpec::Url(_)) => (quote!(UrlValue), quote!(value)), + Some(RichSpec::Quantity(_)) => ( + quote!(QuantityValueNode), + quote!(#ty { mantissa: value.mantissa, scale: value.scale, unit: value.unit }), + ), + _ => return quote!(<#ty as #direct::FromWire>::read_wire(reader, #index)?), + }; + quote! { + match reader.take(#index)? { + #wire::SchemaValueNode::#variant(value) => #value, + _ => return ::core::result::Result::Err(#direct::WireError::Shape(stringify!(#variant))), + } + } +} + +fn option_string(value: Option<&str>) -> TokenStream { + match value { + Some(value) => quote!(::core::option::Option::Some(#value.to_string())), + None => quote!(::core::option::Option::None), + } +} diff --git a/golem-schema-derive/src/lib.rs b/golem-schema-derive/src/lib.rs index ba3afcf20c..8d9c1d67b2 100644 --- a/golem-schema-derive/src/lib.rs +++ b/golem-schema-derive/src/lib.rs @@ -50,6 +50,34 @@ mod parse; use proc_macro::TokenStream; use syn::{DeriveInput, parse_macro_input}; +/// Converts a guest value directly to canonical wire nodes, including affine +/// resource preflight. Does not construct or validate a schema model. +#[proc_macro_derive(IntoWire, attributes(schema))] +pub fn derive_into_wire(input: TokenStream) -> TokenStream { + let input = parse_macro_input!(input as DeriveInput); + codegen::wire::expand(&input, true) + .unwrap_or_else(syn::Error::into_compile_error) + .into() +} + +/// Decodes canonical wire nodes directly into a concrete guest type. +#[proc_macro_derive(FromWire, attributes(schema))] +pub fn derive_from_wire(input: TokenStream) -> TokenStream { + let input = parse_macro_input!(input as DeriveInput); + codegen::wire::expand(&input, false) + .unwrap_or_else(syn::Error::into_compile_error) + .into() +} + +/// Appends a concrete type definition directly to the flat WIT schema arena. +#[proc_macro_derive(WireSchema, attributes(schema))] +pub fn derive_wire_schema(input: TokenStream) -> TokenStream { + let input = parse_macro_input!(input as DeriveInput); + codegen::wire::expand_schema(&input) + .unwrap_or_else(syn::Error::into_compile_error) + .into() +} + #[proc_macro_derive(IntoSchema, attributes(schema))] pub fn derive_into_schema(input: TokenStream) -> TokenStream { let input = parse_macro_input!(input as DeriveInput); diff --git a/golem-schema/Cargo.toml b/golem-schema/Cargo.toml index f7a6781259..ddf3d3b633 100644 --- a/golem-schema/Cargo.toml +++ b/golem-schema/Cargo.toml @@ -17,35 +17,45 @@ name = "owned_typed_wit" harness = false required-features = ["guest"] +[[test]] +name = "direct_wire" +harness = false +required-features = ["guest"] + +[[test]] +name = "feature_matrix" +harness = false + [features] -default = [] -bigdecimal = [] -bit_vec = [] +default = ["rich-validation"] +bigdecimal = ["dep:bigdecimal"] +bit_vec = ["dep:bit-vec"] bytes = ["dep:bytes"] chrono = [] derive = [] -full = ["dep:desert_rust", "dep:golem-api-grpc", "dep:poem", "dep:poem-openapi"] +full = ["rich-validation", "dep:desert_rust", "dep:golem-api-grpc", "dep:poem", "dep:poem-openapi"] guest = ["dep:wasip2", "dep:wit-bindgen"] -host = ["native-stream", "dep:wasmtime", "dep:wasmtime-wasi"] +host = ["rich-validation", "native-stream", "dep:wasmtime", "dep:wasmtime-wasi"] native-stream = [] mac_address = ["dep:mac_address"] nonempty_collections = ["dep:nonempty-collections"] num_bigint = ["dep:num-bigint"] proptest = ["dep:proptest"] +regex = ["dep:regex"] +rich-validation = ["regex", "url"] rust_decimal = ["dep:rust_decimal"] serde_json_types = [] -url = [] +url = ["dep:url"] [dependencies] golem-schema-derive = { workspace = true } base64 = { workspace = true } -bigdecimal = { workspace = true } -bit-vec = { workspace = true } +bigdecimal = { workspace = true, optional = true } +bit-vec = { workspace = true, optional = true } blake3 = { workspace = true } bytes = { workspace = true, optional = true } chrono = { workspace = true } -combine = { workspace = true } desert_rust = { workspace = true, optional = true } golem-api-grpc = { workspace = true, optional = true } mac_address = { workspace = true, optional = true } @@ -54,14 +64,12 @@ num-bigint = { workspace = true, optional = true } poem = { workspace = true, optional = true } poem-openapi = { workspace = true, optional = true } proptest = { workspace = true, optional = true } -range-set-blaze = { workspace = true } -regex = { workspace = true } +regex = { workspace = true, optional = true } rust_decimal = { workspace = true, optional = true } serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } -typed-path = { workspace = true } -url = { workspace = true } +url = { workspace = true, optional = true } uuid = { workspace = true } wasip2 = { workspace = true, optional = true } wasmtime = { workspace = true, optional = true } diff --git a/golem-schema/src/lib.rs b/golem-schema/src/lib.rs index 25a170d697..dcb2f7397d 100644 --- a/golem-schema/src/lib.rs +++ b/golem-schema/src/lib.rs @@ -12,6 +12,21 @@ // See the License for the specific language governing permissions and // limitations under the License. +//! Schema representation, canonical encoding, and validation. +//! +//! `regex` enables the Rust `regex` crate's complete existing dialect for text +//! restrictions and union discriminators. `url` enables WHATWG URL validation +//! (including IDNA) and conversions for `url::Url`. `rich-validation` enables +//! both; it is included by default and by `host` and `full`. +//! +//! With default features disabled, the schema representation and fixed MIME, +//! quantity-unit, and tool-identifier grammars remain available. Validation +//! requiring a disabled feature returns an explicit unsupported-feature error; +//! it never accepts a value by skipping its regex or URL restriction. Regex +//! union rendering also fails explicitly. Enable the needed features in guests +//! that validate these rich values. URL schema structure and canonical encoding +//! do not require URL parsing; value validation does. + #[cfg(all(feature = "host", feature = "guest"))] compile_error!("golem-schema features `host` and `guest` are mutually exclusive"); diff --git a/golem-schema/src/model.rs b/golem-schema/src/model.rs index 218851808b..0861308112 100644 --- a/golem-schema/src/model.rs +++ b/golem-schema/src/model.rs @@ -39,6 +39,14 @@ pub type Datetime = chrono::DateTime; #[cfg_attr(feature = "full", desert(evolution()))] #[serde(rename_all = "camelCase")] #[schema(named = "golem.core.EnvironmentId")] +#[cfg_attr( + feature = "guest", + derive( + golem_schema_derive::FromWire, + golem_schema_derive::IntoWire, + golem_schema_derive::WireSchema + ) +)] #[cfg_attr(feature = "full", derive(golem_schema_derive::PoemSchema))] pub struct EnvironmentId { pub uuid: Uuid, @@ -80,6 +88,14 @@ impl From for Uuid { #[cfg_attr(feature = "full", desert(evolution()))] #[serde(rename_all = "camelCase")] #[schema(named = "golem.core.ComponentId")] +#[cfg_attr( + feature = "guest", + derive( + golem_schema_derive::FromWire, + golem_schema_derive::IntoWire, + golem_schema_derive::WireSchema + ) +)] pub struct ComponentId { pub uuid: Uuid, } @@ -120,6 +136,14 @@ impl From for Uuid { #[cfg_attr(feature = "full", desert(evolution()))] #[serde(rename_all = "camelCase")] #[schema(named = "golem.core.AccountId")] +#[cfg_attr( + feature = "guest", + derive( + golem_schema_derive::FromWire, + golem_schema_derive::IntoWire, + golem_schema_derive::WireSchema + ) +)] pub struct AccountId { pub uuid: Uuid, } @@ -159,6 +183,14 @@ impl From for Uuid { #[cfg_attr(feature = "full", derive(desert_rust::BinaryCodec))] #[cfg_attr(feature = "full", desert(evolution()))] #[schema(named = "golem.core.CardId")] +#[cfg_attr( + feature = "guest", + derive( + golem_schema_derive::FromWire, + golem_schema_derive::IntoWire, + golem_schema_derive::WireSchema + ) +)] pub struct CardId { pub uuid: Uuid, } @@ -187,6 +219,14 @@ impl From for Uuid { #[cfg_attr(feature = "full", desert(evolution()))] #[serde(rename_all = "camelCase")] #[schema(named = "golem.core.AgentId")] +#[cfg_attr( + feature = "guest", + derive( + golem_schema_derive::FromWire, + golem_schema_derive::IntoWire, + golem_schema_derive::WireSchema + ) +)] pub struct AgentId { pub component_id: ComponentId, pub agent_id: String, @@ -210,6 +250,14 @@ pub type OplogIndex = u64; #[cfg_attr(feature = "full", desert(evolution()))] #[serde(rename_all = "camelCase")] #[schema(named = "golem.core.PromiseId")] +#[cfg_attr( + feature = "guest", + derive( + golem_schema_derive::FromWire, + golem_schema_derive::IntoWire, + golem_schema_derive::WireSchema + ) +)] pub struct PromiseId { pub agent_id: AgentId, pub oplog_idx: OplogIndex, diff --git a/golem-schema/src/schema/canonical/binary.rs b/golem-schema/src/schema/canonical/binary.rs index 2110fc5aac..ae1697fc0c 100644 --- a/golem-schema/src/schema/canonical/binary.rs +++ b/golem-schema/src/schema/canonical/binary.rs @@ -31,25 +31,25 @@ use crate::schema::canonical::error::ParseError; use crate::schema::schema_value::BinaryValuePayload; use base64::Engine; use base64::engine::general_purpose::URL_SAFE_NO_PAD; -use regex::Regex; use serde_json::{Map, Value}; -use std::sync::OnceLock; const DATA_PREFIX: &str = "data:"; const BASE64_MARKER: &str = ";base64,"; -fn mime_regex() -> &'static Regex { - static RE: OnceLock = OnceLock::new(); - RE.get_or_init(|| { - Regex::new(r"^[A-Za-z0-9!#$&^_.+\-]+/[A-Za-z0-9!#$&^_.+\-]+$").expect("mime regex compiles") - }) +fn mime_token(s: &str) -> bool { + !s.is_empty() + && s.bytes() + .all(|c| c.is_ascii_alphanumeric() || b"!#$&^_.+-".contains(&c)) } fn validate_mime(mime: &str) -> Result<(), ParseError> { if mime.is_empty() { return Err(ParseError::BadFormat("empty mime_type".into())); } - if !mime_regex().is_match(mime) { + if !mime + .split_once('/') + .is_some_and(|(a, b)| mime_token(a) && mime_token(b)) + { return Err(ParseError::BadFormat("invalid mime_type".into())); } Ok(()) diff --git a/golem-schema/src/schema/canonical/quantity.rs b/golem-schema/src/schema/canonical/quantity.rs index be02695c53..f76a880592 100644 --- a/golem-schema/src/schema/canonical/quantity.rs +++ b/golem-schema/src/schema/canonical/quantity.rs @@ -38,18 +38,11 @@ use crate::schema::canonical::error::ParseError; use crate::schema::schema_type::QuantityValue; -use regex::Regex; use serde_json::{Map, Value}; -use std::sync::OnceLock; const MAX_ABS_SCALE_TEXT: i32 = 18; const MAX_NEGATIVE_SCALE_BODY_LEN: usize = 40; -fn unit_regex() -> &'static Regex { - static RE: OnceLock = OnceLock::new(); - RE.get_or_init(|| Regex::new(r"^[A-Za-z0-9%°µμ_/\-^]+$").expect("quantity unit regex compiles")) -} - pub fn to_text(payload: &QuantityValue) -> Result { if payload.mantissa == i64::MIN { return Err(ParseError::OutOfRange("quantity mantissa")); @@ -220,9 +213,7 @@ fn split_decimal_and_unit(s: &str) -> Result<(&str, &str), ParseError> { let decimal = &s[..i]; let mut unit_start = i; // Allow a single ASCII space between the decimal and the unit on input; - // reject two or more. A leading space without a following unit also - // reaches the unit regex which rejects empty input via the - // non-emptiness check below. + // reject two or more. An empty unit is allowed. if unit_start < bytes.len() && bytes[unit_start] == b' ' { unit_start += 1; if unit_start < bytes.len() && bytes[unit_start] == b' ' { @@ -238,7 +229,10 @@ fn validate_unit(unit: &str) -> Result<(), ParseError> { if unit.is_empty() { return Ok(()); } - if !unit_regex().is_match(unit) { + if !unit + .chars() + .all(|c| c.is_ascii_alphanumeric() || "%°µμ_/-^".contains(c)) + { return Err(ParseError::BadFormat(format!( "invalid characters in unit: {unit:?}" ))); diff --git a/golem-schema/src/schema/fingerprint.rs b/golem-schema/src/schema/fingerprint.rs index bb6e37ca50..2bb96d5ebe 100644 --- a/golem-schema/src/schema/fingerprint.rs +++ b/golem-schema/src/schema/fingerprint.rs @@ -852,9 +852,11 @@ mod tests { resolve_stream_element_schema_v1, schema_fingerprint_v1, }; use crate::schema::{ - MetadataEnvelope, NamedFieldType, PermissionCardSpec, Role, SchemaGraph, SchemaType, - SchemaTypeDef, TextRestrictions, TypeId, + MetadataEnvelope, NamedFieldType, PermissionCardSpec, SchemaGraph, SchemaType, + SchemaTypeDef, TypeId, }; + #[cfg(feature = "regex")] + use crate::schema::{Role, TextRestrictions}; use test_r::test; #[test] @@ -922,7 +924,11 @@ mod tests { .to_hex(), "3931585d2d02a2b7d5c99e3da1082ac8fe904c535e2700bd45e29a95ff2399fa" ); + } + #[test] + #[cfg(feature = "regex")] + fn v1_regex_golden_vector() { let constrained = SchemaType::Text { restrictions: TextRestrictions { languages: Some(vec!["fr".to_string(), "en".to_string()]), @@ -953,7 +959,10 @@ mod tests { blake3::hash(&constrained_bytes).to_hex().as_str(), "b985cdb5445862be90e8dca06bbfa9c46b50cf40edc84ed34205bb3a214c5bb0" ); + } + #[test] + fn v1_permission_card_golden_vector() { let permission_card = SchemaType::permission_card(PermissionCardSpec { polymorphic: true }); let permission_card_bytes = canonical_schema_bytes_v1(&SchemaGraph::empty(), Some(&permission_card)).unwrap(); diff --git a/golem-schema/src/schema/mod.rs b/golem-schema/src/schema/mod.rs index a2799bdef1..2578ccb9b6 100644 --- a/golem-schema/src/schema/mod.rs +++ b/golem-schema/src/schema/mod.rs @@ -50,7 +50,7 @@ pub use fingerprint::{ schema_fingerprint_v1, }; #[cfg(feature = "derive")] -pub use golem_schema_derive::{FromSchema, IntoSchema, Schema}; +pub use golem_schema_derive::{FromSchema, FromWire, IntoSchema, IntoWire, Schema, WireSchema}; pub use graph::{SchemaGraph, SchemaTypeDef, TypedSchemaValue}; pub use host_managed::{ HostManagedKind, HostManagedOccurrence, HostManagedTraversalError, RedactedSchemaValue, diff --git a/golem-schema/src/schema/render/json_value.rs b/golem-schema/src/schema/render/json_value.rs index a91fa8af22..76fdcab56c 100644 --- a/golem-schema/src/schema/render/json_value.rs +++ b/golem-schema/src/schema/render/json_value.rs @@ -453,7 +453,7 @@ fn encode_union( // Sanity check: the produced JSON should match the branch's // discriminator rule. Validation should have caught a tag/body // disagreement at construction time; this is the runtime safety net. - if !rule_matches(&branch.discriminator, &rendered) { + if !rule_matches(&branch.discriminator, &rendered)? { return Err(RenderError::UnionTagMismatch { tag: payload.tag.clone(), reason: format!( @@ -949,7 +949,7 @@ fn decode_union( // time; a runtime safety net catches the case where the value is bad. let mut matched: Vec<&UnionBranch> = Vec::new(); for branch in spec.branches.iter() { - if rule_matches(&branch.discriminator, json) { + if rule_matches(&branch.discriminator, json)? { matched.push(branch); } } @@ -975,8 +975,8 @@ fn decode_union( // ----------------------------------------------------------- discriminators /// Whether a [`DiscriminatorRule`] matches a raw JSON value. -fn rule_matches(rule: &DiscriminatorRule, json: &Value) -> bool { - match rule { +fn rule_matches(rule: &DiscriminatorRule, json: &Value) -> Result { + Ok(match rule { DiscriminatorRule::Prefix { prefix } => json .as_str() .map(|s| s.starts_with(prefix.as_str())) @@ -989,6 +989,9 @@ fn rule_matches(rule: &DiscriminatorRule, json: &Value) -> bool { .as_str() .map(|s| s.contains(substring.as_str())) .unwrap_or(false), + #[cfg(not(feature = "regex"))] + DiscriminatorRule::Regex { .. } => return Err(RenderError::Unsupported("feature `regex`")), + #[cfg(feature = "regex")] DiscriminatorRule::Regex { regex } => match (json.as_str(), regex::Regex::new(regex)) { (Some(s), Ok(re)) => re.is_match(s), _ => false, @@ -1005,7 +1008,7 @@ fn rule_matches(rule: &DiscriminatorRule, json: &Value) -> bool { .as_object() .map(|obj| !obj.contains_key(field_name.as_str())) .unwrap_or(false), - } + }) } fn rule_label(rule: &DiscriminatorRule) -> String { diff --git a/golem-schema/src/schema/tool/validation/mod.rs b/golem-schema/src/schema/tool/validation/mod.rs index 9fdc6fae3d..43a29d5a82 100644 --- a/golem-schema/src/schema/tool/validation/mod.rs +++ b/golem-schema/src/schema/tool/validation/mod.rs @@ -64,20 +64,18 @@ use crate::schema::schema_type::SchemaType; use crate::schema::schema_value::SchemaValue; use crate::schema::validation::value::{ValueError, validate_value}; use crate::schema::validation::well_formedness::{SchemaError, validate_graph, validate_root_type}; -use regex::Regex; use std::collections::HashSet; use std::fmt::{self, Display, Formatter}; -use std::sync::LazyLock; - -/// The identifier grammar shared by every identifier-like string in the tool -/// model: lowercase kebab-case, starting with a letter. -static IDENTIFIER_REGEX: LazyLock = LazyLock::new(|| { - Regex::new(r"^[a-z][a-z0-9]*(-[a-z0-9]+)*$").expect("invalid tool identifier regex") -}); /// Returns `true` if `s` is a valid tool identifier (`^[a-z][a-z0-9]*(-[a-z0-9]+)*$`). pub fn is_valid_identifier(s: &str) -> bool { - IDENTIFIER_REGEX.is_match(s) + s.as_bytes().first().is_some_and(u8::is_ascii_lowercase) + && s.split('-').all(|part| { + !part.is_empty() + && part + .bytes() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit()) + }) } /// A single producer-side construction-invariant violation. diff --git a/golem-schema/src/schema/validation/tests/value_tests.rs b/golem-schema/src/schema/validation/tests/value_tests.rs index 9e7a713a73..654e770ee7 100644 --- a/golem-schema/src/schema/validation/tests/value_tests.rs +++ b/golem-schema/src/schema/validation/tests/value_tests.rs @@ -108,12 +108,19 @@ fn leaf_paired() -> BoxedStrategy<(SchemaType, SchemaValue)> { }), SchemaValue::Path { path: p } )), - Just(( - SchemaType::url(UrlRestrictions::default()), - SchemaValue::Url { - url: "https://example.com/".to_string() - } - )), + Just(if cfg!(feature = "url") { + ( + SchemaType::url(UrlRestrictions::default()), + SchemaValue::Url { + url: "https://example.com/".to_string(), + }, + ) + } else { + ( + SchemaType::string(), + SchemaValue::String("https://example.com/".to_string()), + ) + }), Just(( SchemaType::datetime(), SchemaValue::Datetime { value: Utc::now() } @@ -928,6 +935,7 @@ fn nested_recursive_value_validates_every_level() { // --- URL restrictions --- +#[cfg(feature = "url")] fn url_with_restrictions(restrictions: UrlRestrictions) -> SchemaType { SchemaType::Url { restrictions, @@ -935,11 +943,13 @@ fn url_with_restrictions(restrictions: UrlRestrictions) -> SchemaType { } } +#[cfg(feature = "url")] fn url_value(s: &str) -> SchemaValue { SchemaValue::Url { url: s.to_string() } } #[test] +#[cfg(feature = "url")] fn url_unrestricted_accepts_any_well_formed_url() { let ty = url_with_restrictions(UrlRestrictions::default()); let graph = SchemaGraph::anonymous(ty.clone()); @@ -948,6 +958,7 @@ fn url_unrestricted_accepts_any_well_formed_url() { } #[test] +#[cfg(feature = "url")] fn url_invalid_syntax_is_reported() { let ty = url_with_restrictions(UrlRestrictions::default()); let graph = SchemaGraph::anonymous(ty.clone()); @@ -962,6 +973,7 @@ fn url_invalid_syntax_is_reported() { } #[test] +#[cfg(feature = "url")] fn url_empty_is_reported() { let ty = url_with_restrictions(UrlRestrictions::default()); let graph = SchemaGraph::anonymous(ty.clone()); @@ -975,6 +987,7 @@ fn url_empty_is_reported() { } #[test] +#[cfg(feature = "url")] fn url_scheme_allow_list_accepts_listed() { let ty = url_with_restrictions(UrlRestrictions { allowed_schemes: Some(vec!["https".to_string(), "wss".to_string()]), @@ -987,6 +1000,7 @@ fn url_scheme_allow_list_accepts_listed() { } #[test] +#[cfg(feature = "url")] fn url_scheme_allow_list_rejects_unlisted() { let ty = url_with_restrictions(UrlRestrictions { allowed_schemes: Some(vec!["https".to_string()]), @@ -1004,6 +1018,7 @@ fn url_scheme_allow_list_rejects_unlisted() { } #[test] +#[cfg(feature = "url")] fn url_host_allow_list_accepts_listed() { let ty = url_with_restrictions(UrlRestrictions { allowed_schemes: None, @@ -1017,6 +1032,7 @@ fn url_host_allow_list_accepts_listed() { } #[test] +#[cfg(feature = "url")] fn url_host_allow_list_rejects_unlisted() { let ty = url_with_restrictions(UrlRestrictions { allowed_schemes: None, @@ -1034,6 +1050,7 @@ fn url_host_allow_list_rejects_unlisted() { } #[test] +#[cfg(feature = "url")] fn url_host_allow_list_rejects_missing_host() { let ty = url_with_restrictions(UrlRestrictions { allowed_schemes: None, @@ -1052,6 +1069,7 @@ fn url_host_allow_list_rejects_missing_host() { } #[test] +#[cfg(feature = "url")] fn url_userinfo_confusion_does_not_bypass_host_allow_list() { // `https://example.com@attacker.com/` parses with host=`attacker.com` // (the `example.com` segment is userinfo). The validator must reject @@ -1073,6 +1091,7 @@ fn url_userinfo_confusion_does_not_bypass_host_allow_list() { } #[test] +#[cfg(feature = "url")] fn url_subdomain_is_not_implicitly_allowed_by_parent_host() { // Exact host match only — no wildcard/suffix semantics. let ty = url_with_restrictions(UrlRestrictions { diff --git a/golem-schema/src/schema/validation/tests/well_formedness_tests.rs b/golem-schema/src/schema/validation/tests/well_formedness_tests.rs index de4123f3c4..fdd0845e47 100644 --- a/golem-schema/src/schema/validation/tests/well_formedness_tests.rs +++ b/golem-schema/src/schema/validation/tests/well_formedness_tests.rs @@ -704,6 +704,7 @@ fn union_discriminator_overlap_prefix_suffix_is_reported() { } #[test] +#[cfg(feature = "regex")] fn union_discriminator_validation_rejects_only_reject_classifications() { fn graph(left: DiscriminatorRule, right: DiscriminatorRule) -> SchemaGraph { SchemaGraph::anonymous(SchemaType::union(UnionSpec { @@ -761,6 +762,7 @@ fn union_discriminator_validation_rejects_only_reject_classifications() { } #[test] +#[cfg(feature = "regex")] fn invalid_regex_on_union_branch_is_reported() { let graph = SchemaGraph::anonymous(SchemaType::union(UnionSpec { branches: vec![UnionBranch { @@ -831,6 +833,7 @@ fn inverted_binary_byte_range_is_reported() { } #[test] +#[cfg(feature = "regex")] fn invalid_text_regex_is_reported() { let graph = SchemaGraph::anonymous(SchemaType::text(TextRestrictions { languages: None, diff --git a/golem-schema/src/schema/validation/value.rs b/golem-schema/src/schema/validation/value.rs index 965ed36054..e1081b7a30 100644 --- a/golem-schema/src/schema/validation/value.rs +++ b/golem-schema/src/schema/validation/value.rs @@ -109,6 +109,11 @@ impl Display for ValuePath { /// All errors raised by [`validate_value`]. #[derive(Clone, Debug, PartialEq)] pub enum ValueError { + /// Validation requires an optional Cargo feature. + UnsupportedFeature { + path: ValuePath, + feature: &'static str, + }, ShapeMismatch { path: ValuePath, expected: String, @@ -272,6 +277,9 @@ pub enum ResultSide { impl Display for ValueError { fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { match self { + ValueError::UnsupportedFeature { path, feature } => { + write!(f, "validation at {path} requires feature `{feature}`") + } ValueError::ShapeMismatch { path, expected, @@ -1022,6 +1030,15 @@ fn check<'a>( }), Some(branch) => { path.push(ValuePathSegment::UnionBody); + #[cfg(not(feature = "regex"))] + if matches!(branch.discriminator, DiscriminatorRule::Regex { .. }) { + errors.push(ValueError::UnsupportedFeature { + path: path.snapshot(), + feature: "regex", + }); + path.pop(); + return; + } let mut sub_errors = Vec::new(); check(index, &branch.body, &vp.body, path, &mut sub_errors); errors.extend(sub_errors); @@ -1133,6 +1150,14 @@ fn check_text( found: char_len, }); } + #[cfg(not(feature = "regex"))] + if restrictions.regex.is_some() { + errors.push(ValueError::UnsupportedFeature { + path: path.snapshot(), + feature: "regex", + }); + } + #[cfg(feature = "regex")] if let Some(regex) = &restrictions.regex && let Ok(compiled) = regex::Regex::new(regex.as_str()) && !compiled.is_match(payload.text.as_str()) @@ -1203,6 +1228,20 @@ fn check_path(spec: &PathSpec, p: &str, path: &mut ValuePath, errors: &mut Vec, +) { + errors.push(ValueError::UnsupportedFeature { + path: path.snapshot(), + feature: "url", + }); +} + +#[cfg(feature = "url")] fn check_url( spec: &UrlRestrictions, url: &str, @@ -1365,6 +1404,9 @@ fn discriminator_matches(index: &GraphIndex, branch: &UnionBranch, body: &Schema DiscriminatorRule::Contains { substring } => string_view(index, &branch.body, body) .map(|s| s.contains(substring.as_str())) .unwrap_or(false), + #[cfg(not(feature = "regex"))] + DiscriminatorRule::Regex { .. } => false, + #[cfg(feature = "regex")] DiscriminatorRule::Regex { regex } => { let Some(s) = string_view(index, &branch.body, body) else { return false; diff --git a/golem-schema/src/schema/validation/well_formedness.rs b/golem-schema/src/schema/validation/well_formedness.rs index 125ec851a7..5afa707b28 100644 --- a/golem-schema/src/schema/validation/well_formedness.rs +++ b/golem-schema/src/schema/validation/well_formedness.rs @@ -27,6 +27,8 @@ use std::fmt::{self, Display, Formatter}; /// All structural errors that can be raised by [`validate_graph`]. #[derive(Clone, Debug, PartialEq, Eq)] pub enum SchemaError { + /// Validation requires an optional Cargo feature. + UnsupportedFeature(&'static str), DuplicateTypeId(TypeId), DanglingRef(TypeId), /// A named reference whose alias chain is a pure cycle @@ -107,6 +109,9 @@ pub enum SchemaError { impl Display for SchemaError { fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { match self { + SchemaError::UnsupportedFeature(feature) => { + write!(f, "schema requires feature `{feature}`") + } SchemaError::DuplicateTypeId(id) => write!(f, "duplicate type id `{id}`"), SchemaError::DanglingRef(id) => write!(f, "dangling type reference `{id}`"), SchemaError::RecursiveAlias(id) => { @@ -559,6 +564,11 @@ fn check_text_restrictions(restrictions: &TextRestrictions, errors: &mut Vec) { } fn check_url_spec(_spec: &UrlRestrictions, _errors: &mut Vec) { - // UrlRestrictions has no regex today; nothing to validate beyond - // structural shape. + // URL schema structure has no constraints requiring the URL parser. } fn validate_union( @@ -646,6 +655,12 @@ fn check_union_branch(graph: &SchemaGraph, branch: &UnionBranch, errors: &mut Ve tag: branch.tag.clone(), }); } + #[cfg(not(feature = "regex"))] + { + let _ = regex; + errors.push(SchemaError::UnsupportedFeature("regex")); + } + #[cfg(feature = "regex")] if regex.is_empty() { errors.push(SchemaError::InvalidRegex { tag: branch.tag.clone(), diff --git a/golem-schema/src/schema/wit/direct.rs b/golem-schema/src/schema/wit/direct.rs new file mode 100644 index 0000000000..e7dc4d6105 --- /dev/null +++ b/golem-schema/src/schema/wit/direct.rs @@ -0,0 +1,1391 @@ +// Copyright 2024-2026 Golem Cloud +// +// Licensed under the Golem Source License v1.1 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://license.golem.cloud/LICENSE +// +// 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. + +//! Concrete guest values converted directly to the canonical wire arena. +//! +//! These conversions check shape and affine ownership, not schema constraints. +//! The caller must validate the input against its declared schema at the host +//! boundary. No schema graph is needed to convert a statically known Rust type. + +use super::wire::ValueNodeIndex; +use super::{GuestPermissionCardHandle, GuestQuotaTokenHandle, GuestSecretHandle, wire}; +use crate::schema::SchemaValueStream; +use crate::schema::{Quantity, QuantityUnit, QuantityValue}; +use std::collections::{BTreeMap, HashMap, HashSet}; +use std::rc::Rc; + +/// Builds the flat WIT schema arena used alongside directly encoded values. +/// Named definitions are reserved before their bodies are appended, allowing +/// derived recursive types to refer to themselves without constructing a +/// recursive schema model. +#[derive(Default)] +pub struct WireSchemaBuilder { + type_nodes: Vec, + defs: Vec, + named: HashMap, +} + +impl WireSchemaBuilder { + pub fn push(&mut self, body: wire::SchemaTypeBody) -> wire::TypeNodeIndex { + self.push_with_metadata(body, empty_metadata()) + } + + pub fn push_with_metadata( + &mut self, + body: wire::SchemaTypeBody, + metadata: wire::MetadataEnvelope, + ) -> wire::TypeNodeIndex { + let index = self.type_nodes.len() as wire::TypeNodeIndex; + self.type_nodes + .push(wire::SchemaTypeNode { body, metadata }); + index + } + + pub fn reserve(&mut self, id: String, name: Option) -> (wire::DefIndex, bool) { + if let Some(index) = self.named.get(&id) { + return (*index, false); + } + let index = self.defs.len() as wire::DefIndex; + self.named.insert(id.clone(), index); + self.defs.push(wire::SchemaTypeDef { id, name, body: -1 }); + (index, true) + } + + pub fn commit(&mut self, definition: wire::DefIndex, body: wire::TypeNodeIndex) { + self.defs[definition as usize].body = body; + } + + pub fn reference(&mut self, definition: wire::DefIndex) -> wire::TypeNodeIndex { + self.push(wire::SchemaTypeBody::RefType(definition)) + } + + pub fn node(&self, index: wire::TypeNodeIndex) -> Option<&wire::SchemaTypeNode> { + self.type_nodes.get(usize::try_from(index).ok()?) + } + + pub fn resolve(&self, mut index: wire::TypeNodeIndex) -> Option<&wire::SchemaTypeNode> { + for _ in 0..=self.defs.len() { + let node = self.node(index)?; + match node.body { + wire::SchemaTypeBody::RefType(definition) => { + index = self.defs.get(usize::try_from(definition).ok()?)?.body; + } + _ => return Some(node), + } + } + None + } + + pub fn finish(self, root: wire::TypeNodeIndex) -> wire::SchemaGraph { + wire::SchemaGraph { + type_nodes: self.type_nodes, + defs: self.defs, + root, + } + } +} + +pub fn empty_metadata() -> wire::MetadataEnvelope { + wire::MetadataEnvelope { + doc: None, + aliases: Vec::new(), + examples: Vec::new(), + deprecated: None, + role: None, + } +} + +/// Appends the concrete type's schema directly to a shared wire arena. +pub trait WireSchema { + const IS_UNIT: bool = false; + + fn append_schema(builder: &mut WireSchemaBuilder) -> wire::TypeNodeIndex; + + fn contains_stream(seen: &mut HashSet<&'static str>) -> bool { + let _ = seen; + false + } + + fn wire_type_id() -> String { + std::any::type_name::().replace("::", ".") + } +} + +pub fn schema() -> wire::SchemaGraph { + let mut builder = WireSchemaBuilder::default(); + let root = T::append_schema(&mut builder); + builder.finish(root) +} + +macro_rules! wire_schema_scalar { + ($($ty:ty => $body:expr),* $(,)?) => {$( + impl WireSchema for $ty { + fn append_schema(builder: &mut WireSchemaBuilder) -> wire::TypeNodeIndex { + builder.push($body) + } + } + )*}; +} + +wire_schema_scalar! { + bool => wire::SchemaTypeBody::BoolType, + i8 => wire::SchemaTypeBody::S8Type(None), i16 => wire::SchemaTypeBody::S16Type(None), + i32 => wire::SchemaTypeBody::S32Type(None), i64 => wire::SchemaTypeBody::S64Type(None), + u8 => wire::SchemaTypeBody::U8Type(None), u16 => wire::SchemaTypeBody::U16Type(None), + u32 => wire::SchemaTypeBody::U32Type(None), u64 => wire::SchemaTypeBody::U64Type(None), + f32 => wire::SchemaTypeBody::F32Type(None), f64 => wire::SchemaTypeBody::F64Type(None), + char => wire::SchemaTypeBody::CharType, String => wire::SchemaTypeBody::StringType, + str => wire::SchemaTypeBody::StringType, + chrono::DateTime => wire::SchemaTypeBody::DatetimeType, + std::time::Duration => wire::SchemaTypeBody::DurationType, +} + +impl IntoWire for chrono::DateTime { + fn write_wire(&self, writer: &mut WireWriter) -> Result { + Ok( + writer.push(wire::SchemaValueNode::DatetimeValue(wire::Datetime { + seconds: self.timestamp(), + nanoseconds: self.timestamp_subsec_nanos(), + })), + ) + } +} + +impl FromWire for chrono::DateTime { + fn read_wire(reader: &mut WireReader, index: ValueNodeIndex) -> Result { + let wire::SchemaValueNode::DatetimeValue(value) = reader.take(index)? else { + return Err(WireError::Shape("datetime")); + }; + super::datetime_from_wire(&value).ok_or(WireError::Shape("valid datetime")) + } +} + +impl IntoWire for std::time::Duration { + fn write_wire(&self, writer: &mut WireWriter) -> Result { + Ok(writer.push(wire::SchemaValueNode::DurationValue( + wire::DurationValuePayload { + nanoseconds: self.as_nanos().min(i64::MAX as u128) as i64, + }, + ))) + } +} + +impl FromWire for std::time::Duration { + fn read_wire(reader: &mut WireReader, index: ValueNodeIndex) -> Result { + let wire::SchemaValueNode::DurationValue(value) = reader.take(index)? else { + return Err(WireError::Shape("duration")); + }; + let nanos = u64::try_from(value.nanoseconds) + .map_err(|_| WireError::Shape("nonnegative duration"))?; + Ok(Self::from_nanos(nanos)) + } +} + +#[cfg(feature = "url")] +impl WireSchema for url::Url { + fn append_schema(builder: &mut WireSchemaBuilder) -> wire::TypeNodeIndex { + builder.push(wire::SchemaTypeBody::UrlType(wire::UrlRestrictions { + allowed_schemes: None, + allowed_hosts: None, + })) + } +} + +#[cfg(feature = "url")] +impl IntoWire for url::Url { + fn write_wire(&self, writer: &mut WireWriter) -> Result { + Ok(writer.push(wire::SchemaValueNode::UrlValue(self.to_string()))) + } +} + +#[cfg(feature = "url")] +impl FromWire for url::Url { + fn read_wire(reader: &mut WireReader, index: ValueNodeIndex) -> Result { + let wire::SchemaValueNode::UrlValue(value) = reader.take(index)? else { + return Err(WireError::Shape("url")); + }; + Self::parse(&value).map_err(|_| WireError::Shape("valid url")) + } +} + +impl WireSchema for uuid::Uuid { + fn append_schema(builder: &mut WireSchemaBuilder) -> wire::TypeNodeIndex { + let (definition, fresh) = builder.reserve("uuid.Uuid".into(), Some("uuid".into())); + if fresh { + let high = u64::append_schema(builder); + let low = u64::append_schema(builder); + let body = builder.push(wire::SchemaTypeBody::RecordType(vec![ + wire::NamedFieldType { + name: "high-bits".into(), + body: high, + metadata: empty_metadata(), + }, + wire::NamedFieldType { + name: "low-bits".into(), + body: low, + metadata: empty_metadata(), + }, + ])); + builder.commit(definition, body); + } + builder.reference(definition) + } + + fn wire_type_id() -> String { + "uuid.Uuid".into() + } +} + +impl IntoWire for uuid::Uuid { + fn write_wire(&self, writer: &mut WireWriter) -> Result { + let (high, low) = self.as_u64_pair(); + let high = high.write_wire(writer)?; + let low = low.write_wire(writer)?; + Ok(writer.push(wire::SchemaValueNode::RecordValue(vec![high, low]))) + } +} + +impl FromWire for uuid::Uuid { + fn read_wire(reader: &mut WireReader, index: ValueNodeIndex) -> Result { + let wire::SchemaValueNode::RecordValue(fields) = reader.take(index)? else { + return Err(WireError::Shape("uuid record")); + }; + let [high, low] = fields.as_slice() else { + return Err(WireError::Shape("two uuid fields")); + }; + Ok(Self::from_u64_pair( + u64::read_wire(reader, *high)?, + u64::read_wire(reader, *low)?, + )) + } +} + +impl WireSchema for Quantity { + fn append_schema(builder: &mut WireSchemaBuilder) -> wire::TypeNodeIndex { + builder.push(wire::SchemaTypeBody::QuantityType(wire::QuantitySpec { + base_unit: U::base_unit().to_string(), + allowed_suffixes: U::allowed_suffixes() + .iter() + .map(|value| (*value).to_string()) + .collect(), + min: None, + max: None, + })) + } +} + +impl IntoWire for Quantity { + fn write_wire(&self, writer: &mut WireWriter) -> Result { + let value = self.as_quantity_value(); + Ok(writer.push(wire::SchemaValueNode::QuantityValueNode( + wire::QuantityValue { + mantissa: value.mantissa, + scale: value.scale, + unit: value.unit.clone(), + }, + ))) + } +} + +impl FromWire for Quantity { + fn read_wire(reader: &mut WireReader, index: ValueNodeIndex) -> Result { + let wire::SchemaValueNode::QuantityValueNode(value) = reader.take(index)? else { + return Err(WireError::Shape("quantity")); + }; + Quantity::from_quantity_value(QuantityValue { + mantissa: value.mantissa, + scale: value.scale, + unit: value.unit, + }) + .map_err(|_| WireError::Shape("quantity unit")) + } +} + +impl WireSchema for std::path::PathBuf { + fn append_schema(builder: &mut WireSchemaBuilder) -> wire::TypeNodeIndex { + builder.push(wire::SchemaTypeBody::PathType(wire::PathSpec { + direction: wire::PathDirection::InOut, + kind: wire::PathKind::Any, + allowed_mime_types: None, + allowed_extensions: None, + })) + } +} + +impl FromWire for std::path::PathBuf { + fn read_wire(reader: &mut WireReader, index: ValueNodeIndex) -> Result { + match reader.take(index)? { + wire::SchemaValueNode::PathValue(path) => Ok(path.into()), + _ => Err(WireError::Shape("path")), + } + } +} + +impl IntoWire for std::path::PathBuf { + fn write_wire(&self, writer: &mut WireWriter) -> Result { + Ok(writer.push(wire::SchemaValueNode::PathValue( + self.to_string_lossy().into_owned(), + ))) + } +} + +impl WireSchema for Box { + const IS_UNIT: bool = T::IS_UNIT; + + fn contains_stream(seen: &mut HashSet<&'static str>) -> bool { + T::contains_stream(seen) + } + + fn append_schema(builder: &mut WireSchemaBuilder) -> wire::TypeNodeIndex { + T::append_schema(builder) + } +} +impl WireSchema for Vec { + fn contains_stream(seen: &mut HashSet<&'static str>) -> bool { + T::contains_stream(seen) + } + + fn append_schema(builder: &mut WireSchemaBuilder) -> wire::TypeNodeIndex { + let element = T::append_schema(builder); + builder.push(wire::SchemaTypeBody::ListType(element)) + } +} +impl WireSchema for Option { + fn contains_stream(seen: &mut HashSet<&'static str>) -> bool { + T::contains_stream(seen) + } + + fn append_schema(builder: &mut WireSchemaBuilder) -> wire::TypeNodeIndex { + let inner = T::append_schema(builder); + builder.push(wire::SchemaTypeBody::OptionType(inner)) + } +} +impl WireSchema for Result { + fn contains_stream(seen: &mut HashSet<&'static str>) -> bool { + T::contains_stream(seen) || E::contains_stream(seen) + } + + fn append_schema(builder: &mut WireSchemaBuilder) -> wire::TypeNodeIndex { + let ok = if T::IS_UNIT { + None + } else { + Some(T::append_schema(builder)) + }; + let err = if E::IS_UNIT { + None + } else { + Some(E::append_schema(builder)) + }; + builder.push(wire::SchemaTypeBody::ResultType(wire::ResultSpec { + ok, + err, + })) + } +} +impl WireSchema for () { + const IS_UNIT: bool = true; + + fn append_schema(builder: &mut WireSchemaBuilder) -> wire::TypeNodeIndex { + builder.push(wire::SchemaTypeBody::TupleType(Vec::new())) + } +} +impl WireSchema for HashMap { + fn contains_stream(seen: &mut HashSet<&'static str>) -> bool { + K::contains_stream(seen) || V::contains_stream(seen) + } + + fn append_schema(builder: &mut WireSchemaBuilder) -> wire::TypeNodeIndex { + let key = K::append_schema(builder); + let value = V::append_schema(builder); + builder.push(wire::SchemaTypeBody::MapType(wire::MapSpec { key, value })) + } +} +impl WireSchema for BTreeMap { + fn contains_stream(seen: &mut HashSet<&'static str>) -> bool { + K::contains_stream(seen) || V::contains_stream(seen) + } + + fn append_schema(builder: &mut WireSchemaBuilder) -> wire::TypeNodeIndex { + let key = K::append_schema(builder); + let value = V::append_schema(builder); + builder.push(wire::SchemaTypeBody::MapType(wire::MapSpec { key, value })) + } +} + +macro_rules! wire_map { + ($map:ident, $($key_bound:tt)+) => { + impl IntoWire for $map { + fn preflight(&self, resources: &mut WirePreflight) -> Result<(), WireError> { + for (key, value) in self { + key.preflight(resources)?; + value.preflight(resources)?; + } + Ok(()) + } + async fn prepare_wire(&self) -> Result<(), WireError> { + for (key, value) in self { + key.prepare_wire().await?; + value.prepare_wire().await?; + } + Ok(()) + } + fn write_wire(&self, writer: &mut WireWriter) -> Result { + let entries = self + .iter() + .map(|(key, value)| { + Ok(wire::MapEntry { + key: key.write_wire(writer)?, + value: value.write_wire(writer)?, + }) + }) + .collect::>()?; + Ok(writer.push(wire::SchemaValueNode::MapValue(entries))) + } + } + impl FromWire for $map { + fn read_wire( + reader: &mut WireReader, + index: ValueNodeIndex, + ) -> Result { + let wire::SchemaValueNode::MapValue(entries) = reader.take(index)? else { + return Err(WireError::Shape("map")); + }; + entries + .into_iter() + .map(|entry| { + Ok(( + K::read_wire(reader, entry.key)?, + V::read_wire(reader, entry.value)?, + )) + }) + .collect() + } + } + }; +} +wire_map!(BTreeMap, Ord); +wire_map!(HashMap, Eq + std::hash::Hash); + +impl WireSchema for std::ops::Bound { + fn contains_stream(seen: &mut HashSet<&'static str>) -> bool { + T::contains_stream(seen) + } + + fn append_schema(builder: &mut WireSchemaBuilder) -> wire::TypeNodeIndex { + let included = T::append_schema(builder); + let excluded = T::append_schema(builder); + builder.push(wire::SchemaTypeBody::VariantType(vec![ + wire::VariantCaseType { + name: "included".to_string(), + payload: Some(included), + metadata: empty_metadata(), + }, + wire::VariantCaseType { + name: "excluded".to_string(), + payload: Some(excluded), + metadata: empty_metadata(), + }, + wire::VariantCaseType { + name: "unbounded".to_string(), + payload: None, + metadata: empty_metadata(), + }, + ])) + } +} + +macro_rules! wire_schema_tuple { + ($($ty:ident),+) => { impl<$($ty: WireSchema),+> WireSchema for ($($ty,)+) { + fn contains_stream(seen: &mut HashSet<&'static str>) -> bool { + false $(|| $ty::contains_stream(seen))+ + } + + fn append_schema(builder: &mut WireSchemaBuilder) -> wire::TypeNodeIndex { + let elements = vec![$($ty::append_schema(builder)),+]; + builder.push(wire::SchemaTypeBody::TupleType(elements)) + } + }}; +} +wire_schema_tuple!(A); +wire_schema_tuple!(A, B); +wire_schema_tuple!(A, B, C); +wire_schema_tuple!(A, B, C, D); +wire_schema_tuple!(A, B, C, D, E); +wire_schema_tuple!(A, B, C, D, E, F); +wire_schema_tuple!(A, B, C, D, E, F, G); +wire_schema_tuple!(A, B, C, D, E, F, G, H); + +#[derive(Debug, PartialEq, Eq, thiserror::Error)] +pub enum WireError { + #[error("expected wire {0}")] + Shape(&'static str), + #[error("wire value index {0} is out of bounds")] + OutOfBounds(ValueNodeIndex), + #[error("wire value index {0} is referenced more than once")] + AliasedNode(ValueNodeIndex), + #[error("wire resource at index {0} is not reachable from the root")] + UnreachableResource(ValueNodeIndex), + #[error("{0} resource appears more than once")] + AliasedResource(&'static str), + #[error("{0} resource has already been transferred")] + ConsumedResource(&'static str), + #[error("{0} resource is not allowed in this input")] + ForbiddenResource(&'static str), + #[error("native streams require asynchronous wire encoding")] + AsyncStream, +} + +/// Decode a concrete value, consuming every visited wire node exactly once. +pub trait FromWire: Sized { + fn read_wire(reader: &mut WireReader, index: ValueNodeIndex) -> Result; + + fn read_result_payload( + reader: &mut WireReader, + index: Option, + ) -> Result { + Self::read_wire(reader, index.ok_or(WireError::Shape("result payload"))?) + } +} + +/// Encode a concrete value without constructing an owned schema value. +/// +/// Implementations must visit every resource in `preflight` before moving any +/// handles in `write_wire`. After a successful preflight, `write_wire` must not +/// fail except when a resource was concurrently transferred through an alias. +#[allow(async_fn_in_trait)] +pub trait IntoWire { + fn preflight(&self, _resources: &mut WirePreflight) -> Result<(), WireError> { + Ok(()) + } + + fn write_wire(&self, writer: &mut WireWriter) -> Result; + + /// Wrap native stream endpoints without reading from them. This is called + /// only after the complete value has passed resource preflight. + async fn prepare_wire(&self) -> Result<(), WireError> { + Ok(()) + } + + fn write_result_payload( + &self, + writer: &mut WireWriter, + ) -> Result, WireError> { + self.write_wire(writer).map(Some) + } +} + +enum SnapshotNode { + Value(wire::SchemaValueNode), + Secret(GuestSecretHandle), + QuotaToken(GuestQuotaTokenHandle), + PermissionCard(GuestPermissionCardHandle), + Stream(SchemaValueStream), +} + +/// Reusable owned backing for independent direct decoders. Capability nodes +/// share their take-once cells; ordinary nodes are copied into each reader. +pub struct WireSnapshot { + nodes: Vec, +} + +impl WireSnapshot { + pub fn new(nodes: Vec) -> Self { + let nodes = nodes + .into_iter() + .map(|node| match node { + wire::SchemaValueNode::SecretValue(value) => { + SnapshotNode::Secret(GuestSecretHandle::new(value)) + } + wire::SchemaValueNode::QuotaTokenHandle(value) => { + SnapshotNode::QuotaToken(GuestQuotaTokenHandle::new(value)) + } + wire::SchemaValueNode::PermissionCardHandle(value) => { + SnapshotNode::PermissionCard(GuestPermissionCardHandle::new(value)) + } + wire::SchemaValueNode::StreamValue(value) => { + SnapshotNode::Stream(SchemaValueStream::from_wrapped(value)) + } + value => SnapshotNode::Value(value), + }) + .collect(); + Self { nodes } + } + + pub fn reader(self: &Rc) -> WireReader { + WireReader { + visited: vec![false; self.nodes.len()], + backing: ReaderBacking::Shared(Rc::clone(self)), + } + } +} + +enum ReaderBacking { + Owned(Vec>), + Shared(Rc), +} + +/// Reads one independent view of a [`WireSnapshot`]. +pub struct WireReader { + backing: ReaderBacking, + visited: Vec, +} + +impl WireReader { + pub fn new(nodes: Vec) -> Self { + Self { + backing: ReaderBacking::Owned( + WireSnapshot::new(nodes) + .nodes + .into_iter() + .map(Some) + .collect(), + ), + visited: Vec::new(), + } + } + + pub fn take(&mut self, index: ValueNodeIndex) -> Result { + let node = self.visit(index)?; + match node { + SnapshotNode::Value(value) => Ok(value), + _ => Err(WireError::Shape("non-resource value")), + } + } + + pub fn push(&mut self, node: wire::SchemaValueNode) -> Result { + let ReaderBacking::Owned(nodes) = &mut self.backing else { + return Err(WireError::Shape("cannot extend a shared wire reader")); + }; + let index = nodes.len() as ValueNodeIndex; + nodes.push(Some(SnapshotNode::Value(node))); + Ok(index) + } + + fn visit(&mut self, index: ValueNodeIndex) -> Result { + let snapshot = match &mut self.backing { + ReaderBacking::Owned(nodes) => { + return nodes + .get_mut(index as usize) + .ok_or(WireError::OutOfBounds(index))? + .take() + .ok_or(WireError::AliasedNode(index)); + } + ReaderBacking::Shared(snapshot) => snapshot, + }; + let visited = self + .visited + .get_mut(index as usize) + .ok_or(WireError::OutOfBounds(index))?; + if std::mem::replace(visited, true) { + return Err(WireError::AliasedNode(index)); + } + Ok(match &snapshot.nodes[index as usize] { + SnapshotNode::Value(value) => SnapshotNode::Value(clone_value_node(value)), + SnapshotNode::Secret(value) => SnapshotNode::Secret(value.clone()), + SnapshotNode::QuotaToken(value) => SnapshotNode::QuotaToken(value.clone()), + SnapshotNode::PermissionCard(value) => SnapshotNode::PermissionCard(value.clone()), + SnapshotNode::Stream(value) => SnapshotNode::Stream(value.clone()), + }) + } + + fn secret(&mut self, index: ValueNodeIndex) -> Result { + match self.visit(index)? { + SnapshotNode::Secret(value) => Ok(value.clone()), + _ => Err(WireError::Shape("secret")), + } + } + + fn quota_token(&mut self, index: ValueNodeIndex) -> Result { + match self.visit(index)? { + SnapshotNode::QuotaToken(value) => Ok(value.clone()), + _ => Err(WireError::Shape("quota-token")), + } + } + + fn permission_card( + &mut self, + index: ValueNodeIndex, + ) -> Result { + match self.visit(index)? { + SnapshotNode::PermissionCard(value) => Ok(value.clone()), + _ => Err(WireError::Shape("permission-card")), + } + } + + fn stream(&mut self, index: ValueNodeIndex) -> Result { + match self.visit(index)? { + SnapshotNode::Stream(value) => Ok(value.clone()), + _ => Err(WireError::Shape("stream")), + } + } + + /// Consume an unbound field, releasing its resources and checking its edges + /// without constructing a recursive value. The explicit stack also bounds + /// stack usage for deeply nested fields not consumed by a typed decoder. + pub fn discard(&mut self, index: ValueNodeIndex) -> Result<(), WireError> { + let mut pending = vec![index]; + while let Some(index) = pending.pop() { + let SnapshotNode::Value(node) = self.visit(index)? else { + continue; + }; + match node { + wire::SchemaValueNode::RecordValue(children) + | wire::SchemaValueNode::TupleValue(children) + | wire::SchemaValueNode::ListValue(children) + | wire::SchemaValueNode::FixedListValue(children) => pending.extend(children), + wire::SchemaValueNode::MapValue(entries) => { + for entry in entries { + pending.extend([entry.key, entry.value]); + } + } + wire::SchemaValueNode::VariantValue(value) => pending.extend(value.payload), + wire::SchemaValueNode::OptionValue(value) => pending.extend(value), + wire::SchemaValueNode::ResultValue(value) => match value { + wire::ResultValuePayload::OkValue(value) + | wire::ResultValuePayload::ErrValue(value) => pending.extend(value), + }, + wire::SchemaValueNode::UnionValue(value) => pending.push(value.body), + _ => {} + } + } + Ok(()) + } + + pub fn finish(self) -> Result<(), WireError> { + match self.backing { + ReaderBacking::Owned(nodes) => { + for (index, node) in nodes.iter().enumerate() { + if node + .as_ref() + .is_some_and(|node| !matches!(node, SnapshotNode::Value(_))) + { + return Err(WireError::UnreachableResource(index as ValueNodeIndex)); + } + } + } + ReaderBacking::Shared(snapshot) => { + for (index, (node, visited)) in + snapshot.nodes.iter().zip(self.visited.iter()).enumerate() + { + if !visited && !matches!(node, SnapshotNode::Value(_)) { + return Err(WireError::UnreachableResource(index as ValueNodeIndex)); + } + } + } + } + Ok(()) + } +} + +fn clone_value_node(node: &wire::SchemaValueNode) -> wire::SchemaValueNode { + match node { + wire::SchemaValueNode::BoolValue(v) => wire::SchemaValueNode::BoolValue(*v), + wire::SchemaValueNode::S8Value(v) => wire::SchemaValueNode::S8Value(*v), + wire::SchemaValueNode::S16Value(v) => wire::SchemaValueNode::S16Value(*v), + wire::SchemaValueNode::S32Value(v) => wire::SchemaValueNode::S32Value(*v), + wire::SchemaValueNode::S64Value(v) => wire::SchemaValueNode::S64Value(*v), + wire::SchemaValueNode::U8Value(v) => wire::SchemaValueNode::U8Value(*v), + wire::SchemaValueNode::U16Value(v) => wire::SchemaValueNode::U16Value(*v), + wire::SchemaValueNode::U32Value(v) => wire::SchemaValueNode::U32Value(*v), + wire::SchemaValueNode::U64Value(v) => wire::SchemaValueNode::U64Value(*v), + wire::SchemaValueNode::F32Value(v) => wire::SchemaValueNode::F32Value(*v), + wire::SchemaValueNode::F64Value(v) => wire::SchemaValueNode::F64Value(*v), + wire::SchemaValueNode::CharValue(v) => wire::SchemaValueNode::CharValue(*v), + wire::SchemaValueNode::StringValue(v) => wire::SchemaValueNode::StringValue(v.clone()), + wire::SchemaValueNode::RecordValue(v) => wire::SchemaValueNode::RecordValue(v.clone()), + wire::SchemaValueNode::VariantValue(v) => wire::SchemaValueNode::VariantValue(*v), + wire::SchemaValueNode::EnumValue(v) => wire::SchemaValueNode::EnumValue(*v), + wire::SchemaValueNode::FlagsValue(v) => wire::SchemaValueNode::FlagsValue(v.clone()), + wire::SchemaValueNode::TupleValue(v) => wire::SchemaValueNode::TupleValue(v.clone()), + wire::SchemaValueNode::ListValue(v) => wire::SchemaValueNode::ListValue(v.clone()), + wire::SchemaValueNode::FixedListValue(v) => { + wire::SchemaValueNode::FixedListValue(v.clone()) + } + wire::SchemaValueNode::MapValue(v) => wire::SchemaValueNode::MapValue(v.clone()), + wire::SchemaValueNode::OptionValue(v) => wire::SchemaValueNode::OptionValue(*v), + wire::SchemaValueNode::ResultValue(v) => wire::SchemaValueNode::ResultValue(*v), + wire::SchemaValueNode::TextValue(v) => wire::SchemaValueNode::TextValue(v.clone()), + wire::SchemaValueNode::BinaryValue(v) => wire::SchemaValueNode::BinaryValue(v.clone()), + wire::SchemaValueNode::PathValue(v) => wire::SchemaValueNode::PathValue(v.clone()), + wire::SchemaValueNode::UrlValue(v) => wire::SchemaValueNode::UrlValue(v.clone()), + wire::SchemaValueNode::DatetimeValue(v) => wire::SchemaValueNode::DatetimeValue(*v), + wire::SchemaValueNode::DurationValue(v) => wire::SchemaValueNode::DurationValue(*v), + wire::SchemaValueNode::QuantityValueNode(v) => { + wire::SchemaValueNode::QuantityValueNode(v.clone()) + } + wire::SchemaValueNode::UnionValue(v) => wire::SchemaValueNode::UnionValue(v.clone()), + wire::SchemaValueNode::SecretValue(_) + | wire::SchemaValueNode::QuotaTokenHandle(_) + | wire::SchemaValueNode::PermissionCardHandle(_) + | wire::SchemaValueNode::StreamValue(_) => { + unreachable!("resources are snapshot separately") + } + } +} + +pub fn decode(tree: wire::SchemaValueTree) -> Result { + let mut reader = WireReader::new(tree.value_nodes); + let value = T::read_wire(&mut reader, tree.root)?; + reader.finish()?; + Ok(value) +} + +#[derive(Default)] +pub struct WireWriter { + nodes: Vec, +} + +impl WireWriter { + pub fn push(&mut self, node: wire::SchemaValueNode) -> ValueNodeIndex { + let index = self.nodes.len() as ValueNodeIndex; + self.nodes.push(node); + index + } + + pub fn finish(self, root: ValueNodeIndex) -> wire::SchemaValueTree { + wire::SchemaValueTree { + value_nodes: self.nodes, + root, + } + } +} + +/// A resource-only preflight preserves live handles when aliasing or an +/// already-consumed handle would make an encode fail. It contains no value or +/// schema model and does not poll any stream. +#[derive(Default)] +pub struct WirePreflight { + seen: HashSet<(&'static str, *const ())>, + allow_native_streams: bool, +} + +impl WirePreflight { + pub fn asynchronous() -> Self { + Self { + allow_native_streams: true, + ..Self::default() + } + } + + pub fn reject_quota_tokens(&self) -> Result<(), WireError> { + if self.seen.iter().any(|(kind, _)| *kind == "quota-token") { + Err(WireError::ForbiddenResource("quota-token")) + } else { + Ok(()) + } + } + + fn resource( + &mut self, + kind: &'static str, + id: *const (), + present: bool, + ) -> Result<(), WireError> { + if !present { + return Err(WireError::ConsumedResource(kind)); + } + if !self.seen.insert((kind, id)) { + return Err(WireError::AliasedResource(kind)); + } + Ok(()) + } +} + +pub fn encode(value: &T) -> Result { + let mut preflight = WirePreflight::default(); + value.preflight(&mut preflight)?; + write(value) +} + +pub async fn encode_async( + value: &T, +) -> Result { + let mut preflight = WirePreflight::asynchronous(); + value.preflight(&mut preflight)?; + value.prepare_wire().await?; + write(value) +} + +fn write(value: &T) -> Result { + let mut writer = WireWriter::default(); + let root = value.write_wire(&mut writer)?; + Ok(writer.finish(root)) +} + +macro_rules! scalar { + ($($ty:ty => $variant:ident),* $(,)?) => {$( + impl FromWire for $ty { + fn read_wire(reader: &mut WireReader, index: ValueNodeIndex) -> Result { + match reader.take(index)? { + wire::SchemaValueNode::$variant(value) => Ok(value), + _ => Err(WireError::Shape(stringify!($variant))), + } + } + } + + impl IntoWire for $ty { + fn write_wire(&self, writer: &mut WireWriter) -> Result { + Ok(writer.push(wire::SchemaValueNode::$variant(self.clone()))) + } + } + )*}; +} + +scalar! { + bool => BoolValue, i8 => S8Value, i16 => S16Value, i32 => S32Value, + i64 => S64Value, u8 => U8Value, u16 => U16Value, u32 => U32Value, + u64 => U64Value, f32 => F32Value, f64 => F64Value, char => CharValue, +} + +impl FromWire for String { + fn read_wire(reader: &mut WireReader, index: ValueNodeIndex) -> Result { + match reader.take(index)? { + wire::SchemaValueNode::StringValue(value) => Ok(value), + // Text-refined tool arguments retain String as their Rust type. + wire::SchemaValueNode::TextValue(value) => Ok(value.text), + _ => Err(WireError::Shape("string or text")), + } + } +} + +impl IntoWire for String { + fn write_wire(&self, writer: &mut WireWriter) -> Result { + self.as_str().write_wire(writer) + } +} + +impl WireSchema for usize { + fn append_schema(builder: &mut WireSchemaBuilder) -> wire::TypeNodeIndex { + ::append_schema(builder) + } +} +impl IntoWire for usize { + fn write_wire(&self, writer: &mut WireWriter) -> Result { + (*self as u64).write_wire(writer) + } +} +impl FromWire for usize { + fn read_wire(reader: &mut WireReader, index: ValueNodeIndex) -> Result { + usize::try_from(u64::read_wire(reader, index)?).map_err(|_| WireError::Shape("usize range")) + } +} + +impl IntoWire for str { + fn write_wire(&self, writer: &mut WireWriter) -> Result { + Ok(writer.push(wire::SchemaValueNode::StringValue(self.to_string()))) + } +} + +impl IntoWire for Box { + fn preflight(&self, resources: &mut WirePreflight) -> Result<(), WireError> { + (**self).preflight(resources) + } + + fn write_wire(&self, writer: &mut WireWriter) -> Result { + (**self).write_wire(writer) + } + + async fn prepare_wire(&self) -> Result<(), WireError> { + Box::pin((**self).prepare_wire()).await + } + + fn write_result_payload( + &self, + writer: &mut WireWriter, + ) -> Result, WireError> { + (**self).write_result_payload(writer) + } +} + +impl FromWire for Box { + fn read_wire(reader: &mut WireReader, index: ValueNodeIndex) -> Result { + T::read_wire(reader, index).map(Box::new) + } + + fn read_result_payload( + reader: &mut WireReader, + index: Option, + ) -> Result { + T::read_result_payload(reader, index).map(Box::new) + } +} + +impl IntoWire for Vec { + fn preflight(&self, resources: &mut WirePreflight) -> Result<(), WireError> { + for value in self { + value.preflight(resources)?; + } + Ok(()) + } + + async fn prepare_wire(&self) -> Result<(), WireError> { + for value in self { + Box::pin(value.prepare_wire()).await?; + } + Ok(()) + } + + fn write_wire(&self, writer: &mut WireWriter) -> Result { + let indices = self + .iter() + .map(|value| value.write_wire(writer)) + .collect::>()?; + Ok(writer.push(wire::SchemaValueNode::ListValue(indices))) + } +} + +impl FromWire for Vec { + fn read_wire(reader: &mut WireReader, index: ValueNodeIndex) -> Result { + match reader.take(index)? { + wire::SchemaValueNode::ListValue(indices) => indices + .into_iter() + .map(|index| T::read_wire(reader, index)) + .collect(), + _ => Err(WireError::Shape("list")), + } + } +} + +impl IntoWire for Option { + fn preflight(&self, resources: &mut WirePreflight) -> Result<(), WireError> { + if let Some(value) = self { + value.preflight(resources)?; + } + Ok(()) + } + + async fn prepare_wire(&self) -> Result<(), WireError> { + if let Some(value) = self { + value.prepare_wire().await?; + } + Ok(()) + } + + fn write_wire(&self, writer: &mut WireWriter) -> Result { + let index = self + .as_ref() + .map(|value| value.write_wire(writer)) + .transpose()?; + Ok(writer.push(wire::SchemaValueNode::OptionValue(index))) + } +} + +impl FromWire for Option { + fn read_wire(reader: &mut WireReader, index: ValueNodeIndex) -> Result { + match reader.take(index)? { + wire::SchemaValueNode::OptionValue(index) => { + index.map(|index| T::read_wire(reader, index)).transpose() + } + _ => Err(WireError::Shape("option")), + } + } +} + +impl IntoWire for Result { + fn preflight(&self, resources: &mut WirePreflight) -> Result<(), WireError> { + match self { + Ok(value) => value.preflight(resources), + Err(value) => value.preflight(resources), + } + } + + async fn prepare_wire(&self) -> Result<(), WireError> { + match self { + Ok(value) => value.prepare_wire().await, + Err(value) => value.prepare_wire().await, + } + } + + fn write_wire(&self, writer: &mut WireWriter) -> Result { + let payload = match self { + Ok(value) => wire::ResultValuePayload::OkValue(value.write_result_payload(writer)?), + Err(value) => wire::ResultValuePayload::ErrValue(value.write_result_payload(writer)?), + }; + Ok(writer.push(wire::SchemaValueNode::ResultValue(payload))) + } +} + +impl FromWire for Result { + fn read_wire(reader: &mut WireReader, index: ValueNodeIndex) -> Result { + match reader.take(index)? { + wire::SchemaValueNode::ResultValue(wire::ResultValuePayload::OkValue(index)) => { + T::read_result_payload(reader, index).map(Ok) + } + wire::SchemaValueNode::ResultValue(wire::ResultValuePayload::ErrValue(index)) => { + E::read_result_payload(reader, index).map(Err) + } + _ => Err(WireError::Shape("result")), + } + } +} + +impl IntoWire for std::ops::Bound { + fn preflight(&self, resources: &mut WirePreflight) -> Result<(), WireError> { + match self { + std::ops::Bound::Included(value) | std::ops::Bound::Excluded(value) => { + value.preflight(resources) + } + std::ops::Bound::Unbounded => Ok(()), + } + } + + async fn prepare_wire(&self) -> Result<(), WireError> { + match self { + std::ops::Bound::Included(value) | std::ops::Bound::Excluded(value) => { + value.prepare_wire().await + } + std::ops::Bound::Unbounded => Ok(()), + } + } + + fn write_wire(&self, writer: &mut WireWriter) -> Result { + let (case, payload) = match self { + std::ops::Bound::Included(value) => (0, Some(value.write_wire(writer)?)), + std::ops::Bound::Excluded(value) => (1, Some(value.write_wire(writer)?)), + std::ops::Bound::Unbounded => (2, None), + }; + Ok(writer.push(wire::SchemaValueNode::VariantValue( + wire::VariantValuePayload { case, payload }, + ))) + } +} + +impl FromWire for std::ops::Bound { + fn read_wire(reader: &mut WireReader, index: ValueNodeIndex) -> Result { + let wire::SchemaValueNode::VariantValue(value) = reader.take(index)? else { + return Err(WireError::Shape("bound variant")); + }; + match (value.case, value.payload) { + (0, Some(index)) => T::read_wire(reader, index).map(std::ops::Bound::Included), + (1, Some(index)) => T::read_wire(reader, index).map(std::ops::Bound::Excluded), + (2, None) => Ok(std::ops::Bound::Unbounded), + _ => Err(WireError::Shape("bound case")), + } + } +} + +impl IntoWire for () { + fn write_wire(&self, writer: &mut WireWriter) -> Result { + Ok(writer.push(wire::SchemaValueNode::TupleValue(Vec::new()))) + } + + fn write_result_payload( + &self, + _writer: &mut WireWriter, + ) -> Result, WireError> { + Ok(None) + } +} + +impl FromWire for () { + fn read_wire(reader: &mut WireReader, index: ValueNodeIndex) -> Result { + match reader.take(index)? { + wire::SchemaValueNode::TupleValue(indices) if indices.is_empty() => Ok(()), + _ => Err(WireError::Shape("unit tuple")), + } + } + + fn read_result_payload( + _reader: &mut WireReader, + index: Option, + ) -> Result { + match index { + Some(_) => Err(WireError::Shape("absent unit result payload")), + None => Ok(()), + } + } +} + +macro_rules! tuple { + ($($index:tt : $ty:ident),+) => { + impl<$($ty: IntoWire),+> IntoWire for ($($ty,)+) { + fn preflight(&self, resources: &mut WirePreflight) -> Result<(), WireError> { + $(self.$index.preflight(resources)?;)+ + Ok(()) + } + + async fn prepare_wire(&self) -> Result<(), WireError> { + $(self.$index.prepare_wire().await?;)+ + Ok(()) + } + + fn write_wire(&self, writer: &mut WireWriter) -> Result { + let indices = vec![$(self.$index.write_wire(writer)?),+]; + Ok(writer.push(wire::SchemaValueNode::TupleValue(indices))) + } + } + + impl<$($ty: FromWire),+> FromWire for ($($ty,)+) { + #[allow(non_snake_case)] + fn read_wire(reader: &mut WireReader, index: ValueNodeIndex) -> Result { + match reader.take(index)? { + wire::SchemaValueNode::TupleValue(indices) => { + let [$($ty),+] = indices.as_slice() else { + return Err(WireError::Shape("tuple arity")); + }; + Ok(($($ty::read_wire(reader, *$ty)?,)+)) + } + _ => Err(WireError::Shape("tuple")), + } + } + } + }; +} + +tuple!(0: A); +tuple!(0: A, 1: B); +tuple!(0: A, 1: B, 2: C); +tuple!(0: A, 1: B, 2: C, 3: D); +tuple!(0: A, 1: B, 2: C, 3: D, 4: E); +tuple!(0: A, 1: B, 2: C, 3: D, 4: E, 5: F); +tuple!(0: A, 1: B, 2: C, 3: D, 4: E, 5: F, 6: G); +tuple!(0: A, 1: B, 2: C, 3: D, 4: E, 5: F, 6: G, 7: H); + +macro_rules! resource { + ($ty:ty, $method:ident, $variant:ident, $kind:literal) => { + impl FromWire for $ty { + fn read_wire( + reader: &mut WireReader, + index: ValueNodeIndex, + ) -> Result { + reader.$method(index) + } + } + + impl IntoWire for $ty { + fn preflight(&self, resources: &mut WirePreflight) -> Result<(), WireError> { + resources.resource($kind, self.cell_id(), self.is_present()) + } + + fn write_wire(&self, writer: &mut WireWriter) -> Result { + let handle = self.take().ok_or(WireError::ConsumedResource($kind))?; + Ok(writer.push(wire::SchemaValueNode::$variant(handle))) + } + } + }; +} + +resource!(GuestSecretHandle, secret, SecretValue, "secret"); +resource!( + GuestQuotaTokenHandle, + quota_token, + QuotaTokenHandle, + "quota-token" +); +resource!( + GuestPermissionCardHandle, + permission_card, + PermissionCardHandle, + "permission-card" +); + +impl WireSchema for GuestSecretHandle { + fn append_schema(builder: &mut WireSchemaBuilder) -> wire::TypeNodeIndex { + let inner = ::append_schema(builder); + builder.push(wire::SchemaTypeBody::SecretType(wire::SecretSpec { + inner, + category: None, + })) + } +} + +impl WireSchema for GuestQuotaTokenHandle { + fn append_schema(builder: &mut WireSchemaBuilder) -> wire::TypeNodeIndex { + builder.push(wire::SchemaTypeBody::QuotaTokenType(wire::QuotaTokenSpec { + resource_name: None, + })) + } +} + +impl WireSchema for GuestPermissionCardHandle { + fn append_schema(builder: &mut WireSchemaBuilder) -> wire::TypeNodeIndex { + builder.push(wire::SchemaTypeBody::PermissionCardType( + wire::PermissionCardSpec { polymorphic: false }, + )) + } +} + +impl FromWire for SchemaValueStream { + fn read_wire(reader: &mut WireReader, index: ValueNodeIndex) -> Result { + reader.stream(index) + } +} + +impl IntoWire for SchemaValueStream { + fn preflight(&self, resources: &mut WirePreflight) -> Result<(), WireError> { + resources.resource("stream", self.cell_id(), self.is_present())?; + if !resources.allow_native_streams && !self.is_wrapped() { + return Err(WireError::AsyncStream); + } + Ok(()) + } + + async fn prepare_wire(&self) -> Result<(), WireError> { + self.ensure_wrapped() + .await + .map_err(|_| WireError::ConsumedResource("stream")) + } + + fn write_wire(&self, writer: &mut WireWriter) -> Result { + let stream = self.take_wrapped().ok_or(WireError::AsyncStream)?; + Ok(writer.push(wire::SchemaValueNode::StreamValue(stream))) + } +} + +impl WireSchema for SchemaValueStream { + fn contains_stream(_: &mut HashSet<&'static str>) -> bool { + true + } + + fn append_schema(builder: &mut WireSchemaBuilder) -> wire::TypeNodeIndex { + builder.push(wire::SchemaTypeBody::StreamType(None)) + } +} + +#[cfg(feature = "bytes")] +impl WireSchema for bytes::Bytes { + fn wire_type_id() -> String { + "bytes.Bytes".to_string() + } + + fn append_schema(builder: &mut WireSchemaBuilder) -> wire::TypeNodeIndex { + builder.push(wire::SchemaTypeBody::BinaryType(wire::BinaryRestrictions { + mime_types: None, + min_bytes: None, + max_bytes: None, + })) + } +} + +#[cfg(feature = "bytes")] +impl IntoWire for bytes::Bytes { + fn write_wire(&self, writer: &mut WireWriter) -> Result { + Ok(writer.push(wire::SchemaValueNode::BinaryValue( + wire::BinaryValuePayload { + bytes: self.to_vec(), + mime_type: None, + }, + ))) + } +} + +#[cfg(feature = "bytes")] +impl FromWire for bytes::Bytes { + fn read_wire(reader: &mut WireReader, index: ValueNodeIndex) -> Result { + match reader.take(index)? { + wire::SchemaValueNode::BinaryValue(payload) => Ok(Self::from(payload.bytes)), + _ => Err(WireError::Shape("binary")), + } + } +} diff --git a/golem-schema/src/schema/wit/encode.rs b/golem-schema/src/schema/wit/encode.rs index eef234a886..b96941602b 100644 --- a/golem-schema/src/schema/wit/encode.rs +++ b/golem-schema/src/schema/wit/encode.rs @@ -492,10 +492,8 @@ fn collect_streams<'a>( collect_streams(value, streams); } } - SchemaValue::Option { inner } => { - if let Some(value) = inner { - collect_streams(value, streams); - } + SchemaValue::Option { inner: Some(value) } => { + collect_streams(value, streams); } SchemaValue::Result(payload) => { let value = match payload { diff --git a/golem-schema/src/schema/wit/mod.rs b/golem-schema/src/schema/wit/mod.rs index 4f01e3533f..ca27bc5164 100644 --- a/golem-schema/src/schema/wit/mod.rs +++ b/golem-schema/src/schema/wit/mod.rs @@ -23,6 +23,9 @@ mod decode; mod encode; +#[cfg(all(feature = "guest", not(feature = "host")))] +pub mod direct; + #[cfg(any( all(feature = "guest", not(feature = "host")), all(feature = "host", not(feature = "guest")) diff --git a/golem-schema/tests/direct_wire.rs b/golem-schema/tests/direct_wire.rs new file mode 100644 index 0000000000..07cb71cf42 --- /dev/null +++ b/golem-schema/tests/direct_wire.rs @@ -0,0 +1,871 @@ +test_r::enable!(); + +use golem_schema::schema::wit::direct::{WireError, decode, encode, encode_async, schema}; +use golem_schema::schema::wit::{ + GuestPermissionCardHandle, GuestQuotaTokenHandle, GuestSecretHandle, wire, +}; +use golem_schema_derive::{FromWire, IntoWire, WireSchema}; +use std::collections::HashMap; +use std::ops::Bound; +use test_r::test; + +#[test] +fn snapshot_readers_are_independent_without_clone_or_model_traits() { + use golem_schema::schema::wit::direct::{FromWire, WireSnapshot}; + let value = Request { + id: 83, + values: vec![ + None, + Some(Err(Fault::Rejected { + code: 7, + reason: "denied".into(), + })), + ], + }; + let tree = encode(&value).unwrap(); + let snapshot = std::rc::Rc::new(WireSnapshot::new(tree.value_nodes)); + for _ in 0..2 { + let mut reader = snapshot.reader(); + assert_eq!(Request::read_wire(&mut reader, tree.root).unwrap(), value); + reader.finish().unwrap(); + } + let snapshot = std::rc::Rc::new(WireSnapshot::new(vec![ + wire::SchemaValueNode::U32Value(9), + wire::SchemaValueNode::TupleValue(vec![0, 0]), + ])); + for _ in 0..2 { + assert!(matches!( + <(u32, u32)>::read_wire(&mut snapshot.reader(), 1), + Err(WireError::AliasedNode(0)) + )); + assert!(matches!( + u32::read_wire(&mut snapshot.reader(), -1), + Err(WireError::OutOfBounds(-1)) + )); + } +} + +#[test] +fn shared_readers_keep_resource_reachability_and_discard_checks() { + use golem_schema::schema::wit::direct::{FromWire, WireSnapshot}; + let snapshot = std::rc::Rc::new(WireSnapshot::new(vec![ + wire::SchemaValueNode::SecretValue(unsafe { wire::Secret::from_handle(61) }), + wire::SchemaValueNode::U32Value(9), + ])); + let mut reader = snapshot.reader(); + assert_eq!(u32::read_wire(&mut reader, 1).unwrap(), 9); + assert!(matches!( + reader.finish(), + Err(WireError::UnreachableResource(0)) + )); + let mut reader = snapshot.reader(); + reader.discard(0).unwrap(); + reader.finish().unwrap(); + let mut reader = snapshot.reader(); + let handle = GuestSecretHandle::read_wire(&mut reader, 0).unwrap(); + reader.finish().unwrap(); + assert_eq!(handle.take().unwrap().take_handle(), 61); +} + +#[test] +fn rich_values_and_nominal_ids_use_direct_wire_shapes() { + let uuid = uuid::Uuid::from_u64_pair(0x1234, 0x9876); + let tree = encode(&uuid).unwrap(); + let wire::SchemaValueNode::RecordValue(fields) = &tree.value_nodes[tree.root as usize] else { + panic!("UUID record") + }; + assert!(matches!( + tree.value_nodes[fields[0] as usize], + wire::SchemaValueNode::U64Value(0x1234) + )); + assert!(matches!( + tree.value_nodes[fields[1] as usize], + wire::SchemaValueNode::U64Value(0x9876) + )); + assert_eq!(decode::(tree).unwrap(), uuid); + let promise = golem_schema::PromiseId::new( + golem_schema::AgentId::new(golem_schema::ComponentId::new(uuid), "Counter(abc)".into()), + 83, + ); + assert_eq!( + decode::(encode(&promise).unwrap()).unwrap(), + promise + ); + let mut actual = + golem_schema::schema::wit::decode_graph(&schema::()).unwrap(); + let mut expected = + golem_schema::schema::try_into_schema_graph::().unwrap(); + actual.defs.sort_by(|a, b| a.id.cmp(&b.id)); + expected.defs.sort_by(|a, b| a.id.cmp(&b.id)); + assert_eq!(actual, expected); + let date = chrono::DateTime::from_timestamp(-19, 123456789).unwrap(); + assert_eq!( + decode::>(encode(&date).unwrap()).unwrap(), + date + ); + let duration = std::time::Duration::from_nanos(123456789); + assert_eq!( + decode::(encode(&duration).unwrap()).unwrap(), + duration + ); + assert_eq!( + decode::(encode(&std::time::Duration::MAX).unwrap()).unwrap(), + std::time::Duration::from_nanos(i64::MAX as u64) + ); + assert!( + decode::(wire::SchemaValueTree { + root: 0, + value_nodes: vec![wire::SchemaValueNode::DurationValue( + wire::DurationValuePayload { nanoseconds: -1 } + )] + }) + .is_err() + ); + assert!( + decode::>(wire::SchemaValueTree { + root: 0, + value_nodes: vec![wire::SchemaValueNode::DatetimeValue(wire::Datetime { + seconds: i64::MAX, + nanoseconds: 0 + })] + }) + .is_err() + ); +} + +#[test] +fn standard_maps_and_bounds_roundtrip_directly() { + let value = HashMap::from([ + ("lower".to_string(), Bound::Included(-4i32)), + ("upper".to_string(), Bound::Excluded(19i32)), + ("none".to_string(), Bound::Unbounded), + ]); + let encoded = encode(&value).unwrap(); + assert_eq!( + decode::>>(encoded).unwrap(), + value + ); + + let graph = schema::>(); + let wire::SchemaTypeBody::VariantType(cases) = &graph.type_nodes[graph.root as usize].body + else { + panic!("expected bound variant schema"); + }; + assert_eq!( + cases + .iter() + .map(|case| case.name.as_str()) + .collect::>(), + ["included", "excluded", "unbounded"] + ); + assert!(cases[0].payload.is_some()); + assert!(cases[1].payload.is_some()); + assert!(cases[2].payload.is_none()); +} + +#[cfg(feature = "url")] +#[test] +fn direct_urls_parse_url_nodes_but_not_strings() { + let url = url::Url::parse("https://example.com/a?b=3").unwrap(); + assert_eq!(decode::(encode(&url).unwrap()).unwrap(), url); + assert!(decode::(encode("https://example.com/").unwrap()).is_err()); + assert!( + decode::(wire::SchemaValueTree { + root: 0, + value_nodes: vec![wire::SchemaValueNode::UrlValue("not a url".into())] + }) + .is_err() + ); +} + +#[cfg(feature = "bytes")] +#[test] +fn bytes_use_binary_nodes_not_byte_lists() { + let value = bytes::Bytes::from_static(&[0, 129, 255]); + let encoded = encode(&value).unwrap(); + assert_eq!(encoded.value_nodes.len(), 1); + match &encoded.value_nodes[encoded.root as usize] { + wire::SchemaValueNode::BinaryValue(payload) => { + assert_eq!(payload.bytes, [0, 129, 255]); + assert_eq!(payload.mime_type, None); + } + _ => panic!("expected binary node"), + } + assert_eq!(decode::(encoded).unwrap(), value); + let graph = schema::(); + assert!(matches!( + graph.type_nodes[graph.root as usize].body, + wire::SchemaTypeBody::BinaryType(_) + )); + assert!(decode::(encode(&vec![0u8, 129, 255]).unwrap()).is_err()); + assert_eq!( + decode::(encode(&bytes::Bytes::new()).unwrap()).unwrap(), + bytes::Bytes::new() + ); +} + +// Deliberately no IntoSchema/FromSchema implementations: a hidden model adapter +// cannot satisfy these tests. +#[derive(Debug, PartialEq, FromWire, IntoWire, WireSchema)] +#[schema(named = "example.Request")] +struct Request { + #[schema(doc = "request identifier")] + id: u32, + values: Vec>>, +} + +#[derive(Debug, PartialEq, FromWire, IntoWire, WireSchema)] +enum Fault { + Missing, + Rejected { code: u16, reason: String }, + Retry(u8, bool), + Nested(Box), +} + +#[test] +fn wire_schema_derive_builds_recursive_flat_arena() { + let graph = schema::(); + assert_eq!(graph.defs.len(), 2); + assert_eq!(graph.defs[0].id, "example.Request"); + assert!(graph.defs.iter().all(|definition| definition.body >= 0)); + assert!(matches!( + graph.type_nodes[graph.root as usize].body, + wire::SchemaTypeBody::RefType(0) + )); + + let request = &graph.type_nodes[graph.defs[0].body as usize]; + let wire::SchemaTypeBody::RecordType(fields) = &request.body else { + panic!("request must be a record") + }; + assert_eq!(fields[0].name, "id"); + assert_eq!( + fields[0].metadata.doc.as_deref(), + Some("request identifier") + ); + + let fault = graph + .defs + .iter() + .find(|definition| definition.name.as_deref() == Some("Fault")) + .unwrap(); + assert!(matches!( + graph.type_nodes[fault.body as usize].body, + wire::SchemaTypeBody::VariantType(_) + )); +} + +#[test] +fn wire_schema_single_field_variant_payload_matches_encoder() { + let encoded = encode(&Fault::Nested(Box::new(Request { + id: 1, + values: Vec::new(), + }))) + .unwrap(); + let wire::SchemaValueNode::VariantValue(encoded_variant) = + &encoded.value_nodes[encoded.root as usize] + else { + panic!("fault must encode as a variant") + }; + assert!(matches!( + encoded.value_nodes[encoded_variant.payload.unwrap() as usize], + wire::SchemaValueNode::RecordValue(_) + )); + + let graph = schema::(); + let definition = &graph.defs[0]; + let wire::SchemaTypeBody::VariantType(cases) = &graph.type_nodes[definition.body as usize].body + else { + panic!("fault must be a variant") + }; + let payload = cases[3].payload.expect("tuple case must have a payload"); + assert!(matches!( + graph.type_nodes[payload as usize].body, + wire::SchemaTypeBody::RefType(_) + )); +} + +#[derive(Debug, PartialEq, FromWire, IntoWire)] +struct Pair(u16, String); + +#[derive(Debug, PartialEq, FromWire, IntoWire)] +struct Empty; + +#[derive(Debug, PartialEq, FromWire, IntoWire)] +#[schema(transparent)] +struct Label(String); + +fn tree(nodes: Vec, root: i32) -> wire::SchemaValueTree { + wire::SchemaValueTree { + value_nodes: nodes, + root, + } +} + +#[test] +fn decode_uses_indices_not_arena_order_and_accepts_named_concrete_types() { + use wire::SchemaValueNode::*; + let input = tree( + vec![ + StringValue("denied".into()), + U32Value(73), + RecordValue(vec![1, 6]), + U16Value(409), + RecordValue(vec![3, 0]), + VariantValue(wire::VariantValuePayload { + case: 1, + payload: Some(4), + }), + ListValue(vec![8, 9]), + ResultValue(wire::ResultValuePayload::ErrValue(Some(5))), + OptionValue(Some(7)), + OptionValue(None), + ], + 2, + ); + assert_eq!( + decode::(input).unwrap(), + Request { + id: 73, + values: vec![ + Some(Err(Fault::Rejected { + code: 409, + reason: "denied".into() + })), + None + ], + } + ); +} + +#[test] +fn encoder_emits_exact_wire_shapes_without_schema_traits() { + use wire::SchemaValueNode::*; + let encoded = encode(&Request { + id: 91, + values: vec![Some(Ok("ok".into()))], + }) + .unwrap(); + assert_eq!(encoded.root, 5); + assert_eq!(encoded.value_nodes.len(), 6); + assert!(matches!(&encoded.value_nodes[0], U32Value(91))); + assert!(matches!(&encoded.value_nodes[1], StringValue(s) if s == "ok")); + assert!(matches!( + &encoded.value_nodes[2], + ResultValue(wire::ResultValuePayload::OkValue(Some(1))) + )); + assert!(matches!(&encoded.value_nodes[3], OptionValue(Some(2)))); + assert!(matches!(&encoded.value_nodes[4], ListValue(indices) if indices == &[3])); + assert!(matches!(&encoded.value_nodes[5], RecordValue(indices) if indices == &[0, 4])); +} + +#[test] +fn structural_errors_are_rejected_before_entering_concrete_body() { + use wire::SchemaValueNode::*; + assert_eq!( + decode::(tree(vec![TupleValue(vec![1]), U16Value(4)], 0)), + Err(WireError::Shape("field count")) + ); + assert_eq!( + decode::(tree( + vec![ + TupleValue(vec![1, 2, 3]), + U16Value(4), + StringValue("x".into()), + BoolValue(false) + ], + 0 + )), + Err(WireError::Shape("field count")) + ); + assert_eq!( + decode::(tree( + vec![TupleValue(vec![2, 1]), U16Value(4), StringValue("x".into())], + 0 + )), + Err(WireError::Shape("U16Value")) + ); + assert_eq!( + decode::>(tree(vec![ListValue(vec![1, 1]), U16Value(4)], 0)), + Err(WireError::AliasedNode(1)) + ); + assert_eq!( + decode::>>>(tree(vec![OptionValue(Some(0))], 0)), + Err(WireError::AliasedNode(0)) + ); + assert_eq!( + decode::(tree(vec![StringValue("x".into())], -1)), + Err(WireError::OutOfBounds(-1)) + ); + assert_eq!( + decode::(tree(vec![], 0)), + Err(WireError::OutOfBounds(0)) + ); + assert_eq!( + decode::(tree( + vec![VariantValue(wire::VariantValuePayload { + case: 2, + payload: None + })], + 0 + )), + Err(WireError::Shape("variant payload")) + ); + assert_eq!( + decode::(tree( + vec![ + VariantValue(wire::VariantValuePayload { + case: 0, + payload: Some(1) + }), + U8Value(2) + ], + 0 + )), + Err(WireError::Shape("absent variant payload")) + ); + assert_eq!( + decode::(tree( + vec![VariantValue(wire::VariantValuePayload { + case: 99, + payload: None + })], + 0 + )), + Err(WireError::Shape("variant case")) + ); +} + +#[test] +fn result_unit_payload_and_unit_record_are_distinct() { + use wire::SchemaValueNode::*; + let value = encode(&Ok::<(), String>(())).unwrap(); + assert_eq!(value.value_nodes.len(), 1); + assert!(matches!( + value.value_nodes[0], + ResultValue(wire::ResultValuePayload::OkValue(None)) + )); + assert_eq!(decode::>(value).unwrap(), Ok(())); + assert_eq!( + decode::>(tree( + vec![ResultValue(wire::ResultValuePayload::OkValue(None))], + 0 + )), + Err(WireError::Shape("result payload")) + ); + assert_eq!( + decode::(tree(vec![RecordValue(vec![])], 0)), + Ok(Empty) + ); + assert_eq!( + decode::(tree(vec![TupleValue(vec![])], 0)), + Err(WireError::Shape("RecordValue")) + ); + assert_eq!( + decode::