diff --git a/.agents/skills/typesafe-ai/LICENSE b/.agents/skills/typesafe-ai/LICENSE new file mode 100644 index 0000000..8c73b41 --- /dev/null +++ b/.agents/skills/typesafe-ai/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 TypeSafe AI + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/.agents/skills/typesafe-ai/SKILL.md b/.agents/skills/typesafe-ai/SKILL.md new file mode 100644 index 0000000..0109513 --- /dev/null +++ b/.agents/skills/typesafe-ai/SKILL.md @@ -0,0 +1,149 @@ +--- +name: typesafe-ai +license: MIT +description: > + Build AI-powered software with TypeSafe: small units of AI intelligence you + can use like programming primitives. Its System One models, including Jev, + turn natural language and application state into typed judgments and + probabilities that code can combine. Use when a feature needs programmable + common sense, when brainstorming what AI could make possible in an app, or + when an LLM prompt-and-parse step could become a structured decision. + Applications include routing, ranking, extraction, verification, and + interactive experiences; these are starting points, not the limits. + Read live docs and cookbooks to find useful patterns and discover new combinations. +--- + +# Build with TypeSafe + +TypeSafe makes units of AI intelligence usable like programming primitives: small +judgments you can compose into larger capabilities. Its **System One models** return +fast, focused judgments that software can consume directly. **Jev** is TypeSafe's +flagship and first System One model. It understands natural language and returns +typed answers and probabilities rather +than generating text or reasoning explanations. Code owns the workflow; the model +supplies programmable common sense where ordinary code needs semantic understanding. + +## Read the live docs + +**The live TypeSafe docs are the source of truth. Read them as part of the task.** +This skill gives direction; the docs carry current concepts, prompting guidance, +API contracts, SDK usage, models, limits, and worked examples. + +- Start with the [documentation index](https://docs.typesafe.ai/llms.txt) to discover + relevant pages and cookbooks. Use targeted reads rather than loading the entire site. +- Mintlify serves Markdown by appending `.md` to a page path, for example + [how to build with TypeSafe](https://docs.typesafe.ai/concepts/how-to-build-with-system-one.md). + Follow links from the index; convert extensionless documentation page links to + `.md` when useful. Resolve relative links against `https://docs.typesafe.ai`. +- Before writing an integration, read the current API or chosen SDK page and the + question guidance relevant to the design. For a new workflow, also inspect the + closest cookbook: it often shows a better decomposition than a generic classifier. +- If the index is unavailable, use the direct links below or the site's navigation. + If Markdown fetching fails, try the normal page. If live access is unavailable, + use available local docs or installed SDK types, state that limitation, and avoid + inventing version-dependent details. + +| Task | Start here; follow the relevant details | +| --- | --- | +| Understand the programming model | [System One](https://docs.typesafe.ai/concepts/system-one.md), [building guide](https://docs.typesafe.ai/concepts/how-to-build-with-system-one.md) | +| Explore what to build | [Use-case map](https://docs.typesafe.ai/concepts/use-case-map.md), then relevant cookbooks from the index | +| Prepare inputs and questions | [State](https://docs.typesafe.ai/concepts/state.md), [primitives](https://docs.typesafe.ai/primitives.md), then the chosen primitive's page | +| Decide how to handle uncertainty | [Confidence](https://docs.typesafe.ai/confidence.md) | +| Write API code | [HTTP API](https://docs.typesafe.ai/api.md), [Python SDK](https://docs.typesafe.ai/sdk/python.md), or [JavaScript SDK](https://docs.typesafe.ai/sdk/javascript.md) | +| Update an older integration | [Migration guide](https://docs.typesafe.ai/migrating-to-v1.md) and the installed SDK's current reference | + +## Find the useful shape + +Start from the behavior the user wants: what will the application show, select, +change, or hand off? Work backward to the judgments it needs. Keep known rules, +calculations, exact lookups, and execution in code. Preserve the user's chosen stack +and scope; add TypeSafe where semantic understanding helps. + +When brainstorming or choosing an architecture, consider more than classification. +The patterns below are starting points: combine primitives around the user's goal, +including ideas that do not fit an established recipe. + +- **Route and fill known arguments.** A request can select a handler and its typed + parameters. Ask useful branch-specific questions up front and consume only the + relevant answers. Explore [function calling](https://docs.typesafe.ai/cookbooks/function_calling.md) + and [speculative fan-out](https://docs.typesafe.ai/patterns/fan-out.md). +- **Select instead of generate.** Find candidate values or source spans in code, + use a judgment to select the intended one, then copy or normalize it. Code can + also assemble source text into a formatted document or reading guide. Explore + [value extraction](https://docs.typesafe.ai/cookbooks/pre_parsed_value_extraction_cookbook.md) + and [structure recovery](https://docs.typesafe.ai/cookbooks/autoformat.md). +- **Find and judge evidence.** Retrieve candidates, compare their relevance to a + query, and select useful context. Explore [reranking](https://docs.typesafe.ai/cookbooks/rerank_typesafe.md) + and [hierarchical classification](https://docs.typesafe.ai/cookbooks/hierarchical_classification.md). +- **Turn judgments into reusable data.** Score dimensions once, then let code or + user controls change weights, thresholds, rankings, and views. With labeled + outcomes, those signals can become classical ML features. Explore + [composite scoring](https://docs.typesafe.ai/patterns/composite-scoring.md) and + [feature discovery](https://docs.typesafe.ai/cookbooks/autoresearch_feature_discovery.md). +- **Verify and escalate.** Check specific claims or fields against their evidence; + send uncertain or failing cases to a person or reasoning model. Explore + [citation checks](https://docs.typesafe.ai/cookbooks/citation_check.md) and + [extraction cascades](https://docs.typesafe.ai/cookbooks/sde_cascade.md). +- **Respond to changing state.** Code can retain goals and observations while fresh + judgments guide the next bounded step. Keep inferred state distinct from observed + facts, and check freshness before applying a result to a changed situation. + +For open-ended requests, offer the few directions that best serve the user's goal +and recommend a starting point. For a concrete request, choose the relevant pattern +and build; a brainstorm is not a mandatory detour. + +## Design the judgments + +Choose by what the answer means, then read the relevant primitive page: + +| Need | Primitive | Important distinction | +| --- | --- | --- | +| One of a defined set | [Choice](https://docs.typesafe.ai/primitives/choice.md) | Picks one option; its distribution compares competing options | +| Whether a condition holds | [Noul](https://docs.typesafe.ai/primitives/noul.md) | Probability of yes; no separate confidence; use one per label when several may apply | +| Degree along a described dimension | [Score](https://docs.typesafe.ai/primitives/score.md) | Probability-weighted position on ordered levels; use comparable per-item Scores for graded ranking | + +Give each question enough relevant **state** to answer: source text, identities, +relationships, policies, and current facts. Prefer named JSON fields when context +has several parts. Put the judgment in **instructions** and define its possible +answers in **criteria**. Question IDs are for code and are not sent to the model; +include complete meaning in the question. Reference nested state with backticked +paths such as `ticket.messages[0].text`. + +Ask one narrow, coherent judgment per question. Split independently useful dimensions, +without destroying the relationship being judged. A bounded action selection or +contextual interpretation is valid; atomic does not mean literal fact extraction +or a one-sentence limit. Strings work for simple questions. Use structured objects +or arrays when definitions, contrasts, exclusions, or examples clarify instructions +or criteria. Score levels must describe concrete situations and stand on their own. + +Keep the needed answers available. Include a no-match outcome when nothing may fit; +use a separate presence judgment when it is independently useful. For source-value +selection, check candidate coverage: the model cannot choose an omitted value. + +## Compose and verify + +**Ask independent questions over the same state together**, including useful +speculative questions. They run in parallel and cannot see one another's answers. +State each speculative premise explicitly; code consumes the applicable answers. +A second request is warranted when an earlier answer is needed to fetch evidence, +construct new state, or determine the next options. Extra questions still use tokens; +measure actual request budgets, cost, and end-to-end latency. + +Use probabilities and confidence to guide behavior, with thresholds evaluated on +the user's data and consequences. Choice/Score confidence summarizes distribution +concentration, not overall workflow correctness or permission to act. A Noul near +0.5 means similar probability for yes and no, not medium intensity. Several +acceptable alternatives can also spread probability; low confidence need not +invalidate a harmless preference choice. Ignore uncertainty on unused branches. + +Keep policy explicit and raw judgments reusable. Weighted scores suit compensating +preferences; an “any serious violation” rule needs separate conditions. Changing a +weight or display filter need not rerun inference when evidence and question meanings +are unchanged. Typed output guarantees the interface, not truth. System One models +are trained for calibrated decisions; validate their performance in the target domain. + +Test representative cases and the resulting application behavior. For failures, +inspect the exact state, questions, candidates, answers, composition, and observed +outcome. Separate missing evidence, model errors, code errors, and service failures. +Treat cookbook thresholds and demo results as examples to evaluate, not universal +rules or permanent model limitations. Keep API credentials server-side in web apps. diff --git a/.env.example b/.env.example index e22ca63..b50c317 100644 --- a/.env.example +++ b/.env.example @@ -14,6 +14,5 @@ # Backtraces for local debugging: 1 for a short backtrace, "full" for all frames. # RUST_BACKTRACE=1 -# Example of a credential a live/network-gated test would need. Tests that -# require one must skip cleanly when it is unset. -# EXAMPLE_API_KEY=replace-me +# TypeSafe credential used only by the live example and explicitly gated runs. +TYPESAFE_API_KEY=replace-me diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ba8c2fc..ff2213b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,7 +26,6 @@ jobs: # This job executes repository code (cargo build/test); don't persist # the token in git config. persist-credentials: false - submodules: recursive - uses: dtolnay/rust-toolchain@stable with: @@ -53,35 +52,6 @@ jobs: - name: Test default features run: cargo test - # `cargo build --all-targets` only *compiles* an example. `AGENTS.md` - # promises `cargo run -p template --example basic` works, and a compiled - # example can still fail on its first line. - - name: Run the bundled example - run: cargo run -p template --example basic - - # `crates/template-bus` exists so a host can name the payload types - # without compiling the module. That promise is invisible in a diff, - # because a forbidden dependency arrives transitively through a feature - # someone enabled one crate away — so it is asserted rather than - # documented. - # - # The FORWARD form is required. `cargo tree -i -p template-bus` - # discards the `-p` scope, prints the whole-workspace inverse tree, and - # exits 0 looking clean even when this crate is the one at fault. - - name: Assert the contract crate stays transport-free - run: | - set -euo pipefail - forbidden="$(cargo tree -p template-bus -e normal,build --prefix none \ - | grep -Ei 'tinybus|tokio|reqwest|ureq|hyper|rusqlite|git2' || true)" - if [ -n "$forbidden" ]; then - echo "template-bus pulled in a dependency its manifest forbids:" >&2 - echo "$forbidden" >&2 - echo >&2 - echo "The contract is what a host compiles against. It must stay free" >&2 - echo "of transports, async runtimes, HTTP clients and native libraries." >&2 - exit 1 - fi - - name: Require 90% line coverage in every source file run: .github/scripts/check-file-coverage.sh 90 coverage.json @@ -100,7 +70,6 @@ jobs: - uses: actions/checkout@v7 with: persist-credentials: false - submodules: recursive - uses: dtolnay/rust-toolchain@stable @@ -118,17 +87,14 @@ jobs: - uses: actions/checkout@v7 with: persist-credentials: false - submodules: recursive - # `rust-version` is inherited from `[workspace.package]`, so every member - # reports the same value. Read it off the package the module ships as - # rather than off `packages[0]`, whose order cargo does not promise. + # `rust-version` is inherited from `[workspace.package]`. - name: Read rust-version from Cargo.toml id: msrv run: | set -euo pipefail msrv="$(cargo metadata --format-version 1 --no-deps \ - | jq -r '.packages[] | select(.name == "template") | .rust_version')" + | jq -r '.packages[] | select(.name == "tinyjevclient") | .rust_version')" if [[ -z "$msrv" || "$msrv" == "null" ]]; then echo "workspace.package.rust-version is not set in Cargo.toml" >&2 exit 1 @@ -151,7 +117,6 @@ jobs: - uses: actions/checkout@v7 with: persist-credentials: false - submodules: recursive - name: Check advisories, licenses, bans, and sources uses: EmbarkStudios/cargo-deny-action@v2 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index 4acf379..0000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,591 +0,0 @@ -name: Release - -on: - workflow_dispatch: - inputs: - bump: - description: Version bump to release - type: choice - required: true - options: - - patch - - minor - - major - - current - -concurrency: - group: release-${{ github.ref_name }} - cancel-in-progress: false - -permissions: - contents: write - -env: - # The workspace member that ships as the loadable module. Its package name is - # the artifact name and the library name; `crates/template-bus` rides along on - # the same inherited version and is not packaged separately. - RELEASE_PACKAGE: template - -jobs: - prepare: - name: Prepare release - if: ${{ github.ref == 'refs/heads/main' }} - runs-on: ubuntu-latest - outputs: - crate_name: ${{ steps.version.outputs.crate_name }} - next_version: ${{ steps.version.outputs.next_version }} - tag: ${{ steps.version.outputs.tag }} - steps: - - uses: actions/checkout@v7 - with: - fetch-depth: 0 - submodules: recursive - - - uses: dtolnay/rust-toolchain@stable - with: - components: rustfmt, clippy - - - uses: taiki-e/install-action@v2 - with: - tool: cargo-llvm-cov - - - uses: Swatinem/rust-cache@v2 - - - name: Check formatting - run: cargo fmt --all -- --check - - - name: Clippy - run: cargo clippy --all-targets --all-features -- -D warnings - - - name: Build - run: cargo build --all-targets --all-features - - - name: Test - run: cargo test --all-features - - - name: Require 90% line coverage in every source file - run: .github/scripts/check-file-coverage.sh 90 target/coverage.json - - - name: Build documentation - env: - RUSTDOCFLAGS: -D warnings - run: cargo doc --no-deps --all-features - - - name: Compute next version - id: version - shell: bash - run: | - set -euo pipefail - - metadata="$(cargo metadata --format-version 1 --no-deps)" - crate_name="$(jq -r --arg name "$RELEASE_PACKAGE" \ - '.packages[] | select(.name == $name) | .name' <<< "$metadata")" - current_version="$(jq -r --arg name "$RELEASE_PACKAGE" \ - '.packages[] | select(.name == $name) | .version' <<< "$metadata")" - if [[ -z "$crate_name" || "$crate_name" == "null" ]]; then - echo "Could not resolve the crate name" >&2 - exit 1 - fi - if [[ -z "$current_version" || "$current_version" == "null" ]]; then - echo "Could not resolve the current crate version" >&2 - exit 1 - fi - - IFS=. read -r major minor patch <<< "$current_version" - case "${{ inputs.bump }}" in - current) - ;; - major) - major=$((major + 1)) - minor=0 - patch=0 - ;; - minor) - minor=$((minor + 1)) - patch=0 - ;; - patch) - patch=$((patch + 1)) - ;; - *) - echo "Unsupported bump: ${{ inputs.bump }}" >&2 - exit 1 - ;; - esac - - next_version="${major}.${minor}.${patch}" - tag="v${next_version}" - git fetch --tags origin - - if git rev-parse --verify --quiet "refs/tags/${tag}"; then - if [[ "${{ inputs.bump }}" != "current" ]]; then - echo "Tag ${tag} already exists" >&2 - exit 1 - fi - tagged_version="$( - git show "${tag}:Cargo.toml" \ - | sed -n '/^\[workspace\.package\]/,/^\[/ s/^version = "\([^"]*\)"/\1/p' \ - | head -n 1 - )" - if [[ "$tagged_version" != "$current_version" ]]; then - echo "Tag ${tag} does not contain version ${current_version}" >&2 - exit 1 - fi - elif [[ "${{ inputs.bump }}" == "current" ]]; then - echo "Tag ${tag} does not exist; choose a semantic version bump" >&2 - exit 1 - fi - - { - echo "crate_name=${crate_name}" - echo "current_version=${current_version}" - echo "next_version=${next_version}" - echo "tag=${tag}" - } >> "$GITHUB_OUTPUT" - - - name: Update crate version - if: ${{ inputs.bump != 'current' }} - env: - CRATE_NAME: ${{ steps.version.outputs.crate_name }} - NEXT_VERSION: ${{ steps.version.outputs.next_version }} - run: | - set -euo pipefail - # One version for the whole workspace: every member inherits it with - # `version.workspace = true`, so this is the only edit needed. - perl -0pi -e 's/(\[workspace\.package\][\s\S]*?\nversion = ")[^"]+(")/$1$ENV{NEXT_VERSION}$2/' Cargo.toml - # `--workspace` re-resolves the local packages only, which is what a - # version bump changes. `-p --precise` cannot express "and the - # other member moved too". - cargo update --workspace - released="$(cargo metadata --format-version 1 --no-deps \ - | jq -r --arg name "$CRATE_NAME" \ - '.packages[] | select(.name == $name) | .version')" - if [[ "$released" != "$NEXT_VERSION" ]]; then - echo "version bump did not take: expected ${NEXT_VERSION}, got ${released}" >&2 - exit 1 - fi - - - name: Commit version bump and tag - if: ${{ inputs.bump != 'current' }} - env: - RELEASE_TAG: ${{ steps.version.outputs.tag }} - run: | - set -euo pipefail - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add Cargo.toml Cargo.lock - git commit -m "Release ${RELEASE_TAG}" - git tag -a "${RELEASE_TAG}" -m "Release ${RELEASE_TAG}" - git push origin "HEAD:${GITHUB_REF_NAME}" - git push origin "${RELEASE_TAG}" - - native-bundles: - name: Rust module bundle (${{ matrix.id }}) - needs: prepare - strategy: - fail-fast: false - matrix: - include: - - id: ubuntu-22.04-x86_64 - os: ubuntu-22.04 - target: x86_64-unknown-linux-gnu - - id: ubuntu-22.04-arm64 - os: ubuntu-22.04-arm - target: aarch64-unknown-linux-gnu - - id: ubuntu-24.04-x86_64 - os: ubuntu-24.04 - target: x86_64-unknown-linux-gnu - - id: ubuntu-24.04-arm64 - os: ubuntu-24.04-arm - target: aarch64-unknown-linux-gnu - - id: macos-15-x86_64 - os: macos-15-intel - target: x86_64-apple-darwin - - id: macos-15-arm64 - os: macos-15 - target: aarch64-apple-darwin - - id: macos-26-x86_64 - os: macos-26-intel - target: x86_64-apple-darwin - - id: macos-26-arm64 - os: macos-26 - target: aarch64-apple-darwin - - id: windows-2022-x86_64 - os: windows-2022 - target: x86_64-pc-windows-msvc - - id: windows-2025-x86_64 - os: windows-2025 - target: x86_64-pc-windows-msvc - - id: windows-11-arm64 - os: windows-11-arm - target: aarch64-pc-windows-msvc - runs-on: ${{ matrix.os }} - steps: - - uses: actions/checkout@v7 - with: - ref: ${{ needs.prepare.outputs.tag }} - persist-credentials: false - submodules: recursive - - - uses: dtolnay/rust-toolchain@stable - - - uses: Swatinem/rust-cache@v2 - - - name: Verify native Rust target - shell: bash - env: - EXPECTED_TARGET: ${{ matrix.target }} - run: | - set -euo pipefail - actual_target="$(rustc -vV | sed -n 's/^host: //p')" - if [[ "$actual_target" != "$EXPECTED_TARGET" ]]; then - echo "expected ${EXPECTED_TARGET}, got ${actual_target}" >&2 - exit 1 - fi - - - name: Build installable module - run: cargo build --locked --release --lib --package ${{ env.RELEASE_PACKAGE }} - - - name: Verify Unix module through TinyBus loader - if: ${{ runner.os != 'Windows' }} - shell: bash - env: - CRATE_NAME: ${{ needs.prepare.outputs.crate_name }} - run: | - set -euo pipefail - library_name="${CRATE_NAME//-/_}" - case "$RUNNER_OS" in - Linux) module="target/release/lib${library_name}.so" ;; - macOS) module="target/release/lib${library_name}.dylib" ;; - *) echo "unsupported Unix runner: ${RUNNER_OS}" >&2; exit 1 ;; - esac - cargo run --locked --package template --example verify_module -- "$module" - - - name: Verify Windows module through TinyBus loader - if: ${{ runner.os == 'Windows' }} - shell: pwsh - env: - CRATE_NAME: ${{ needs.prepare.outputs.crate_name }} - run: | - $ErrorActionPreference = 'Stop' - $libraryName = $env:CRATE_NAME.Replace('-', '_') - $module = "target/release/$libraryName.dll" - $verifyRoot = Join-Path $env:RUNNER_TEMP 'template-module-verify' - New-Item -ItemType Directory -Force $verifyRoot | Out-Null - - $identity = [System.Security.Principal.WindowsIdentity]::GetCurrent() - $security = [System.Security.AccessControl.DirectorySecurity]::new() - $security.SetOwner($identity.User) - $security.SetAccessRuleProtection($true, $false) - $rights = [System.Security.AccessControl.FileSystemRights]::FullControl - $inheritance = [System.Security.AccessControl.InheritanceFlags]'ContainerInherit, ObjectInherit' - $propagation = [System.Security.AccessControl.PropagationFlags]::None - $access = [System.Security.AccessControl.AccessControlType]::Allow - foreach ($sidValue in @( - $identity.User.Value, - 'S-1-5-18', - 'S-1-5-32-544' - )) { - $sid = [System.Security.Principal.SecurityIdentifier]::new($sidValue) - $rule = [System.Security.AccessControl.FileSystemAccessRule]::new( - $sid, - $rights, - $inheritance, - $propagation, - $access - ) - [void]$security.AddAccessRule($rule) - } - Set-Acl -LiteralPath $verifyRoot -AclObject $security - - $verifiedModule = Join-Path $verifyRoot "$libraryName.dll" - Copy-Item -LiteralPath $module -Destination $verifiedModule - cargo run --locked --package template --example verify_module -- $verifiedModule - - - name: Assemble Unix module package - if: ${{ runner.os != 'Windows' }} - id: unix_package - shell: bash - env: - BUNDLE_ID: ${{ matrix.id }} - CRATE_NAME: ${{ needs.prepare.outputs.crate_name }} - VERSION: ${{ needs.prepare.outputs.next_version }} - run: | - set -euo pipefail - - library_name="${CRATE_NAME//-/_}" - case "$RUNNER_OS" in - Linux) module="target/release/lib${library_name}.so" ;; - macOS) module="target/release/lib${library_name}.dylib" ;; - *) echo "unsupported Unix runner: ${RUNNER_OS}" >&2; exit 1 ;; - esac - if [[ ! -f "$module" ]]; then - echo "module artifact is missing: ${module}" >&2 - exit 1 - fi - - package_name="${CRATE_NAME}-${VERSION}-${BUNDLE_ID}" - package_root="dist/${package_name}" - mkdir -p "$package_root" - install -m 755 "$module" "$package_root/" - install -m 644 LICENSE MODULE.md "$package_root/" - - module_name="$(basename "$module")" - module_hash="$(shasum -a 256 "$package_root/$module_name" | awk '{print $1}')" - printf '"%s" = "%s"\n' "$module_name" "$module_hash" \ - > "$package_root/modules.toml" - - tar -C "$package_root" -czf "dist/${package_name}.tar.gz" . - echo "archive=dist/${package_name}.tar.gz" >> "$GITHUB_OUTPUT" - - - name: Assemble Windows module package - if: ${{ runner.os == 'Windows' }} - id: windows_package - shell: pwsh - env: - BUNDLE_ID: ${{ matrix.id }} - CRATE_NAME: ${{ needs.prepare.outputs.crate_name }} - VERSION: ${{ needs.prepare.outputs.next_version }} - run: | - $ErrorActionPreference = 'Stop' - - $libraryName = $env:CRATE_NAME.Replace('-', '_') - $module = "target/release/$libraryName.dll" - if (-not (Test-Path -LiteralPath $module -PathType Leaf)) { - throw "module artifact is missing: $module" - } - - $packageName = "$env:CRATE_NAME-$env:VERSION-$env:BUNDLE_ID" - $packageRoot = "dist/$packageName" - New-Item -ItemType Directory -Force $packageRoot | Out-Null - Copy-Item -LiteralPath $module, 'LICENSE', 'MODULE.md' -Destination $packageRoot - - $moduleName = Split-Path -Leaf $module - $hash = (Get-FileHash -LiteralPath "$packageRoot/$moduleName" -Algorithm SHA256).Hash.ToLowerInvariant() - $utf8 = [System.Text.UTF8Encoding]::new($false) - [System.IO.File]::WriteAllText( - "$packageRoot/modules.toml", - ('"{0}" = "{1}"' -f $moduleName, $hash) + [Environment]::NewLine, - $utf8 - ) - - $archive = "dist/$packageName.zip" - Compress-Archive -Path "$packageRoot/*" -DestinationPath $archive - "archive=$archive" >> $env:GITHUB_OUTPUT - - - name: Upload Unix package - if: ${{ runner.os != 'Windows' }} - uses: actions/upload-artifact@v7 - with: - name: ${{ needs.prepare.outputs.crate_name }}-${{ matrix.id }} - path: ${{ steps.unix_package.outputs.archive }} - if-no-files-found: error - - - name: Upload Windows package - if: ${{ runner.os == 'Windows' }} - uses: actions/upload-artifact@v7 - with: - name: ${{ needs.prepare.outputs.crate_name }}-${{ matrix.id }} - path: ${{ steps.windows_package.outputs.archive }} - if-no-files-found: error - - distro-bundles: - name: Rust module bundle (${{ matrix.id }}) - needs: prepare - strategy: - fail-fast: false - matrix: - include: - - id: fedora-43-x86_64 - os: ubuntu-24.04 - container: fedora:43 - target: x86_64-unknown-linux-gnu - family: fedora - - id: fedora-43-arm64 - os: ubuntu-24.04-arm - container: fedora:43 - target: aarch64-unknown-linux-gnu - family: fedora - - id: fedora-44-x86_64 - os: ubuntu-24.04 - container: fedora:44 - target: x86_64-unknown-linux-gnu - family: fedora - - id: fedora-44-arm64 - os: ubuntu-24.04-arm - container: fedora:44 - target: aarch64-unknown-linux-gnu - family: fedora - - id: archlinux-rolling-x86_64 - os: ubuntu-24.04 - container: archlinux:base-devel - target: x86_64-unknown-linux-gnu - family: archlinux - runs-on: ${{ matrix.os }} - container: ${{ matrix.container }} - steps: - - name: Install Fedora build tools - if: ${{ matrix.family == 'fedora' }} - run: dnf install -y gcc git gzip make perl tar - - - name: Install Arch Linux build tools - if: ${{ matrix.family == 'archlinux' }} - run: pacman -Syu --noconfirm base-devel git - - - uses: actions/checkout@v7 - with: - ref: ${{ needs.prepare.outputs.tag }} - persist-credentials: false - submodules: recursive - - - uses: dtolnay/rust-toolchain@stable - - - name: Verify native Rust target - env: - EXPECTED_TARGET: ${{ matrix.target }} - run: | - set -euo pipefail - actual_target="$(rustc -vV | sed -n 's/^host: //p')" - if [[ "$actual_target" != "$EXPECTED_TARGET" ]]; then - echo "expected ${EXPECTED_TARGET}, got ${actual_target}" >&2 - exit 1 - fi - - - name: Build installable module - run: cargo build --locked --release --lib --package ${{ env.RELEASE_PACKAGE }} - - - name: Verify module through TinyBus loader - env: - CRATE_NAME: ${{ needs.prepare.outputs.crate_name }} - run: | - set -euo pipefail - library_name="${CRATE_NAME//-/_}" - verify_root="/opt/${CRATE_NAME}-module-verify" - install -d -m 700 "$verify_root" - install -m 755 "target/release/lib${library_name}.so" "$verify_root/" - cargo run --locked --package template --example verify_module -- \ - "$verify_root/lib${library_name}.so" - - - name: Assemble distribution module package - id: package - env: - BUNDLE_ID: ${{ matrix.id }} - CRATE_NAME: ${{ needs.prepare.outputs.crate_name }} - VERSION: ${{ needs.prepare.outputs.next_version }} - run: | - set -euo pipefail - - library_name="${CRATE_NAME//-/_}" - module="target/release/lib${library_name}.so" - if [[ ! -f "$module" ]]; then - echo "module artifact is missing: ${module}" >&2 - exit 1 - fi - - package_name="${CRATE_NAME}-${VERSION}-${BUNDLE_ID}" - package_root="dist/${package_name}" - mkdir -p "$package_root" - install -m 755 "$module" "$package_root/" - install -m 644 LICENSE MODULE.md "$package_root/" - - module_name="$(basename "$module")" - module_hash="$(sha256sum "$package_root/$module_name" | awk '{print $1}')" - printf '"%s" = "%s"\n' "$module_name" "$module_hash" \ - > "$package_root/modules.toml" - - tar -C "$package_root" -czf "dist/${package_name}.tar.gz" . - echo "archive=dist/${package_name}.tar.gz" >> "$GITHUB_OUTPUT" - - - name: Upload distribution package - uses: actions/upload-artifact@v7 - with: - name: ${{ needs.prepare.outputs.crate_name }}-${{ matrix.id }} - path: ${{ steps.package.outputs.archive }} - if-no-files-found: error - - github-release: - name: Create GitHub release - needs: - - prepare - - native-bundles - - distro-bundles - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v7 - with: - ref: ${{ needs.prepare.outputs.tag }} - persist-credentials: false - submodules: recursive - - - uses: dtolnay/rust-toolchain@stable - - - uses: Swatinem/rust-cache@v2 - - - uses: actions/download-artifact@v8 - with: - pattern: '*' - path: release-assets - merge-multiple: true - - - name: Create release checksum manifest with TinyBus - shell: bash - run: | - set -euo pipefail - shopt -s nullglob - assets=(release-assets/*.tar.gz release-assets/*.zip) - if [[ ${#assets[@]} -ne 16 ]]; then - echo "expected 16 module archives, found ${#assets[@]}" >&2 - exit 1 - fi - - checksum_args=() - for asset in "${assets[@]}"; do - checksum_args+=(--path "$asset") - done - cargo run --manifest-path vendor/tinybus/Cargo.toml --locked \ - --package tinybus --all-features --bin tinybus -- \ - modules checksum "${checksum_args[@]}" \ - --output release-assets/checksum.toml - - - name: Create immutable release with module packages - env: - GH_TOKEN: ${{ github.token }} - RELEASE_TAG: ${{ needs.prepare.outputs.tag }} - REPOSITORY: ${{ github.repository }} - run: | - set -euo pipefail - if gh release view "$RELEASE_TAG" --repo "$REPOSITORY" >/dev/null 2>&1; then - echo "Release ${RELEASE_TAG} already exists; immutable assets are unchanged." - else - gh release create "$RELEASE_TAG" release-assets/* \ - --repo "$REPOSITORY" \ - --verify-tag \ - --title "$RELEASE_TAG" \ - --generate-notes - fi - - - name: Verify the published module through TinyBus - shell: bash - env: - RELEASE_TAG: ${{ needs.prepare.outputs.tag }} - REPOSITORY: ${{ github.repository }} - CRATE_NAME: ${{ needs.prepare.outputs.crate_name }} - VERSION: ${{ needs.prepare.outputs.next_version }} - run: | - set -euo pipefail - archive="${CRATE_NAME}-${VERSION}-ubuntu-24.04-x86_64.tar.gz" - release_url="https://github.com/${REPOSITORY}/releases/tag/${RELEASE_TAG}" - sha256="$( - sed -n "s/^\"${archive}\" = \"\([0-9a-f]\{64\}\)\"$/\1/p" \ - release-assets/checksum.toml - )" - if [[ -z "$sha256" ]]; then - echo "checksum missing for ${archive}" >&2 - exit 1 - fi - - cargo run --manifest-path vendor/tinybus/Cargo.toml --locked \ - --package tinybus --all-features --example github_module_host -- \ - "$release_url" "$archive" "$sha256" - cargo run --locked --package template --example verify_github_release -- \ - "$release_url" "$archive" "$sha256" diff --git a/.gitmodules b/.gitmodules deleted file mode 100644 index da09a74..0000000 --- a/.gitmodules +++ /dev/null @@ -1,4 +0,0 @@ -[submodule "vendor/tinybus"] - path = vendor/tinybus - url = https://github.com/tinyhumansai/tinybus - branch = main diff --git a/AGENTS.md b/AGENTS.md index ee8fdfc..36a46e1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,126 +1,36 @@ # Repository Guidelines -This file is the single source of truth for how humans and coding agents work -in this repository. `CLAUDE.md` is a symlink to this file, so every agent reads -the same instructions. +`tinyjevclient` is a Rust 2024 client for TypeSafe AI's System One API and Jev +model. `CLAUDE.md` is a symlink to this file. -When you generate a new project from this template, keep this file and adapt -the project-specific parts (crate name, module map, feature flags, commands). -Delete guidance that no longer applies rather than leaving it to rot. +## Workflow -## Template Checklist +- Work in a feature worktree created with `worktree `; never edit `main`. +- Preserve unrelated changes and never rewrite or squash published history. +- The auto-commit hook may checkpoint files. Do not reset those commits. +- Push feature branches and open ready-for-review PRs against the canonical + `tinyhumansai/tinyjevclient` repository. Do not merge without authorization. +- Never commit API keys. Live calls read `TYPESAFE_API_KEY` only. -Do this once, in a single commit, before writing feature code: +## Architecture -- [ ] Rename `crates/template` and `crates/template-bus` to the project's crate - names, and update `name` in each manifest plus the `template-bus` entry in - the root `[workspace.dependencies]`. -- [ ] Set `description`, `keywords`, and `categories` in each manifest, and - `repository` in the root `[workspace.package]`. -- [ ] Rename the crate references in `README.md`, both `src/lib.rs` files, - `crates/template/examples/`, and `crates/template/tests/` (search for - `template` and `template_bus`). -- [ ] Replace the placeholder `greeting` module in both crates with the first - real feature area — payload types in the contract crate, behavior in the - module crate — keeping the `mod.rs` / `types.rs` / `test.rs` layout. -- [ ] Confirm `license` and `LICENSE` match the project's intended license. -- [ ] Update the security contact in `SECURITY.md`. -- [ ] Rename the TinyBus interface, object path, and member constants in - `crates/template-bus/src/names/`, and the matching `provides` / `methods` - declarations in `crates/template/src/tinybus_module/`, while keeping - `vendor/tinybus` pinned. -- [ ] Reset `CONTRACT_VERSION` in `crates/template-bus/src/version/` for the new - contract. -- [ ] Replace `ROADMAP.md` with the real plan, or delete it. -- [ ] Rewrite the "Project Structure" section below to describe this workspace. +The virtual workspace contains one library crate at `crates/tinyjevclient`. +Feature directories under `src/` contain a `mod.rs`, substantial definitions in +`types.rs`, unit tests in `test.rs`, and a short `README.md`: -## Project Structure +- `client/`: HTTP execution, retries, secret handling, and measurements. +- `request/`: Choice, Score, Noul, and request validation. +- `response/`: answers and request-relative response validation. +- `error/`: the crate-wide `Error` and `Result` types. -This is a Rust 2024 cargo workspace rooted at a virtual `Cargo.toml`. Every -crate lives under `crates/`, one directory per package, each directory named for -the package it holds. There is no root package: the crate that ships as the -loadable module is `crates/template`, the same as any other member. +The model supplies typed judgments; application code owns policy, thresholds, +workflow control, and side effects. Do not make confidence an authorization +decision, ask Jev to generate free-form text, or hide conditional workflows in +question text. -```text -Cargo.toml # virtual workspace: members, [workspace.package], - # [workspace.dependencies], [workspace.lints] -crates/ -├── template-bus/ # the wire contract: what crosses the bus, nothing else -│ ├── README.md # why the contract is its own crate -│ └── src/ -│ ├── lib.rs # crate docs + the entire public re-export surface -│ ├── names/ # interface, object path, one constant per member -│ ├── version/ # contract version and the host bind rule -│ └── / # one directory per payload family -└── template/ # the module: behavior, adapter, and the cdylib - ├── src/ - │ ├── lib.rs # crate docs + public surface, re-exporting the contract - │ ├── error/mod.rs # crate-wide `Error` and `Result` - │ ├── tinybus_module/ # TinyBus interface, ABI exports, integration tests - │ └── / # one directory per feature area - │ ├── mod.rs # module docs, wiring, smallest useful public API - │ ├── types.rs # substantial type definitions - │ └── test.rs # module-local unit tests - ├── tests/ # integration tests against the public API only - └── examples/ # runnable, compiled-in-CI usage examples -vendor/tinybus/ # pinned TinyBus host types and module SDK -docs/ -├── specs/ # behavior and architecture specifications -├── plans/ # test-first implementation plans -└── adr/ # immutable architecture decision records -``` - -### The two-crate split - -`crates/template-bus` holds every type that crosses the bus and the names of the -members that carry them. It has no transport, no runtime, and no behavior, and -CI asserts it stays that way. A host that only makes calls depends on it alone. - -`crates/template` depends on it and re-exports all of it, so -`template::GreetRequest` and `template_bus::GreetRequest` are the *same* type -rather than structural twins. That direction is load-bearing: a parallel set of -payload types for hosts would mean a conversion at every call site that nothing -checks. - -The rule for deciding where something goes: a payload type describes what a -frame carries and belongs in the contract; anything that answers a frame, holds -a connection, or touches an engine belongs in the module crate. - -Add a crate by creating `crates//` — `members = ["crates/*"]` picks it up -by existing. Inherit `version`, `edition`, `rust-version`, `license`, and -`repository` from `[workspace.package]`, take shared dependencies from -`[workspace.dependencies]`, and opt into the shared lint set with: - -```toml -[lints] -workspace = true -``` - -Each feature area belongs in a focused module directory under a crate's `src/`. -A module root explains the module, wires its pieces together, and exposes the -smallest useful API. Move substantial type definitions into `types.rs` and put -module-local unit tests in a dedicated `test.rs`, wired from the bottom of the -module root with: - -```rust -#[cfg(test)] -mod test; -``` +## Build and Test -Do not accumulate inline `mod tests` blocks in implementation files, and do not -let a general-purpose `utils.rs` or `helpers.rs` grow — those are a symptom of a -missing module. Prefer many small modules that each do one thing well over few -broad ones. - -Keep public exports centralized in each crate's `src/lib.rs` so downstream users -have one predictable surface. Put shared error variants in -`crates/template/src/error/mod.rs` and return the crate-wide `Result` from -fallible public APIs. - -## Build And Test - -Run every command from the repository root. These four are the contract; CI -runs exactly them, so a green local run should mean a green CI run. +Run from the repository root: ```sh cargo fmt --all -- --check @@ -129,206 +39,31 @@ cargo build --all-targets --all-features cargo test --all-features ``` -Supporting commands: - -- `cargo fmt --all` — format before committing. -- `cargo test ` — run a focused subset while iterating. -- `cargo test -p template-bus` — run one crate's suite. -- `cargo run -p template --example basic` — run the bundled example. -- `cargo doc --no-deps --all-features` — build the rustdoc CI also builds with - `RUSTDOCFLAGS="-D warnings"`. -- `cargo test --doc` — run doctests alone when editing documentation examples. - -Never skip, ignore, or delete a failing test to make a command pass. Fix the -root cause, or stop and report the blocker. - -## Coding Style - -Use standard `rustfmt` output and Rust 2024 idioms. Do not hand-format around -`rustfmt`, and do not add `#[rustfmt::skip]` without a comment explaining why. - -- `snake_case` for modules, files, functions, methods, fields, and locals. -- `PascalCase` for types, traits, and enum variants; `SCREAMING_SNAKE_CASE` for - constants and statics. -- Name things for what they are, not for their layer: `RetryPolicy`, not - `RetryHelper`. -- Prefer small, typed APIs over stringly-typed ones. Accept `&str` and generic - `impl Into` at boundaries; return owned, concrete types. -- Keep the public surface minimal: default to private, and export deliberately - from `src/lib.rs`. -- `unsafe` is forbidden workspace-wide by `[workspace.lints]` in the root - `Cargo.toml`. If a project genuinely needs it, relax the lint in its own - commit and document every invariant with a `// SAFETY:` comment. - -### Errors - -- One crate-wide `Error` enum per crate, in `src/error/mod.rs`, built with - `thiserror`. -- Fallible public functions return `Result`, the crate alias. -- Add a specific variant instead of stuffing context into a string; error - messages are lowercase, without trailing punctuation. -- Do not `unwrap()`, `expect()`, or `panic!` in library code paths. They are - fine in tests, examples, and genuinely unreachable states — where `expect` - must carry a message explaining the invariant. -- Document a `# Errors` section on every public fallible function and a - `# Panics` section on anything that can panic. - -### Dependencies +CI also runs rustdoc with warnings denied, the declared MSRV build, +`cargo-deny`, and the per-file 90% line-coverage gate. The bundled example is a +paid live operation and must only be run explicitly with `TYPESAFE_API_KEY`. -Adding a dependency is a design decision. Before adding one, check whether the -standard library or an existing dependency already covers the need. When you do -add one: +## Rust Style and Errors -- pin a caret range (`serde = "1"`), not an exact version; -- enable only the features you need, with `default-features = false` when that - meaningfully trims the tree; -- gate anything optional behind a Cargo feature, documented in `Cargo.toml`; -- declare it once in the root `[workspace.dependencies]` when more than one - crate needs it, and take it with `{ workspace = true }`; -- never add one to `crates/template-bus` that pulls in a transport, an async - runtime, an HTTP client, or a native library — CI fails the build if you do; -- leave a comment above the entry explaining *why* the crate is needed and what - uses it — see the existing entries for the expected tone; -- prefer well-maintained crates with a compatible license. +- Use rustfmt and Rust 2024 idioms; unsafe code is forbidden. +- Public items require rustdoc. Fallible public functions require `# Errors`. +- Return the crate-wide `Result` and add specific `Error` variants. +- Do not use `unwrap`, `expect`, `panic`, `todo`, or `unimplemented` in library + paths. +- Keep files below 700 lines and Markdown below 500 lines. +- Pin serde wire representations in tests and cover every failure variant. +- Tests are deterministic and offline unless explicitly named and gated live. -Keep `Cargo.lock` committed; this workspace ships a single lockfile so CI and -releases are reproducible. +## Dependencies -### Vendored dependencies - -TinyBus is registered as the `vendor/tinybus` git submodule and pinned by its -gitlink. It supplies the host types and module-side SDK required to build this -crate's `cdylib`. Initialize it after cloning with: - -```sh -git submodule update --init --recursive -``` - -Do not edit vendored code from the parent repository. Make TinyBus changes in -its own repository, push them there, then update this repository's gitlink in a -separate commit. Keep the exact path dependencies and minimal features unless a -new module capability requires more. - -## Testing - -- Module-local unit tests live in `crates//src//test.rs` and may - touch private items. -- Integration tests live in `crates//tests/` and exercise only the public - API — they are the regression suite for the crate's contract. -- Payload types pin their serde representation in a unit test. That - representation is the wire form: a host and a module that disagree about a - field name fail at runtime with a decode error. -- Use descriptive, behavioral test names: `rejects_an_empty_name`, not - `test_greet_2`. -- Cover the failure paths, not just the happy path. Every new error variant - needs a test that produces it. -- For async behavior, standardize on one runtime (`tokio` as a dev-dependency - for tests) rather than mixing runtimes. -- Tests must be deterministic and independent of network, wall-clock time, and - execution order. Gate any live/network test behind a feature or an env var and - name it `live_*` so it is easy to exclude. -- Maintain at least 90% line coverage in every source file. Add or update tests - with every behavior change, and note any deliberately untested edge case in - the pull request description. - -Write the test first when fixing a bug: a failing test that reproduces the -report, then the fix that turns it green. +Dependencies use caret ranges, minimal features, and a comment in the root +workspace manifest explaining why they exist. Prefer the standard library or an +existing dependency. Keep `Cargo.lock` committed and run `cargo deny check all` +when available. ## Documentation -Write documentation for the reader who has never seen the code. - -- Every public item gets a rustdoc comment. `missing_docs` is a warning that CI - treats as an error. -- Start every `mod.rs` and `test.rs` with a concise module-level `//!` - description. -- Each crate's `src/lib.rs` carries its crate-level overview: what the crate - does, the primary entry points, and a short runnable example. It should also - say what the crate deliberately does *not* hold, and why. -- Prefer concrete examples over vague description. Doc examples are compiled and - run by `cargo test`, so they cannot drift. -- Complex modules must include a module-level `README.md` covering their design, - public surface, and important operational constraints. -- Keep `README.md`, `docs/`, and module docs aligned with code changes in the - same commit that changes behavior. -- Write accepted behavior and constraints in `docs/specs/` before creating a - linked, implementation-ordered plan in `docs/plans/`. Specs define what and - why; plans define how and in what sequence. -- Keep every Markdown file, including this one, at 500 lines or fewer. When a - topic outgrows that, split it into focused files and link them from the - nearest `README.md`. - -## Git Workflow - -- Never commit directly to `main`. Branch first, one branch per logical change. -- Do feature work in a git worktree so the main checkout stays clean. -- Commit subjects are concise and imperative: `Add retry policy to the client`. - Keep the subject specific to the change and under ~72 characters. -- Make small, focused commits. Each commit should cover one logical change, - build independently, and avoid mixing formatting, refactors, and behavior - changes unless they are inseparable. -- Never commit secrets. `.env` is git-ignored; document new variables in - `.env.example` with placeholder values. -- Never force-push a shared branch, rewrite published history, or bypass hooks - with `--no-verify`. - -## Pull Requests - -Open pull requests ready for review, not as drafts, unless the work genuinely -must not merge yet. A pull request should: - -- summarize what changed and why, in a few sentences; -- call out public API or behavior changes explicitly, or state "None"; -- list the validation commands actually run, with their outcome; -- link the related issue; -- include updated tests, docs, and examples in the same change. - -The template in `.github/PULL_REQUEST_TEMPLATE.md` encodes this checklist. -Address review feedback by fixing it, and reply on each thread describing what -changed. Do not resolve a thread whose feedback you have not addressed or -explicitly declined with a reason. - -## Releases - -Releases run from `.github/workflows/release.yml` via a manual -`workflow_dispatch` with a `patch` / `minor` / `major` bump; `current` resumes -an interrupted release after its version commit and tag exist. The workflow -re-runs the full validation suite, computes the next version, updates -the root `[workspace.package]` version and `Cargo.lock`, commits and tags -`vX.Y.Z`, builds `crates/template` as a TinyBus module for every supported -platform, pushes, and creates an immutable GitHub release with installable -native packages. - -Consequently: - -- Do not hand-edit the `version` field in the root `[workspace.package]`; the - release workflow owns it. Every member inherits it with - `version.workspace = true`, so the whole workspace releases as one version. -- Follow semantic versioning. Any change to the public surface that is not - purely additive is a breaking change and needs a major bump (pre-1.0: a minor - bump). -- The module must be packageable for every release target — `main` should - always be green. - -## Agent Working Agreement - -For automated contributors specifically: - -1. **Read before writing.** Inspect the surrounding module and match its - conventions, comment density, and idiom rather than importing a house style. -2. **Verify, do not assume.** Run the four contract commands and read their - output before reporting a task complete. Report failures with the output; - never claim a check passed that you did not run. -3. **Stay in scope.** Implement what was asked. Do not opportunistically - refactor, reformat, upgrade dependencies, or "fix" unrelated code — raise it - instead. -4. **No placeholders in delivered code.** No `todo!()`, no stubbed functions, no - commented-out alternatives left behind. If something cannot be finished, say - so explicitly. -5. **Do not weaken the guardrails.** Never add blanket `#[allow(...)]`, relax a - lint, mark a test `#[ignore]`, or loosen CI to get a green run. Fix the - cause. -6. **Secrets stay out.** Never read, echo, or commit `.env` contents, tokens, or - credentials, and never paste them into a pull request or issue. -7. **Ask only when blocked.** Make routine judgment calls yourself; escalate - only irreversible decisions or genuine forks with no clear default. +Keep `README.md`, crate docs, module READMEs, `docs/specs/`, and `docs/plans/` +aligned with behavior. Specs define accepted behavior; plans define delivery +order. The live TypeSafe documentation is the source of truth for API fields and +limits. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1424f9d..42b5700 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -7,11 +7,10 @@ this document is the short path through them. ## Development Setup Install a stable Rust toolchain with Rust 2024 support (see `rust-version` in -`Cargo.toml` for the minimum supported version), initialize the vendored -submodules, then run the four checks CI runs: +`Cargo.toml` for the minimum supported version), then run the four checks CI +runs: ```sh -git submodule update --init --recursive cargo fmt --all -- --check cargo clippy --all-targets --all-features -- -D warnings cargo build --all-targets --all-features @@ -25,10 +24,11 @@ installing `cargo-llvm-cov`, run the same gate locally: .github/scripts/check-file-coverage.sh 90 target/coverage.json ``` -The bundled example should also run: +The bundled example performs a paid live call and runs only with an explicit +credential: ```sh -cargo run --example basic +TYPESAFE_API_KEY='' cargo run --example basic ``` ## Making A Change diff --git a/Cargo.lock b/Cargo.lock index b4f454e..713f6fd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3,36 +3,16 @@ version = 4 [[package]] -name = "adler2" -version = "2.0.1" +name = "atomic-waker" +version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" - -[[package]] -name = "arbitrary" -version = "1.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" -dependencies = [ - "derive_arbitrary", -] - -[[package]] -name = "async-trait" -version = "0.1.92" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.3", -] +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[package]] name = "base64" -version = "0.23.1" +version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "bitflags" @@ -69,29 +49,29 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] -name = "crc32fast" -version = "1.5.0" +name = "cfg_aliases" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" -dependencies = [ - "cfg-if", -] +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" [[package]] -name = "crossbeam-utils" -version = "0.8.22" +name = "chacha20" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures", + "rand_core", +] [[package]] -name = "derive_arbitrary" -version = "1.4.2" +name = "cpufeatures" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", + "libc", ] [[package]] @@ -102,55 +82,55 @@ checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn", ] [[package]] -name = "equivalent" -version = "1.0.2" +name = "find-msvc-tools" +version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +checksum = "26b73573e6edcd2af0cdf47bd6cb58f0b3839491263c314eaad1ccf24430e1de" [[package]] -name = "errno" -version = "0.3.14" +name = "form_urlencoded" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" dependencies = [ - "libc", - "windows-sys 0.61.2", + "percent-encoding", ] [[package]] -name = "fastrand" -version = "2.5.0" +name = "futures-channel" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", +] [[package]] -name = "filetime" -version = "0.2.29" +name = "futures-core" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" -dependencies = [ - "cfg-if", - "libc", -] +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" [[package]] -name = "find-msvc-tools" -version = "0.1.10" +name = "futures-task" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26b73573e6edcd2af0cdf47bd6cb58f0b3839491263c314eaad1ccf24430e1de" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" [[package]] -name = "flate2" -version = "1.1.9" +name = "futures-util" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ - "crc32fast", - "miniz_oxide", + "futures-core", + "futures-task", + "pin-project-lite", + "slab", ] [[package]] @@ -160,8 +140,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", ] [[package]] @@ -171,16 +153,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi", + "rand_core", + "wasm-bindgen", ] -[[package]] -name = "hashbrown" -version = "0.17.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" - [[package]] name = "http" version = "1.5.0" @@ -191,6 +170,29 @@ dependencies = [ "itoa", ] +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + [[package]] name = "httparse" version = "1.10.1" @@ -198,21 +200,197 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" [[package]] -name = "indexmap" -version = "2.14.0" +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" dependencies = [ - "equivalent", - "hashbrown", + "icu_normalizer", + "icu_properties", ] +[[package]] +name = "ipnet" +version = "2.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" + [[package]] name = "itoa" version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "js-sys" +version = "0.3.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + [[package]] name = "libc" version = "0.2.189" @@ -220,10 +398,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]] -name = "linux-raw-sys" -version = "0.12.1" +name = "litemap" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" [[package]] name = "log" @@ -231,6 +409,12 @@ version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +[[package]] +name = "lru-slab" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f" + [[package]] name = "memchr" version = "2.8.3" @@ -238,13 +422,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] -name = "miniz_oxide" -version = "0.8.9" +name = "mio" +version = "1.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" dependencies = [ - "adler2", - "simd-adler32", + "libc", + "wasi", + "windows-sys 0.61.2", ] [[package]] @@ -265,6 +450,15 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + [[package]] name = "proc-macro2" version = "1.0.107" @@ -274,6 +468,62 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "quinn" +version = "0.11.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9746dbde176634f4f2f1faf2404e30a31b2bc1e9cafb5329c95d8177a18c9fc" +dependencies = [ + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + [[package]] name = "quote" version = "1.0.47" @@ -289,6 +539,70 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core", +] + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + [[package]] name = "ring" version = "0.17.14" @@ -304,25 +618,17 @@ dependencies = [ ] [[package]] -name = "rustix" -version = "1.1.4" +name = "rustc-hash" +version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" -dependencies = [ - "bitflags", - "errno", - "libc", - "linux-raw-sys", - "windows-sys 0.61.2", -] +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" [[package]] name = "rustls" -version = "0.23.43" +version = "0.23.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" dependencies = [ - "log", "once_cell", "ring", "rustls-pki-types", @@ -337,20 +643,33 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" dependencies = [ + "web-time", "zeroize", ] [[package]] name = "rustls-webpki" -version = "0.103.13" +version = "0.103.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" dependencies = [ "ring", "rustls-pki-types", "untrusted", ] +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + [[package]] name = "serde" version = "1.0.229" @@ -378,7 +697,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn", ] [[package]] @@ -395,11 +714,14 @@ dependencies = [ ] [[package]] -name = "serde_spanned" -version = "0.6.9" +name = "serde_urlencoded" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" dependencies = [ + "form_urlencoded", + "itoa", + "ryu", "serde", ] @@ -410,28 +732,39 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" [[package]] -name = "simd-adler32" -version = "0.3.10" +name = "slab" +version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] -name = "subtle" -version = "2.6.1" +name = "smallvec" +version = "1.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891" [[package]] -name = "syn" -version = "2.0.119" +name = "socket2" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", + "libc", + "windows-sys 0.61.2", ] +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "3.0.3" @@ -444,47 +777,23 @@ dependencies = [ ] [[package]] -name = "tar" -version = "0.4.46" +name = "sync_wrapper" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" dependencies = [ - "filetime", - "libc", - "xattr", + "futures-core", ] [[package]] -name = "tempfile" -version = "3.27.0" +name = "synstructure" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" dependencies = [ - "fastrand", - "getrandom 0.4.3", - "once_cell", - "rustix", - "windows-sys 0.61.2", -] - -[[package]] -name = "template" -version = "0.2.1" -dependencies = [ - "serde_json", - "template-bus", - "thiserror", - "tinybus", - "tinybus-module", - "tokio", -] - -[[package]] -name = "template-bus" -version = "0.2.1" -dependencies = [ - "serde", - "serde_json", + "proc-macro2", + "quote", + "syn", ] [[package]] @@ -504,48 +813,36 @@ checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn", ] [[package]] -name = "tinybus" -version = "0.1.0" +name = "tinyjevclient" +version = "0.2.1" dependencies = [ - "async-trait", - "flate2", + "httpdate", + "reqwest", "serde", "serde_json", - "tar", - "tempfile", "thiserror", - "tinybus-macros", "tokio", - "toml", - "tracing", - "ureq", - "zip", ] [[package]] -name = "tinybus-macros" -version = "0.1.0" +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", + "displaydoc", + "zerovec", ] [[package]] -name = "tinybus-module" -version = "0.1.0" -dependencies = [ - "async-trait", - "serde", - "serde_json", - "tinybus", - "tokio", - "tracing", -] +name = "tinyvec" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" [[package]] name = "tokio" @@ -554,8 +851,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" dependencies = [ "bytes", + "libc", + "mio", "pin-project-lite", + "socket2", "tokio-macros", + "windows-sys 0.61.2", ] [[package]] @@ -566,49 +867,63 @@ checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn", ] [[package]] -name = "toml" -version = "0.8.23" +name = "tokio-rustls" +version = "0.26.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +checksum = "b0c85f2c3ef0b1cd58b36682f4b17aaa995f0e5db534d85692b4903abce21f67" dependencies = [ - "serde", - "serde_spanned", - "toml_datetime", - "toml_edit", + "rustls", + "tokio", ] [[package]] -name = "toml_datetime" -version = "0.6.11" +name = "tower" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" dependencies = [ - "serde", + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", ] [[package]] -name = "toml_edit" -version = "0.22.27" +name = "tower-http" +version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ - "indexmap", - "serde", - "serde_spanned", - "toml_datetime", - "toml_write", - "winnow", + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", ] [[package]] -name = "toml_write" -version = "0.1.2" +name = "tower-layer" +version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" [[package]] name = "tracing" @@ -617,21 +932,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "pin-project-lite", - "tracing-attributes", "tracing-core", ] -[[package]] -name = "tracing-attributes" -version = "0.1.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - [[package]] name = "tracing-core" version = "0.1.36" @@ -641,6 +944,12 @@ dependencies = [ "once_cell", ] +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -654,39 +963,31 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" [[package]] -name = "ureq" -version = "3.4.0" +name = "url" +version = "2.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "972d7902c8735f2695410b8aed7df6ed12a47394aa1c8d7af49f0497b731a94d" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" dependencies = [ - "base64", - "flate2", - "log", + "form_urlencoded", + "idna", "percent-encoding", - "rustls", - "rustls-pki-types", - "ureq-proto", - "utf8-zero", - "webpki-roots", + "serde", ] [[package]] -name = "ureq-proto" -version = "0.6.1" +name = "utf8_iter" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da5f78b09e6941e1a0f2e30e695e4b120377b54d5e0aec11b594bb57b3971613" -dependencies = [ - "base64", - "http", - "httparse", - "log", -] +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] -name = "utf8-zero" -version = "0.8.1" +name = "want" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] [[package]] name = "wasi" @@ -694,6 +995,81 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasm-bindgen" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.78" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ef4c5d3d2cdf5c54f4231181768f5510842e350db025faf1f7163b1030ed928" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fbddc4a036f00ec4f18c83445bd3115cb306a91da554919a099d9222fe4a7f8" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "webpki-roots" version = "1.0.9" @@ -792,22 +1168,53 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] -name = "winnow" -version = "0.7.15" +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "yoke" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" dependencies = [ - "memchr", + "stable_deref_trait", + "yoke-derive", + "zerofrom", ] [[package]] -name = "xattr" -version = "1.6.1" +name = "yoke-derive" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +checksum = "33811428bee40dbceb6d545e95754741d17a6aef9a4849f0fd62e2ba4f412a78" dependencies = [ - "libc", - "rustix", + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", ] [[package]] @@ -817,36 +1224,40 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" [[package]] -name = "zip" -version = "2.4.2" +name = "zerotrie" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" dependencies = [ - "arbitrary", - "crc32fast", - "crossbeam-utils", "displaydoc", - "flate2", - "indexmap", - "memchr", - "thiserror", - "zopfli", + "yoke", + "zerofrom", ] [[package]] -name = "zmij" -version = "1.0.23" +name = "zerovec" +version = "0.11.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] [[package]] -name = "zopfli" -version = "0.8.3" +name = "zerovec-derive" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" dependencies = [ - "bumpalo", - "crc32fast", - "log", - "simd-adler32", + "proc-macro2", + "quote", + "syn", ] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml index fae3c38..065cf4b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,18 +1,10 @@ [workspace] resolver = "3" -# Every crate in this repository lives under `crates/`, one directory per -# package, each directory named for the package it holds. There is no root -# package: the crate a host loads is `crates/template`, the same as any other -# member. Keeping the root virtual is what makes that uniform — a root package -# would make one crate structurally different from the rest for no reason other -# than history, and it is the arrangement this template moved away from. +# The HTTP client is the repository's only package. The root stays virtual so +# examples, tests, and documentation all exercise the same library artifact. members = ["crates/*"] -# `vendor/` holds the pinned TinyBus submodule, which is its own workspace with -# its own lockfile. `worktrees/` holds `git worktree` checkouts of this same -# repository; each contains a full copy of this manifest and every crate under -# it, so without this entry cargo walks into them and reports duplicate -# packages. -exclude = ["vendor", "worktrees"] +# Worktrees contain another copy of this manifest and must not become members. +exclude = ["worktrees"] # Shared package metadata. A member inherits a field with `field.workspace = # true`, so the version the release workflow bumps is written in exactly one @@ -22,35 +14,23 @@ version = "0.2.1" edition = "2024" rust-version = "1.88" license = "GPL-3.0-only" -repository = "https://github.com/tinyhumansai/rust-template" +repository = "https://github.com/tinyhumansai/tinyjevclient" [workspace.dependencies] -# The wire contract. `crates/template` depends on it and re-exports it, so a -# host that only makes calls takes this crate alone. -# No `version` requirement on purpose: the workspace version moves on every -# release, and a pinned requirement here would stop resolving the moment it did. -# Nothing in this workspace is published, so the path is the whole address. -template-bus = { path = "crates/template-bus" } -# TinyBus defines the message types, interface macro, and frozen module ABI -# used by the generated integration. Socket and CLI features are unnecessary -# here. -tinybus = { path = "vendor/tinybus/crates/tinybus", version = "0.1.0", default-features = false, features = [ - "macros", - "modules", -] } -# The module-side SDK owns the isolated runtime and exports the ABI entrypoints -# required by TinyBus's dynamic loader. -tinybus-module = { path = "vendor/tinybus/crates/tinybus-module", version = "0.1.0" } -# Derive macros for the crate-wide error type in `crates/template/src/error/`. +# Derive macros for the crate-wide error type. # Every dependency entry should carry a comment like this one saying why it is # here. thiserror = "2" -# The bus payload types are serialized into TinyBus frames. +# Requests and responses use the TypeSafe System One JSON wire contract. serde = { version = "1", features = ["derive"] } -# Positional argument arrays and the module configuration blob. +# State and structured instructions may contain arbitrary JSON. serde_json = "1" -# Module integration tests exercise the real asynchronous in-memory TinyBus. -tokio = { version = "1", features = ["macros", "rt-multi-thread", "time"] } +# The client uses a rustls-backed HTTP transport and no platform TLS library. +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } +# Parses the HTTP-date form of Retry-After without accepting locale-dependent dates. +httpdate = "1" +# Bounded retry delays are asynchronous and inherit the caller's Tokio runtime. +tokio = { version = "1", features = ["time"] } # Lints apply to every member that opts in with `[lints] workspace = true`, and # to every target of that member. CI runs clippy with `-D warnings`, so anything diff --git a/MODULE.md b/MODULE.md deleted file mode 100644 index 651906e..0000000 --- a/MODULE.md +++ /dev/null @@ -1,31 +0,0 @@ -# Template TinyBus Module - -This package contains the native `template` module for TinyBus module ABI -v1. Install only the archive matching the host operating system and -architecture. - -The module claims `ai.tinyhumans.template.Greeting`, serves the object at -`/ai/tinyhumans/template/Greeting`, and provides the `Greet` method. The -method accepts a `GreetRequest` and returns a `GreetResponse` carrying -`Hello, !`; empty names are rejected. Both payload types, the interface -name, the object path, and the member names are published as the `template-bus` -crate, so a host names them from a library rather than by string literal. - -The archive contains one `.so`, `.dylib`, or `.dll` plus `modules.toml`. Keep -those files together when copying them into a TinyBus module directory. The -allowlist binds the native library filename to its SHA-256 digest so TinyBus can -reject a missing, renamed, or modified artifact before initialization. - -The GitHub release also publishes `checksum.toml` as a separate asset. TinyBus -checks that manifest before downloading and extracting the selected platform -archive. Install directly from a tagged release with: - -```sh -tinybus modules load-github \ - https://github.com/tinyhumansai/rust-template/releases/tag/v0.1.5 \ - template-0.1.5-ubuntu-24.04-x86_64.tar.gz \ - -``` - -TinyBus modules are trusted in-process code. Install release artifacts only -from a trusted source and restart the host after replacing a loaded module. diff --git a/README.md b/README.md index 67a4e39..60f757b 100644 --- a/README.md +++ b/README.md @@ -1,156 +1,51 @@ -# Rust Template - -A production-ready Rust 2024 TinyBus module template used by TinyHumans AI. It -ships the workspace layout, TinyBus ABI adapter, error handling, testing, -documentation, CI, and multi-platform release workflow that every new -integration in this organization starts from. - -It is a two-crate cargo workspace. `crates/template-bus` is the wire contract — -member names, payload types, and the contract version, with no transport and no -behavior — and `crates/template` is the implementation, built as both an `rlib` -and the `cdylib` TinyBus loads. A host that only makes calls depends on the -contract crate alone and compiles neither the module nor `tinybus` itself. - -## Use This Template - -Choose **Use this template** on GitHub, create a repository, then work through -the checklist at the top of [`AGENTS.md`](AGENTS.md): - -- rename the `crates/template` and `crates/template-bus` directories and the - `name` fields in their manifests, and set the shared `description`, - `repository`, `keywords`, and `categories`; -- update this README and the crate documentation in `crates/template/src/lib.rs`; -- replace the placeholder `greeting` module with the first real feature area, in - both crates: the payload types in the contract, the behavior in the module; -- rename the TinyBus interface, object path, and member constants in - `crates/template-bus/src/names/`, and the matching `provides` / `methods` - declarations in `crates/template/src/tinybus_module/`; -- update the security contact and repository links in the community files; -- replace `ROADMAP.md` with the real plan, or delete it; -- change the license if GPL-3.0-only is not appropriate. - -Search for `template` and `template_bus` to find every remaining -template-specific value. - -## What You Get - -| Area | What is configured | -| --- | --- | -| Layout | A cargo workspace under `crates/`, split into a dependency-light wire contract and the module that implements it; directory modules with `mod.rs` / `types.rs` / `test.rs`, a crate-wide error type, integration tests, and a runnable example | -| Lints | `unsafe_code` forbidden, `missing_docs`, clippy `all` + `pedantic`, no `unwrap`/`expect`/`panic`/`todo` in library code — all declared once in `[workspace.lints]` so every crate, local run, and CI run agree | -| CI | Format, clippy, build, test (default and all features), a run of the bundled example, an assertion that the contract crate stays transport-free, at least 90% line coverage in every source file, rustdoc with `-D warnings`, an MSRV build, and a `cargo-deny` supply-chain check | -| Release | Manual `workflow_dispatch` bump that validates, versions, tags, and creates installable native module packages for every supported platform | -| Community | Issue and pull request templates, Dependabot, contributing, security, support, and code of conduct docs | -| Agents | [`AGENTS.md`](AGENTS.md) as the single source of truth, symlinked as `CLAUDE.md`, plus a `.claude/settings.json` allowlist for the standard commands | -| Vendor | TinyBus host types and module SDK pinned as the `vendor/tinybus` build-time submodule | - -## Layout - -```text -Cargo.toml # virtual workspace: members, shared metadata, lints -crates/ -├── template-bus/ # the wire contract — what crosses the bus -│ ├── README.md # why the contract is its own crate -│ └── src/ -│ ├── lib.rs # crate docs + the entire public re-export surface -│ ├── names/ # interface, object path, one constant per member -│ ├── greeting/ # payload types, one directory per family -│ │ ├── mod.rs -│ │ ├── types.rs -│ │ └── test.rs -│ └── version/ # contract version and the host bind rule -└── template/ # the module — behavior, adapter, and the cdylib - ├── src/ - │ ├── lib.rs # crate docs + public surface, re-exporting the contract - │ ├── error/ # crate-wide `Error` and `Result` - │ ├── greeting/ # one directory per feature area - │ └── tinybus_module/ # bus interface, setup, and ABI v1 exports - ├── tests/ - │ └── public_api.rs # integration tests against the public API only - └── examples/ - ├── basic.rs # ordinary library API usage - ├── verify_module.rs # local dynamic-module verification - └── verify_github_release.rs # tagged-release download and bus call -vendor/ -└── tinybus/ # pinned TinyBus git submodule -docs/ -├── README.md # documentation index and conventions -├── specs/ # behavior and architecture specifications -├── plans/ # implementation-ordered delivery plans -└── adr/ # immutable architecture decision records -``` - -The split is the point. A payload type describes what a frame carries; the -behavior that answers it is a different obligation. `template` depends on -`template-bus` and re-exports all of it, so `template::GreetRequest` and -`template_bus::GreetRequest` are the *same* type rather than structural twins, -and a host is never forced to choose between linking the whole module and -redefining the vocabulary. See -[`crates/template-bus/README.md`](crates/template-bus/README.md). - -Within each crate, feature areas use directory modules: implementation and -exports live in `mod.rs`, substantial types move to `types.rs`, and unit tests -live in `test.rs`. [`AGENTS.md`](AGENTS.md) holds the complete repository -guidance, and `CLAUDE.md` is a symlink to it so every coding agent reads one -source of truth. - -## Development - -Clone with submodules, or initialize them before building: - -```sh -git submodule update --init --recursive -``` - -```sh -cargo fmt --all -- --check -cargo clippy --all-targets --all-features -- -D warnings -cargo build --all-targets --all-features -cargo test --all-features -cargo run -p template --example basic -cargo build -p template --release --lib # produces the installable cdylib +# TinyJevClient + +`tinyjevclient` is a typed Rust client for TypeSafe AI's System One API and +Jev model. It sends shared state with independent Choice, Score, and Noul +questions, validates the provider's response against the originating request, +and returns latency, attempts, usage, and request metadata alongside the typed +answers. + +The client keeps execution and policy outside the model. A Choice selects only +from caller-supplied values, a Score rates one described dimension, and a Noul +reports the probability of a yes/no condition. Callers own confidence +thresholds, escalation, state transitions, and side effects. + +```rust,no_run +use std::collections::BTreeMap; +use serde_json::json; +use tinyjevclient::{Choice, Client, EvaluationRequest, Question}; + +# async fn run() -> Result<(), Box> { +let request = EvaluationRequest::jev( + json!({"ticket": "I was charged twice"}), + BTreeMap::from([( + "route".to_owned(), + Question::Choice(Choice { + instructions: json!("Which team should handle this ticket?"), + criteria: BTreeMap::from([ + ("billing".to_owned(), None), + ("technical".to_owned(), None), + ]), + }), + )]), +); +let result = Client::from_env()?.evaluate(&request).await?; +println!("{:?}", result.response.answers["route"]); +# Ok(()) +# } ``` -Those four checks are exactly what CI runs. Optional extras: +The API key is read from `TYPESAFE_API_KEY` or supplied to `ClientConfig`. It is +redacted from `Debug` and never included in errors. The live example spends a +real API call: ```sh -cargo doc --no-deps --all-features # CI builds this with RUSTDOCFLAGS="-D warnings" -cargo deny check all # supply-chain check; see deny.toml -cargo install cargo-llvm-cov # once, before running the coverage gate -.github/scripts/check-file-coverage.sh 90 coverage.json +TYPESAFE_API_KEY='' cargo run -p tinyjevclient --example basic ``` -## Releasing - -Run the **Release** workflow from the Actions tab with a `patch`, `minor`, or -`major` bump. Use `current` only to resume an interrupted release whose version -commit and tag already exist. The workflow revalidates the workspace, versions -and tags it — one `[workspace.package]` version that every member inherits — -builds `crates/template` as a TinyBus `cdylib`, and creates a GitHub release. -Assets follow `template--.` and contain the -native module, its SHA-256 `modules.toml`, license, and -[`MODULE.md`](MODULE.md). Every release also publishes `checksum.toml`, which -TinyBus uses to verify an archive before extraction. The workflow loads the -published Ubuntu archive through TinyBus's GitHub release API and calls its -`Greet` method before declaring the release successful. TinyBus itself is not -shipped by this repository; the pinned submodule is the build-time SDK. The stable native -matrix covers Ubuntu 22.04 and 24.04 on x86_64 and ARM64; Fedora 43 and 44 on -x86_64 and ARM64; rolling Arch Linux on its officially supported x86_64 -architecture; macOS 15 and 26 on Intel and Apple Silicon; Windows Server 2022 -and 2025 on x86_64; and Windows 11 on ARM64. Preview, deprecated, and unofficial -architecture images are not release gates. Do not hand-edit the version in the -root `Cargo.toml`. - -## Documentation - -- [`AGENTS.md`](AGENTS.md) — repository guidelines for humans and agents -- [`CONTRIBUTING.md`](CONTRIBUTING.md) — how to propose a change -- [`docs/specs/`](docs/specs/README.md) — behavior and architecture specs -- [`docs/plans/`](docs/plans/README.md) — test-first implementation plans -- [`docs/adr/`](docs/adr/0001-record-architecture-decisions.md) — architecture - decision records -- [`SECURITY.md`](SECURITY.md) — how to report a vulnerability - -## License +Remote API roots must use HTTPS; HTTP is reserved for literal loopback IPs. +Failed evaluations retain their classified error, attempt count, and elapsed +time so reliability measurements do not lose unsuccessful work. -GPL-3.0-only. See [LICENSE](LICENSE). +The repository is GPL-3.0-only and is consumed by pinned git revision. diff --git a/ROADMAP.md b/ROADMAP.md index 1134024..3ba36c5 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,26 +1,19 @@ # Roadmap -Replace this file with the real plan for the crate generated from this -template, or delete it if the project does not need a public roadmap. - -Keep it short and honest: what exists, what is next, and what is deliberately -out of scope. A roadmap that lists everything is a roadmap nobody trusts. - ## Shipped -- module layout, crate-wide error type, and the public re-export surface -- lint configuration in `[lints]`, enforced identically locally and in CI -- CI: format, clippy, build, test, per-file coverage, rustdoc, MSRV, and - supply-chain checks -- a manual release workflow that versions, tags, publishes to crates.io, and - creates a GitHub release with crate and TinyBus runtime/module assets +- typed Choice, Score, and Noul request/response contracts +- request-relative response validation +- rustls HTTP client with classified failures and bounded retries +- redacted credentials and measured usage, attempts, request id, and latency ## Next -- the first real feature area, replacing the placeholder `greeting` module -- module-level `README.md` and `docs/spec/` entries as modules grow +- production evidence from the TinyHiveMind paired benchmark +- API additions only when a real integration requires them -## Out Of Scope +## Out of scope -- anything that cannot be tested deterministically -- convenience wrappers that hide the crate's error taxonomy from callers +- executing selected actions +- choosing application confidence thresholds +- generating free-form text diff --git a/crates/template-bus/Cargo.toml b/crates/template-bus/Cargo.toml deleted file mode 100644 index a30dd85..0000000 --- a/crates/template-bus/Cargo.toml +++ /dev/null @@ -1,24 +0,0 @@ -[package] -name = "template-bus" -version.workspace = true -edition.workspace = true -rust-version.workspace = true -license.workspace = true -repository.workspace = true -description = "The TinyBus wire contract for the template module: member names, payload types, and the contract version." -documentation = "https://docs.rs/template-bus" -readme = "README.md" -keywords = ["tinybus", "module", "contract", "template"] -categories = ["development-tools"] -publish = false - -# Deliberately dependency-light: this is the crate a host links to talk to the -# loadable module, so it must cost that host almost nothing. Nothing here may -# pull in `tinybus`, an async runtime, an HTTP client, or a native library — -# see `src/lib.rs` for why the transport in particular is absent. CI asserts it. -[dependencies] -serde = { workspace = true } -serde_json = { workspace = true } - -[lints] -workspace = true diff --git a/crates/template-bus/README.md b/crates/template-bus/README.md deleted file mode 100644 index 7f8e99c..0000000 --- a/crates/template-bus/README.md +++ /dev/null @@ -1,100 +0,0 @@ -# template-bus - -Every type that crosses the template module's `TinyBus` boundary, and the names -of the members that carry them. - -The template ships as a loadable module so a host does not compile the -implementation: `crates/template` is built as a `cdylib` and exports one object. -A host can load that binary but cannot `use` anything out of it, so the payload -vocabulary has to be published as an ordinary library. This is it. - -| module | what it holds | -| ---------- | ------------------------------------------------------------ | -| `names` | interface name, object path, one constant per member | -| `greeting` | the value vocabulary: the `Greet` request and response | -| `version` | `CONTRACT_VERSION` and the bind rule a host applies to it | - -Two dependencies, both pure Rust: `serde` and `serde_json`. - -## This crate sits underneath `template` - -`template` **depends on this crate and re-exports all of it**. That direction -matters, and it is the opposite of the obvious one. - -A *host* needs the payload types and needs nothing else: it loads the module and -makes calls, so it names `GreetRequest` and `GreetResponse` but implements no -behavior and links no transport. Making it depend on the whole module crate — and -through it on `tinybus`, `tokio`, and the module SDK — to spell a payload type -would be the wrong shape. - -The alternative, a parallel set of payload types for hosts, is worse: a -`GreetRequest` defined twice is two distinct types, with a conversion at every -call site that nothing checks. One definition, here, at the bottom. - -Because the re-export is by module as well as by item, `template::GreetRequest`, -`template::names::OBJECT_PATH`, and `template_bus::greeting::GreetRequest` all -resolve to the same items, not twins. - -So: a module author depends on `template` and gets behavior and vocabulary. A -host depends on `template-bus` and gets vocabulary alone. - -## What is deliberately absent - -**No behavior.** `greet` lives in `crates/template`. A payload type describes -what a frame carries, not what the module does with it. The split is readable -off the path: a name here is data, a name there is an obligation. - -**No transport.** This crate does not depend on `tinybus` and holds no -connection, client, or codec. A host already owns its connection — its reconnect -policy, its timeouts, its tracing — and the useful part is the vocabulary. - -That is also structural, not just preference: `tinybus` is vendored as a -submodule whose manifest inherits fields from its own nested -`[workspace.package]`. Keeping the contract crate transport-free is what keeps -it down to two dependencies and what lets anything in the workspace — or outside -it — depend on it freely. CI asserts the dependency tree stays that way. - -## Making a call - -Arguments travel as a positional JSON array — `#[tinybus::interface]` decodes -them into a tuple — and the member name comes from `names`: - -```rust,ignore -use template_bus::{names, GreetRequest, GreetResponse}; - -let proxy = connection.proxy(names::INTERFACE, names::OBJECT_PATH, names::INTERFACE)?; -let reply: GreetResponse = proxy - .call(names::methods::GREET, (GreetRequest::new("Ferris"),)) - .await?; -assert_eq!(reply.greeting, "Hello, Ferris!"); -``` - -Nothing above is a string literal at a call site. Renaming the interface, the -path, or a member is therefore a compile error in every consumer rather than an -`UnknownMethod` discovered at runtime. - -## Staying in step with the module - -`names::METHODS` lists every member in dispatch order. `crates/template` asserts -its served members against that list, so a method added to the interface without -an entry here fails that crate's tests rather than surfacing in a host. - -## Versioning - -`CONTRACT_VERSION` describes *this vocabulary*, not the package. Bump its major -component when a payload's wire form changes incompatibly or a member is removed -or renamed, and its minor component when a member or an optional field is added. -It is deliberately independent of the package version the release workflow owns, -which tracks the shipped artifact. - -The payload tests pin the serde representation, because that representation is -the wire form: a host and a module that disagree about a field name fail at -runtime with a decode error, so the shape is asserted rather than assumed. - -## Generating a project from the template - -Rename the interface, the object path, and the member constants in `names` -together, replace `greeting` with the first real payload family, and reset -`CONTRACT_VERSION` to `(1, 0)` for the new contract. Keep the crate -dependency-light: the moment it links a transport or a runtime, the reason it -exists is gone. diff --git a/crates/template-bus/src/greeting/mod.rs b/crates/template-bus/src/greeting/mod.rs deleted file mode 100644 index f810aab..0000000 --- a/crates/template-bus/src/greeting/mod.rs +++ /dev/null @@ -1,17 +0,0 @@ -//! The payloads the `Greet` member exchanges. -//! -//! A module root like this one documents the module, wires its pieces together, -//! and exposes the smallest useful API. The type definitions live in the -//! sibling `types.rs`, and the unit tests in `test.rs`, wired in at the bottom -//! of this file. -//! -//! Replace this module with the first real payload family the module carries. -//! Payload types are `serde`-derived, `#[non_exhaustive]`, and hold owned data: -//! they are decoded from a frame, so they can borrow nothing from the caller. - -mod types; - -pub use types::{GreetRequest, GreetResponse}; - -#[cfg(test)] -mod test; diff --git a/crates/template-bus/src/greeting/test.rs b/crates/template-bus/src/greeting/test.rs deleted file mode 100644 index 1a30000..0000000 --- a/crates/template-bus/src/greeting/test.rs +++ /dev/null @@ -1,65 +0,0 @@ -//! Unit tests for the `Greet` payloads. -//! -//! These pin the serde representation. It is the wire form: a host and a module -//! that disagree about a field name fail at runtime with a decode error, so the -//! shape is asserted here rather than assumed. - -#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] - -use super::{GreetRequest, GreetResponse}; - -#[test] -fn a_request_serializes_to_its_wire_form() { - let encoded = serde_json::to_value(GreetRequest::new("Ferris")).unwrap(); - assert_eq!(encoded, serde_json::json!({ "name": "Ferris" })); -} - -#[test] -fn a_response_serializes_to_its_wire_form() { - let encoded = serde_json::to_value(GreetResponse::new("Hello, Ferris!")).unwrap(); - assert_eq!(encoded, serde_json::json!({ "greeting": "Hello, Ferris!" })); -} - -#[test] -fn a_request_round_trips_through_json() { - let request = GreetRequest::new(" Ferris "); - let encoded = serde_json::to_string(&request).unwrap(); - assert_eq!( - serde_json::from_str::(&encoded).unwrap(), - request - ); -} - -#[test] -fn a_response_round_trips_through_json() { - let response = GreetResponse::new("Hello, Ferris!"); - let encoded = serde_json::to_string(&response).unwrap(); - assert_eq!( - serde_json::from_str::(&encoded).unwrap(), - response - ); -} - -#[test] -fn a_request_missing_its_name_is_rejected() { - let decoded = serde_json::from_value::(serde_json::json!({})); - assert!(decoded.is_err()); -} - -#[test] -fn a_response_missing_its_greeting_is_rejected() { - let decoded = serde_json::from_value::(serde_json::json!({})); - assert!(decoded.is_err()); -} - -#[test] -fn constructors_accept_both_borrowed_and_owned_names() { - assert_eq!( - GreetRequest::new(String::from("Ferris")), - GreetRequest::new("Ferris") - ); - assert_eq!( - GreetResponse::new(String::from("Hi")), - GreetResponse::new("Hi") - ); -} diff --git a/crates/template-bus/src/greeting/types.rs b/crates/template-bus/src/greeting/types.rs deleted file mode 100644 index d70b376..0000000 --- a/crates/template-bus/src/greeting/types.rs +++ /dev/null @@ -1,54 +0,0 @@ -//! Request and response types for the `Greet` member. - -use serde::{Deserialize, Serialize}; - -/// The argument to [`crate::names::methods::GREET`]. -/// -/// The module trims surrounding whitespace from [`GreetRequest::name`] and -/// rejects a name that is empty once trimmed. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[non_exhaustive] -pub struct GreetRequest { - /// The name to greet. - pub name: String, -} - -impl GreetRequest { - /// Builds a request greeting `name`. - /// - /// # Examples - /// - /// ``` - /// # use template_bus::GreetRequest; - /// assert_eq!(GreetRequest::new("Ferris").name, "Ferris"); - /// ``` - #[must_use] - pub fn new(name: impl Into) -> Self { - Self { name: name.into() } - } -} - -/// The reply from [`crate::names::methods::GREET`]. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[non_exhaustive] -pub struct GreetResponse { - /// The rendered greeting. - pub greeting: String, -} - -impl GreetResponse { - /// Builds a reply carrying `greeting`. - /// - /// # Examples - /// - /// ``` - /// # use template_bus::GreetResponse; - /// assert_eq!(GreetResponse::new("Hello, Ferris!").greeting, "Hello, Ferris!"); - /// ``` - #[must_use] - pub fn new(greeting: impl Into) -> Self { - Self { - greeting: greeting.into(), - } - } -} diff --git a/crates/template-bus/src/lib.rs b/crates/template-bus/src/lib.rs deleted file mode 100644 index a1857d1..0000000 --- a/crates/template-bus/src/lib.rs +++ /dev/null @@ -1,74 +0,0 @@ -//! Every type that crosses the template module's `TinyBus` boundary, and the -//! names of the members that carry them. -//! -//! This crate ships as a loadable `TinyBus` module: `crates/template` is built -//! as a `cdylib` and exports one object. A host that loads that binary can call -//! into it but cannot `use` anything out of it, so the payload vocabulary has -//! to be published as an ordinary library. This is that library. -//! -//! # What is here -//! -//! - [`names`] — the interface name, the object path, and one constant per -//! member, plus [`names::METHODS`] listing them in dispatch order. -//! - [`greeting`] — the value vocabulary: the request and response payloads the -//! `Greet` member exchanges. -//! - [`version`] — [`CONTRACT_VERSION`] and the [`is_compatible`] bind rule. -//! -//! # What is deliberately not here -//! -//! **No behavior.** The `greet` implementation lives in `crates/template`, -//! which depends on this crate and re-exports it. A payload type describes what -//! a frame carries, not what the module does with it. -//! -//! **No transport.** This crate does not depend on `tinybus` and holds no -//! connection, client, or codec. A host already owns its connection — its -//! reconnect policy, its timeouts, its tracing — and the useful part is the -//! vocabulary, not another wrapper around it. -//! -//! That is also a structural necessity, not only a preference: `tinybus` is -//! vendored as a submodule whose manifest inherits fields from its own nested -//! `[workspace.package]`. A crate that every workspace member can depend on has -//! to stay transport-free, and staying transport-free is what keeps this crate -//! down to two pure-Rust dependencies. -//! -//! # This crate sits underneath the implementation, not beside it -//! -//! `template` **depends on this crate and re-exports all of it**, so -//! `template::GreetRequest` and `template_bus::greeting::GreetRequest` are the -//! *same type*, not structural twins. Defining a parallel set of payload types -//! for hosts would mean a conversion at every call site that nothing checks. -//! One definition, here, at the bottom. -//! -//! So: a module author depends on `template` and gets behavior and vocabulary. -//! A host depends on `template-bus` and gets vocabulary alone. -//! -//! # Staying in step with the module -//! -//! [`names::METHODS`] lists every member. `crates/template` asserts its served -//! members against that list, in order, so a method added to the interface -//! without an entry here fails that crate's tests rather than surfacing as an -//! unknown method in a host at runtime. -//! -//! # Example -//! -//! ``` -//! use template_bus::{names, GreetRequest, GreetResponse}; -//! -//! let body = serde_json::to_value([GreetRequest::new("Ferris")])?; -//! assert_eq!(names::methods::GREET, "Greet"); -//! assert_eq!(names::OBJECT_PATH, "/ai/tinyhumans/template/Greeting"); -//! -//! let reply: GreetResponse = serde_json::from_value( -//! serde_json::json!({ "greeting": "Hello, Ferris!" }), -//! )?; -//! assert_eq!(reply.greeting, "Hello, Ferris!"); -//! # Ok::<(), serde_json::Error>(()) -//! ``` - -pub mod greeting; -pub mod names; -pub mod version; - -pub use greeting::{GreetRequest, GreetResponse}; -pub use names::{INTERFACE, METHODS, OBJECT_PATH}; -pub use version::{CONTRACT_VERSION, is_compatible}; diff --git a/crates/template-bus/src/names/mod.rs b/crates/template-bus/src/names/mod.rs deleted file mode 100644 index 4da1547..0000000 --- a/crates/template-bus/src/names/mod.rs +++ /dev/null @@ -1,33 +0,0 @@ -//! The bus identity of the template module: interface name, object path, and -//! one constant per member. -//! -//! Nothing here is a string literal at a call site. A host names a member -//! through [`methods`] and the object through [`OBJECT_PATH`], so a rename is a -//! compile error in every consumer rather than a runtime "unknown method". -//! -//! When generating a project from this template, rename all three together — -//! the interface, the path, and the member constants — and keep -//! [`METHODS`] in the same order as the interface's dispatch table. - -/// The well-known interface name the module claims on the bus. -pub const INTERFACE: &str = "ai.tinyhumans.template.Greeting"; - -/// The object path the module serves its interface at. -pub const OBJECT_PATH: &str = "/ai/tinyhumans/template/Greeting"; - -/// One constant per member of [`INTERFACE`]. -pub mod methods { - /// Builds a greeting for a name. - /// - /// Takes a [`crate::GreetRequest`] and returns a [`crate::GreetResponse`]. - pub const GREET: &str = "Greet"; -} - -/// Every member of [`INTERFACE`], in the order the interface dispatches them. -/// -/// `crates/template` asserts its declared manifest methods against this list, -/// so the two cannot drift. -pub const METHODS: &[&str] = &[methods::GREET]; - -#[cfg(test)] -mod test; diff --git a/crates/template-bus/src/names/test.rs b/crates/template-bus/src/names/test.rs deleted file mode 100644 index bf7bea2..0000000 --- a/crates/template-bus/src/names/test.rs +++ /dev/null @@ -1,28 +0,0 @@ -//! Unit tests for the bus name table. - -use super::{INTERFACE, METHODS, OBJECT_PATH, methods}; - -#[test] -fn the_object_path_is_the_interface_in_path_form() { - let expected = format!("/{}", INTERFACE.replace('.', "/")); - assert_eq!(OBJECT_PATH, expected); -} - -#[test] -fn every_member_is_listed_exactly_once() { - let mut sorted = METHODS.to_vec(); - sorted.sort_unstable(); - let mut deduplicated = sorted.clone(); - deduplicated.dedup(); - assert_eq!(sorted, deduplicated); -} - -#[test] -fn the_method_table_holds_the_declared_members() { - assert_eq!(METHODS, [methods::GREET]); -} - -#[test] -fn no_member_name_is_empty() { - assert!(METHODS.iter().all(|method| !method.is_empty())); -} diff --git a/crates/template-bus/src/version/mod.rs b/crates/template-bus/src/version/mod.rs deleted file mode 100644 index ada372d..0000000 --- a/crates/template-bus/src/version/mod.rs +++ /dev/null @@ -1,46 +0,0 @@ -//! The contract version, and the rule a host uses to decide whether it can bind -//! to a module that reports one. -//! -//! The version describes *this vocabulary*, not the crate: bump the major -//! component when a payload's wire form changes incompatibly or a member is -//! removed or renamed, and the minor component when a member or an optional -//! field is added. It is deliberately independent of the package version the -//! release workflow bumps, which tracks the shipped artifact. - -/// The wire contract version this crate defines. -pub const CONTRACT_VERSION: (u32, u32) = (1, 0); - -/// Returns whether a host holding [`CONTRACT_VERSION`] can bind to a module -/// reporting `module`. -/// -/// Compatibility is the ordinary semantic-version rule for a pre-release-free -/// contract: the majors must match, and the module must be at least as new as -/// the host, because a host cannot call a member a module does not serve. -/// -/// # Examples -/// -/// ``` -/// # use template_bus::{is_compatible, CONTRACT_VERSION}; -/// assert!(is_compatible(CONTRACT_VERSION)); -/// assert!(is_compatible((1, 4))); -/// assert!(!is_compatible((2, 0))); -/// ``` -#[must_use] -pub fn is_compatible(module: (u32, u32)) -> bool { - binds(CONTRACT_VERSION, module) -} - -/// The bind rule with the host version supplied explicitly. -/// -/// [`is_compatible`] is this function applied to [`CONTRACT_VERSION`]. It is -/// split out so the unit tests can exercise both directions of the comparison -/// without pinning them to whatever the shipped version happens to be. -fn binds(host: (u32, u32), module: (u32, u32)) -> bool { - let (host_major, host_minor) = host; - let (module_major, module_minor) = module; - - module_major == host_major && module_minor >= host_minor -} - -#[cfg(test)] -mod test; diff --git a/crates/template-bus/src/version/test.rs b/crates/template-bus/src/version/test.rs deleted file mode 100644 index 3fd3edf..0000000 --- a/crates/template-bus/src/version/test.rs +++ /dev/null @@ -1,34 +0,0 @@ -//! Unit tests for the contract version and its bind rule. - -use super::{CONTRACT_VERSION, binds, is_compatible}; - -#[test] -fn the_shipped_contract_version_is_pinned() { - assert_eq!(CONTRACT_VERSION, (1, 0)); -} - -#[test] -fn the_contract_binds_to_itself() { - assert!(is_compatible(CONTRACT_VERSION)); -} - -#[test] -fn a_newer_minor_on_the_module_side_binds() { - assert!(is_compatible((1, 1))); - assert!(is_compatible((1, 97))); -} - -#[test] -fn an_older_minor_on_the_module_side_is_rejected() { - // A host built against 1.4 cannot call a 1.2 module: the members it names - // may not be served. - assert!(!binds((1, 4), (1, 2))); - assert!(binds((1, 4), (1, 4))); -} - -#[test] -fn a_different_major_is_rejected() { - assert!(!is_compatible((0, 0))); - assert!(!is_compatible((2, 0))); - assert!(!is_compatible((2, 97))); -} diff --git a/crates/template/Cargo.toml b/crates/template/Cargo.toml deleted file mode 100644 index e1bcdf4..0000000 --- a/crates/template/Cargo.toml +++ /dev/null @@ -1,39 +0,0 @@ -[package] -name = "template" -version.workspace = true -edition.workspace = true -rust-version.workspace = true -license.workspace = true -repository.workspace = true -description = "A production-ready template for installable TinyBus modules." -documentation = "https://docs.rs/template" -readme = "../../README.md" -keywords = ["tinybus", "module", "plugin", "template"] -categories = ["development-tools"] -publish = false - -[lib] -# Keep the ordinary Rust library for tests and downstream reuse while also -# producing the native module artifact that TinyBus loads at runtime. -crate-type = ["rlib", "cdylib"] - -[dependencies] -# The wire contract: member names, payload types, and the contract version. -# Re-exported wholesale from `src/lib.rs` so a consumer takes one dependency -# rather than two, and so `template::GreetRequest` and -# `template_bus::GreetRequest` are the same type. -template-bus = { workspace = true } -tinybus = { workspace = true } -tinybus-module = { workspace = true } -thiserror = { workspace = true } - -[dev-dependencies] -tokio = { workspace = true } -# The GitHub release verifier passes an explicit empty module configuration. -serde_json = { workspace = true } - -[features] -default = [] - -[lints] -workspace = true diff --git a/crates/template/examples/basic.rs b/crates/template/examples/basic.rs deleted file mode 100644 index 99233ec..0000000 --- a/crates/template/examples/basic.rs +++ /dev/null @@ -1,22 +0,0 @@ -//! Minimal end-to-end usage of the crate. -//! -//! Examples are compiled and linted in CI, so they cannot drift from the API. -//! Run it with: -//! -//! ```sh -//! cargo run --example basic -//! ``` - -use template::{Result, greet}; - -fn main() -> Result<()> { - println!("{}", greet("Rust")?); - - // Failure modes are part of the public contract; show them too. - match greet(" ") { - Ok(greeting) => println!("{greeting}"), - Err(error) => println!("expected failure: {error}"), - } - - Ok(()) -} diff --git a/crates/template/examples/verify_github_release.rs b/crates/template/examples/verify_github_release.rs deleted file mode 100644 index 9b173fe..0000000 --- a/crates/template/examples/verify_github_release.rs +++ /dev/null @@ -1,93 +0,0 @@ -//! Downloads a tagged release asset and calls the loaded `TinyBus` module. -//! -//! Run it with the release tag URL, platform archive, and archive SHA-256: -//! -//! ```text -//! cargo run --example verify_github_release -- \ -//! https://github.com/tinyhumansai/template/releases/tag/v0.1.4 \ -//! template-0.1.4-ubuntu-24.04-x86_64.tar.gz \ -//! -//! ``` - -use std::io; -use std::time::Duration; - -use template::{GreetRequest, GreetResponse, names}; -use tinybus::Connection; -use tinybus::broker::Broker; -use tinybus::module::ModuleHost; -use tinybus::transport::memory::MemoryBus; - -#[tokio::main] -async fn main() -> Result<(), Box> { - let (release_url, archive, sha256) = arguments()?; - let bus = MemoryBus::new(); - let broker = Broker::new(); - let broker_task = broker.spawn(bus.clone()); - let module_host = ModuleHost::new(broker); - let info = module_host.load_github_release( - &release_url, - &archive, - Some(&sha256), - serde_json::Value::default(), - )?; - - if info.name != env!("CARGO_PKG_NAME") { - return Err(io::Error::other(format!( - "loaded module `{}` instead of `{}`", - info.name, - env!("CARGO_PKG_NAME") - )) - .into()); - } - - let client = Connection::connect(bus.connect().await?).await?; - tokio::time::timeout(Duration::from_secs(5), async { - loop { - let claimed = client.list_names().await?; - if claimed.iter().any(|name| name.as_str() == names::INTERFACE) { - return tinybus::Result::Ok(()); - } - tokio::task::yield_now().await; - } - }) - .await??; - - let proxy = client.proxy(names::INTERFACE, names::OBJECT_PATH, names::INTERFACE)?; - let reply: GreetResponse = proxy - .call(names::methods::GREET, (GreetRequest::new("TinyBus"),)) - .await?; - if reply.greeting != "Hello, TinyBus!" { - return Err(io::Error::other(format!( - "module returned an unexpected greeting: {}", - reply.greeting - )) - .into()); - } - - println!( - "verified {archive} from {release_url} as TinyBus module `{}`", - info.name - ); - broker_task.abort(); - Ok(()) -} - -fn arguments() -> Result<(String, String, String), io::Error> { - let mut args = std::env::args().skip(1); - let usage = "usage: cargo run --example verify_github_release -- \ - "; - let release_url = args - .next() - .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, usage))?; - let archive = args - .next() - .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, usage))?; - let sha256 = args - .next() - .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, usage))?; - if args.next().is_some() { - return Err(io::Error::new(io::ErrorKind::InvalidInput, usage)); - } - Ok((release_url, archive, sha256)) -} diff --git a/crates/template/examples/verify_module.rs b/crates/template/examples/verify_module.rs deleted file mode 100644 index 6e3856e..0000000 --- a/crates/template/examples/verify_module.rs +++ /dev/null @@ -1,74 +0,0 @@ -//! Loads a built module through the real `TinyBus` dynamic loader. - -use std::io; -use std::path::PathBuf; -use std::time::Duration; - -use template::{GreetRequest, GreetResponse, names}; -use tinybus::Connection; -use tinybus::broker::Broker; -use tinybus::module::ModuleHost; -use tinybus::transport::memory::MemoryBus; - -#[tokio::main] -async fn main() -> Result<(), Box> { - let module = module_argument()?; - let bus = MemoryBus::new(); - let broker = Broker::new(); - let broker_task = broker.spawn(bus.clone()); - let module_host = ModuleHost::new(broker); - let info = module_host.load_file(&module)?; - - if info.name != env!("CARGO_PKG_NAME") { - return Err(io::Error::other(format!( - "loaded module `{}` instead of `{}`", - info.name, - env!("CARGO_PKG_NAME") - )) - .into()); - } - - let client = Connection::connect(bus.connect().await?).await?; - tokio::time::timeout(Duration::from_secs(5), async { - loop { - let claimed = client.list_names().await?; - if claimed.iter().any(|name| name.as_str() == names::INTERFACE) { - return tinybus::Result::Ok(()); - } - tokio::task::yield_now().await; - } - }) - .await??; - - let proxy = client.proxy(names::INTERFACE, names::OBJECT_PATH, names::INTERFACE)?; - let reply: GreetResponse = proxy - .call(names::methods::GREET, (GreetRequest::new("TinyBus"),)) - .await?; - if reply.greeting != "Hello, TinyBus!" { - return Err(io::Error::other(format!( - "module returned an unexpected greeting: {}", - reply.greeting - )) - .into()); - } - - println!( - "verified {} as TinyBus module `{}`", - module.display(), - info.name - ); - broker_task.abort(); - Ok(()) -} - -fn module_argument() -> Result { - std::env::args_os() - .nth(1) - .map(PathBuf::from) - .ok_or_else(|| { - io::Error::new( - io::ErrorKind::InvalidInput, - "usage: cargo run --example verify_module -- ", - ) - }) -} diff --git a/crates/template/src/error/mod.rs b/crates/template/src/error/mod.rs deleted file mode 100644 index b8ddbe0..0000000 --- a/crates/template/src/error/mod.rs +++ /dev/null @@ -1,28 +0,0 @@ -//! Crate-wide error and result types. -//! -//! Every fallible public function in this crate returns [`Result`], and every -//! failure mode is a distinct [`Error`] variant. Add a variant rather than -//! encoding new context into an existing message: callers match on variants, -//! and message text is not a stable API. -//! -//! Variants carry the data a caller needs to react, keep their `#[error]` -//! message lowercase and free of trailing punctuation, and are documented so -//! the rendered rustdoc explains when each one occurs. - -/// Errors returned by this crate. -#[derive(Debug, thiserror::Error, PartialEq, Eq)] -#[non_exhaustive] -pub enum Error { - /// A required name was empty or contained only whitespace. - #[error("name must not be empty")] - EmptyName, -} - -/// The crate's standard result type. -/// -/// Use this alias in public signatures instead of spelling out -/// `std::result::Result`. -pub type Result = std::result::Result; - -#[cfg(test)] -mod test; diff --git a/crates/template/src/error/test.rs b/crates/template/src/error/test.rs deleted file mode 100644 index 4c5d609..0000000 --- a/crates/template/src/error/test.rs +++ /dev/null @@ -1,17 +0,0 @@ -//! Unit tests for the crate-wide error type. - -#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] - -use super::*; - -#[test] -fn renders_a_human_readable_message() { - assert_eq!(Error::EmptyName.to_string(), "name must not be empty"); -} - -#[test] -fn is_a_standard_error() { - fn assert_error(_: &E) {} - - assert_error(&Error::EmptyName); -} diff --git a/crates/template/src/greeting/mod.rs b/crates/template/src/greeting/mod.rs deleted file mode 100644 index 862fa21..0000000 --- a/crates/template/src/greeting/mod.rs +++ /dev/null @@ -1,38 +0,0 @@ -//! Greeting behavior used to demonstrate the template's module layout. -//! -//! A module root like this one documents the module, wires its pieces -//! together, and exposes the smallest useful API. Substantial type definitions -//! belong in a sibling `types.rs`, and unit tests belong in `test.rs`, wired in -//! at the bottom of this file. -//! -//! Replace this module with the crate's first real feature area. - -use crate::{Error, Result}; - -/// Returns a friendly greeting for `name`. -/// -/// Surrounding whitespace is trimmed before the greeting is built. -/// -/// # Examples -/// -/// ``` -/// # use template::greet; -/// assert_eq!(greet(" Ferris ")?, "Hello, Ferris!"); -/// # Ok::<(), template::Error>(()) -/// ``` -/// -/// # Errors -/// -/// Returns [`Error::EmptyName`] when `name` is empty or contains only -/// whitespace. -pub fn greet(name: &str) -> Result { - let name = name.trim(); - if name.is_empty() { - return Err(Error::EmptyName); - } - - Ok(format!("Hello, {name}!")) -} - -#[cfg(test)] -mod test; diff --git a/crates/template/src/greeting/test.rs b/crates/template/src/greeting/test.rs deleted file mode 100644 index de04ef4..0000000 --- a/crates/template/src/greeting/test.rs +++ /dev/null @@ -1,28 +0,0 @@ -//! Unit tests for the greeting module. -//! -//! Unit tests live next to the code they cover and may reach into private -//! items. Tests of the public contract belong in `tests/` instead. - -#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] - -use super::*; - -#[test] -fn greets_a_named_person() { - assert_eq!(greet("Ferris").unwrap(), "Hello, Ferris!"); -} - -#[test] -fn trims_the_name() { - assert_eq!(greet(" Ferris ").unwrap(), "Hello, Ferris!"); -} - -#[test] -fn rejects_an_empty_name() { - assert_eq!(greet("").unwrap_err(), Error::EmptyName); -} - -#[test] -fn rejects_a_whitespace_only_name() { - assert_eq!(greet(" \t\n ").unwrap_err(), Error::EmptyName); -} diff --git a/crates/template/src/lib.rs b/crates/template/src/lib.rs deleted file mode 100644 index 566fa7e..0000000 --- a/crates/template/src/lib.rs +++ /dev/null @@ -1,62 +0,0 @@ -//! A production-ready starting point for an installable `TinyBus` module. -//! -//! This crate is a template. It ships the layout, lint configuration, error -//! handling, testing, and documentation conventions described in `AGENTS.md`. -//! The compiled `cdylib` exports `TinyBus` module ABI v1 and serves the example -//! [`greet`] behavior over the bus. -//! -//! # Layout -//! -//! This is the implementation half of a two-crate workspace: -//! -//! - [`template_bus`] — the wire contract. Member names, payload types, and the -//! contract version, with no transport and no behavior. A host that only -//! makes calls depends on that crate alone. -//! - `template` — this crate. The behavior, the crate-wide error type, and the -//! `TinyBus` adapter that serves them, built as both an `rlib` and the -//! `cdylib` the loader consumes. -//! -//! Within this crate: -//! -//! - `src/error/` holds the crate-wide [`Error`] enum and the [`Result`] alias -//! returned by every fallible public function. -//! - Each feature area lives in its own module directory with a `mod.rs` -//! module root, an optional `types.rs`, and a `test.rs` holding its unit -//! tests. -//! - Every public item is re-exported from here — including all of -//! [`template_bus`] — so downstream users have a single predictable surface -//! and `template::GreetRequest` is the *same type* as -//! `template_bus::GreetRequest`, not a structural twin. -//! - `tinybus_module` adapts the public behavior to `TinyBus` and exports the -//! module descriptor, embedded manifest, and initialization entrypoint. -//! -//! # Example -//! -//! ``` -//! use template::{greet, Error, GreetRequest}; -//! -//! assert_eq!(greet("Ferris")?, "Hello, Ferris!"); -//! assert_eq!(greet(" ").unwrap_err(), Error::EmptyName); -//! assert_eq!(GreetRequest::new("Ferris").name, "Ferris"); -//! # Ok::<(), template::Error>(()) -//! ``` -//! -//! Replace the `greeting` module with the first real feature area, keep the -//! conventions, and update this documentation to describe the new crate. - -mod error; -mod greeting; -mod tinybus_module; - -pub use error::{Error, Result}; -pub use greeting::greet; - -// The wire contract, re-exported by module rather than by item so every path -// through this crate resolves to the same definitions the contract crate -// publishes. A host may depend on `template-bus` directly and get exactly these -// types; nothing here redefines them. -pub use template_bus; -pub use template_bus::{ - CONTRACT_VERSION, GreetRequest, GreetResponse, INTERFACE, METHODS, OBJECT_PATH, is_compatible, - names, version, -}; diff --git a/crates/template/src/tinybus_module/README.md b/crates/template/src/tinybus_module/README.md deleted file mode 100644 index 2c05772..0000000 --- a/crates/template/src/tinybus_module/README.md +++ /dev/null @@ -1,20 +0,0 @@ -# TinyBus Adapter - -This module is the boundary between ordinary feature code and TinyBus module -ABI v1. `GreetingService` converts the crate's public `greet` function into the typed -`Greet` bus method, while `setup` registers its object and claims the well-known -interface name. Neither the name, the object path, nor the payload types are -spelled here: they come from `template-bus`, so a rename is a compile error in -every consumer instead of an `UnknownMethod` at runtime. - -`tinybus_module::module_export!` emits the descriptor, embedded manifest, and -initialization symbols consumed by the dynamic loader. The manifest method list -must stay aligned with the interface macro's dispatch table and with -`template_bus::names::METHODS`; the unit tests check both relationships. -Integration tests use TinyBus's in-memory transport, and -`crates/template/examples/verify_module.rs` loads a compiled `cdylib` through -the real dynamic loader before a release archive is accepted. - -Generated projects should replace the example interface, object path, and method -declarations together — here and in `crates/template-bus/src/names/`. They must not retain Rust-owned data across the -ABI boundary or bypass the SDK exports with an ad hoc FFI surface. diff --git a/crates/template/src/tinybus_module/mod.rs b/crates/template/src/tinybus_module/mod.rs deleted file mode 100644 index 1c9c2f0..0000000 --- a/crates/template/src/tinybus_module/mod.rs +++ /dev/null @@ -1,43 +0,0 @@ -//! `TinyBus` module entrypoint and bus-facing interface. -//! -//! This adapter keeps the feature implementation independent from `TinyBus` -//! while exposing it as an installable, dynamically loaded integration. The -//! names and payload types it serves come from [`template_bus`], so a host -//! spells them from the contract crate instead of repeating string literals. - -use template_bus::{GreetRequest, GreetResponse, names}; -use tinybus::{Connection, Result as TinyBusResult}; - -struct GreetingService; - -#[tinybus::interface(name = "ai.tinyhumans.template.Greeting")] -impl GreetingService { - async fn greet(&self, request: GreetRequest) -> TinyBusResult { - std::future::ready(crate::greet(&request.name)) - .await - .map(GreetResponse::new) - .map_err(|error| tinybus::Error::failed(error.to_string())) - } -} - -async fn setup(connection: Connection) -> TinyBusResult<()> { - connection - .serve_at(names::OBJECT_PATH.try_into()?, GreetingService) - .await?; - connection.request_name(names::INTERFACE).await?; - Ok(()) -} - -tinybus_module::module_export! { - setup = setup, - worker_threads = 1, - provides = ["ai.tinyhumans.template.Greeting"], - methods = ["Greet"], - signals = [], - requires = [], - optional = [], - lazy = false, -} - -#[cfg(test)] -mod test; diff --git a/crates/template/src/tinybus_module/test.rs b/crates/template/src/tinybus_module/test.rs deleted file mode 100644 index d5fe71a..0000000 --- a/crates/template/src/tinybus_module/test.rs +++ /dev/null @@ -1,64 +0,0 @@ -//! Tests for the `TinyBus` module adapter and its declared surface. - -use super::{GreetingService, setup}; -use template_bus::{GreetRequest, GreetResponse, names}; -use tinybus::broker::Broker; -use tinybus::transport::memory::MemoryBus; -use tinybus::{Connection, Interface}; - -#[test] -fn declared_methods_match_the_dispatch_table() { - let methods = GreetingService - .members() - .into_iter() - .map(|member| member.to_string()) - .collect::>(); - - assert_eq!(methods, names::METHODS.to_vec()); -} - -#[test] -fn the_served_interface_name_matches_the_contract() { - assert_eq!(GreetingService.name().to_string(), names::INTERFACE); -} - -#[tokio::test] -async fn module_serves_greetings_over_a_real_bus() -> tinybus::Result<()> { - let bus = MemoryBus::new(); - Broker::new().spawn(bus.clone()); - - let service = Connection::connect(bus.connect().await?).await?; - setup(service.clone()).await?; - - let client = Connection::connect(bus.connect().await?).await?; - let proxy = client.proxy(names::INTERFACE, names::OBJECT_PATH, names::INTERFACE)?; - let reply: GreetResponse = proxy - .call(names::methods::GREET, (GreetRequest::new("Ferris"),)) - .await?; - - assert_eq!(reply, GreetResponse::new("Hello, Ferris!")); - Ok(()) -} - -#[tokio::test] -async fn module_rejects_an_empty_name_over_the_bus() -> tinybus::Result<()> { - let bus = MemoryBus::new(); - Broker::new().spawn(bus.clone()); - - let service = Connection::connect(bus.connect().await?).await?; - setup(service.clone()).await?; - - let client = Connection::connect(bus.connect().await?).await?; - let proxy = client.proxy(names::INTERFACE, names::OBJECT_PATH, names::INTERFACE)?; - let result = proxy - .call::(names::methods::GREET, (GreetRequest::new(" "),)) - .await; - - let Err(error) = result else { - return Err(tinybus::Error::failed( - "whitespace-only names unexpectedly succeeded", - )); - }; - assert!(error.to_string().contains("name must not be empty")); - Ok(()) -} diff --git a/crates/template/tests/public_api.rs b/crates/template/tests/public_api.rs deleted file mode 100644 index 256b71c..0000000 --- a/crates/template/tests/public_api.rs +++ /dev/null @@ -1,20 +0,0 @@ -//! Integration tests for the public crate surface. -//! -//! These tests link against the crate as a downstream consumer would: they can -//! only use what `src/lib.rs` re-exports. Treat them as the regression suite -//! for the crate's public contract — if a change breaks a test here, it is a -//! breaking change for users. - -#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] - -use template::{Error, greet}; - -#[test] -fn greeting_is_available_to_consumers() { - assert_eq!(greet("Rust").unwrap(), "Hello, Rust!"); -} - -#[test] -fn errors_are_available_to_consumers() { - assert_eq!(greet("").unwrap_err(), Error::EmptyName); -} diff --git a/crates/tinyjevclient/Cargo.toml b/crates/tinyjevclient/Cargo.toml new file mode 100644 index 0000000..960054a --- /dev/null +++ b/crates/tinyjevclient/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "tinyjevclient" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +description = "A typed Rust client for TypeSafe AI's System One API and Jev model." +documentation = "https://docs.rs/tinyjevclient" +readme = "../../README.md" +keywords = ["ai", "classification", "jev", "typesafe"] +categories = ["api-bindings", "web-programming::http-client"] +publish = false + +[dependencies] +reqwest = { workspace = true } +httpdate = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } +tokio = { workspace = true } + +[dev-dependencies] +tokio = { workspace = true, features = ["io-util", "macros", "net", "rt-multi-thread", "sync"] } + +[features] +default = [] + +[lints] +workspace = true diff --git a/crates/tinyjevclient/examples/basic.rs b/crates/tinyjevclient/examples/basic.rs new file mode 100644 index 0000000..43364ff --- /dev/null +++ b/crates/tinyjevclient/examples/basic.rs @@ -0,0 +1,27 @@ +//! Evaluate one ticket when `TYPESAFE_API_KEY` is configured. + +use std::collections::BTreeMap; + +use serde_json::json; +use tinyjevclient::{Choice, Client, EvaluationRequest, Question}; + +#[tokio::main] +async fn main() -> Result<(), Box> { + let request = EvaluationRequest::jev( + json!({"ticket": "I was charged twice. Please fix this."}), + BTreeMap::from([( + "route".to_owned(), + Question::Choice(Choice { + instructions: json!("Which team should handle this ticket?"), + criteria: BTreeMap::from([ + ("billing".to_owned(), Some(json!("payments and refunds"))), + ("technical".to_owned(), Some(json!("bugs and outages"))), + ("other".to_owned(), None), + ]), + }), + )]), + ); + let result = Client::from_env()?.evaluate(&request).await?; + println!("{:?}", result.response.answers["route"]); + Ok(()) +} diff --git a/crates/tinyjevclient/src/README.md b/crates/tinyjevclient/src/README.md new file mode 100644 index 0000000..f314dda --- /dev/null +++ b/crates/tinyjevclient/src/README.md @@ -0,0 +1,8 @@ +# Source layout + +| directory | responsibility | +| --- | --- | +| `client/` | HTTP execution, bounded retry, secret handling, and measurements | +| `request/` | typed Choice, Score, and Noul request payloads and validation | +| `response/` | typed answers and request-relative response validation | +| `error/` | crate-wide classified failures | diff --git a/crates/tinyjevclient/src/client/README.md b/crates/tinyjevclient/src/client/README.md new file mode 100644 index 0000000..e146256 --- /dev/null +++ b/crates/tinyjevclient/src/client/README.md @@ -0,0 +1,13 @@ +# Client module + +The client validates a request, sends it to the System One endpoint, classifies +HTTP failures, retries only transient failures, validates the response against +the original questions, and returns attempts and end-to-end latency. API keys +remain private and render only as `[REDACTED]`. + +Production endpoints require HTTPS. Plain HTTP is accepted only for literal +loopback IP addresses used by local tests and development services. Both +successful and failed evaluations report attempts and end-to-end latency. All +transport failures use the same explicit bounded retry policy because the +transport error taxonomy cannot reliably distinguish transient DNS, TLS, and +connectivity failures from permanent ones. diff --git a/crates/tinyjevclient/src/client/mod.rs b/crates/tinyjevclient/src/client/mod.rs new file mode 100644 index 0000000..72dd951 --- /dev/null +++ b/crates/tinyjevclient/src/client/mod.rs @@ -0,0 +1,266 @@ +//! Async HTTP client, retry policy, and measured evaluation result. + +#[cfg(test)] +mod test; + +mod types; + +pub use types::{Client, ClientConfig, EvaluationFailure, EvaluationResult, RetryPolicy}; + +use std::time::{Duration, Instant}; + +use reqwest::{StatusCode, header::RETRY_AFTER}; + +use crate::{Error, EvaluationRequest, EvaluationResponse, Result}; + +const SYSTEM_ONE_PATH: &str = "v1/systemone"; +const MAX_RETRIES: u32 = 100; + +impl Client { + /// Construct a client from an explicit configuration. + /// + /// # Errors + /// + /// Returns [`Error::InvalidConfig`] for an empty key, invalid base URL, + /// zero timeout, or invalid retry policy. + pub fn new(config: ClientConfig) -> Result { + config.validate()?; + let http = reqwest::Client::builder() + .timeout(config.timeout) + .build() + .map_err(|source| Error::Transport { source })?; + Ok(Self { config, http }) + } + + /// Construct a client using `TYPESAFE_API_KEY` and production defaults. + /// + /// # Errors + /// + /// Returns [`Error::MissingApiKey`] when the variable is absent, or the + /// same configuration errors as [`Self::new`]. + pub fn from_env() -> Result { + let api_key = std::env::var("TYPESAFE_API_KEY").map_err(|_| Error::MissingApiKey)?; + Self::new(ClientConfig::new(api_key)) + } + + /// Evaluate typed questions against shared state. + /// + /// The returned latency includes retry delays and all attempts. Request and + /// response bodies are never logged by this crate. + /// + /// # Errors + /// + /// Returns request validation, transport, HTTP, decoding, or response + /// contract errors. All transport failures are retried within the explicit + /// attempt cap because the transport does not reliably distinguish a + /// transient DNS/TLS/connectivity fault from a permanent one. HTTP request + /// validation and authentication failures remain terminal. + pub async fn evaluate( + &self, + request: &EvaluationRequest, + ) -> std::result::Result { + let started = Instant::now(); + request.validate().map_err(|error| EvaluationFailure { + error, + attempts: 0, + latency: started.elapsed(), + })?; + let mut attempts = 0_u32; + loop { + attempts = attempts.saturating_add(1); + match self.send_once(request).await { + Ok((response, request_id)) => { + response + .validate_for(request) + .map_err(|error| EvaluationFailure { + error, + attempts, + latency: started.elapsed(), + })?; + return Ok(EvaluationResult { + response, + request_id, + attempts, + latency: started.elapsed(), + }); + } + Err(Failure::Terminal(error)) => { + return Err(EvaluationFailure { + error, + attempts, + latency: started.elapsed(), + }); + } + Err(Failure::Retryable { error, retry_after }) => { + if attempts > self.config.retry.max_retries { + return Err(EvaluationFailure { + error, + attempts, + latency: started.elapsed(), + }); + } + let delay = retry_after.unwrap_or_else(|| self.config.retry.delay(attempts)); + tokio::time::sleep(delay.min(self.config.retry.max_backoff)).await; + } + } + } + } + + async fn send_once( + &self, + request: &EvaluationRequest, + ) -> std::result::Result<(EvaluationResponse, Option), Failure> { + let url = format!( + "{}/{}", + self.config.base_url.trim_end_matches('/'), + SYSTEM_ONE_PATH + ); + let response = self + .http + .post(url) + .bearer_auth(self.config.api_key.expose()) + .json(request) + .send() + .await + .map_err(classify_transport)?; + let request_id = response + .headers() + .get("x-request-id") + .and_then(|value| value.to_str().ok()) + .map(str::to_owned); + let status = response.status(); + if !status.is_success() { + let retry_after = parse_retry_after(response.headers().get(RETRY_AFTER)); + return Err(classify_status(status, retry_after)); + } + let bytes = response.bytes().await.map_err(classify_transport)?; + let decoded = serde_json::from_slice(&bytes) + .map_err(|source| Failure::Terminal(Error::Decode { source }))?; + Ok((decoded, request_id)) + } +} + +impl ClientConfig { + fn validate(&self) -> Result<()> { + if self.api_key.expose().trim().is_empty() { + return Err(Error::InvalidConfig { + reason: "api key must not be empty".to_owned(), + }); + } + let url = reqwest::Url::parse(&self.base_url).map_err(|_| Error::InvalidConfig { + reason: "base URL must be an absolute HTTP URL".to_owned(), + })?; + if !matches!(url.scheme(), "http" | "https") { + return Err(Error::InvalidConfig { + reason: "base URL must use HTTP or HTTPS".to_owned(), + }); + } + if !url.username().is_empty() || url.password().is_some() { + return Err(Error::InvalidConfig { + reason: "base URL must not contain credentials".to_owned(), + }); + } + if url.scheme() == "http" + && !url + .host_str() + .and_then(|host| { + host.trim_matches(['[', ']']) + .parse::() + .ok() + }) + .is_some_and(|address| address.is_loopback()) + { + return Err(Error::InvalidConfig { + reason: "HTTP base URLs must use a literal loopback address".to_owned(), + }); + } + if self.timeout.is_zero() { + return Err(Error::InvalidConfig { + reason: "timeout must be greater than zero".to_owned(), + }); + } + if self.retry.initial_backoff.is_zero() || self.retry.max_backoff.is_zero() { + return Err(Error::InvalidConfig { + reason: "retry backoffs must be greater than zero".to_owned(), + }); + } + if self.retry.max_retries > MAX_RETRIES { + return Err(Error::InvalidConfig { + reason: format!("max retries must not exceed {MAX_RETRIES}"), + }); + } + Ok(()) + } +} + +enum Failure { + Terminal(Error), + Retryable { + error: Error, + retry_after: Option, + }, +} + +fn classify_transport(source: reqwest::Error) -> Failure { + if source.is_timeout() { + Failure::Retryable { + error: Error::Timeout, + retry_after: None, + } + } else { + Failure::Retryable { + error: Error::Transport { source }, + retry_after: None, + } + } +} + +fn classify_status(status: StatusCode, retry_after: Option) -> Failure { + match status { + StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => { + Failure::Terminal(Error::Authentication) + } + StatusCode::UNPROCESSABLE_ENTITY | StatusCode::BAD_REQUEST => { + Failure::Terminal(Error::Unprocessable) + } + StatusCode::REQUEST_TIMEOUT => Failure::Retryable { + error: Error::Timeout, + retry_after, + }, + StatusCode::TOO_MANY_REQUESTS => Failure::Retryable { + error: Error::RateLimited, + retry_after, + }, + status if status.as_u16() == 529 => Failure::Retryable { + error: Error::Overloaded, + retry_after, + }, + status if status.is_server_error() => Failure::Retryable { + error: Error::HttpStatus { + status: status.as_u16(), + }, + retry_after, + }, + status => Failure::Terminal(Error::HttpStatus { + status: status.as_u16(), + }), + } +} + +fn parse_retry_after(value: Option<&reqwest::header::HeaderValue>) -> Option { + parse_retry_after_at(value, std::time::SystemTime::now()) +} + +fn parse_retry_after_at( + value: Option<&reqwest::header::HeaderValue>, + now: std::time::SystemTime, +) -> Option { + let value = value?.to_str().ok()?; + if let Ok(seconds) = value.parse::() { + return Some(Duration::from_secs(seconds)); + } + httpdate::parse_http_date(value) + .ok()? + .duration_since(now) + .ok() +} diff --git a/crates/tinyjevclient/src/client/test.rs b/crates/tinyjevclient/src/client/test.rs new file mode 100644 index 0000000..21d54d4 --- /dev/null +++ b/crates/tinyjevclient/src/client/test.rs @@ -0,0 +1,367 @@ +//! Client transport, retry, measurement, and secret-handling tests. + +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use std::{ + collections::BTreeMap, + sync::Arc, + time::{Duration, SystemTime}, +}; + +use serde_json::json; +use tokio::{ + io::{AsyncReadExt, AsyncWriteExt}, + net::TcpListener, + sync::Mutex, +}; + +use super::*; +use crate::{Choice, Question}; + +fn request() -> EvaluationRequest { + EvaluationRequest::jev( + json!({"message": "review this"}), + BTreeMap::from([( + "route".to_owned(), + Question::Choice(Choice { + instructions: json!("Who should answer?"), + criteria: BTreeMap::from([("alice".to_owned(), None), ("bob".to_owned(), None)]), + }), + )]), + ) +} + +fn success() -> String { + json!({ + "model": "jev-latest", + "answers": { + "route": { + "type": "choice", + "choice": "bob", + "probabilities": {"alice": 0.2, "bob": 0.8}, + "confidence": 0.6 + } + }, + "usage": {"input_tokens": 12, "output_tokens": 2} + }) + .to_string() +} + +fn response(status: u16, body: &str, extra_headers: &str) -> String { + let reason = if status == 200 { "OK" } else { "Error" }; + format!( + "HTTP/1.1 {status} {reason}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n{extra_headers}\r\n{body}", + body.len() + ) +} + +async fn server(responses: Vec) -> (String, Arc>>) { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let requests = Arc::new(Mutex::new(Vec::new())); + let recorded = Arc::clone(&requests); + tokio::spawn(async move { + for reply in responses { + let (mut stream, _) = listener.accept().await.unwrap(); + let mut buffer = vec![0_u8; 16_384]; + let count = stream.read(&mut buffer).await.unwrap(); + recorded + .lock() + .await + .push(String::from_utf8_lossy(&buffer[..count]).into_owned()); + stream.write_all(reply.as_bytes()).await.unwrap(); + } + }); + (format!("http://{address}"), requests) +} + +async fn slow_server() -> String { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + tokio::spawn(async move { + let (_stream, _) = listener.accept().await.unwrap(); + tokio::time::sleep(Duration::from_millis(100)).await; + }); + format!("http://{address}") +} + +fn config(base_url: String) -> ClientConfig { + let mut config = ClientConfig::new("secret-test-key"); + config.base_url = base_url; + config.timeout = Duration::from_secs(1); + config.retry = RetryPolicy { + max_retries: 0, + initial_backoff: Duration::from_millis(1), + max_backoff: Duration::from_millis(5), + }; + config +} + +#[tokio::test] +async fn sends_the_documented_endpoint_and_bearer_header() { + let (base_url, requests) = server(vec![response( + 200, + &success(), + "x-request-id: request-7\r\n", + )]) + .await; + let result = Client::new(config(base_url)) + .unwrap() + .evaluate(&request()) + .await + .unwrap(); + assert_eq!(result.attempts, 1); + assert_eq!(result.request_id.as_deref(), Some("request-7")); + assert_eq!(result.response.usage.input_tokens, Some(12)); + let sent = requests.lock().await.join(""); + assert!(sent.starts_with("POST /v1/systemone HTTP/1.1")); + assert!( + sent.to_ascii_lowercase() + .contains("authorization: bearer secret-test-key") + ); + assert!(sent.contains("\"model\":\"jev-latest\"")); +} + +#[tokio::test] +async fn retries_rate_limits_and_reports_attempts() { + let (base_url, requests) = + server(vec![response(429, "{}", ""), response(200, &success(), "")]).await; + let mut config = config(base_url); + config.retry.max_retries = 1; + let result = Client::new(config) + .unwrap() + .evaluate(&request()) + .await + .unwrap(); + assert_eq!(result.attempts, 2); + assert_eq!(requests.lock().await.len(), 2); +} + +#[tokio::test] +async fn authentication_is_terminal() { + let (base_url, requests) = server(vec![response(401, "{}", "")]).await; + let error = Client::new(config(base_url)) + .unwrap() + .evaluate(&request()) + .await + .unwrap_err(); + assert!(matches!(error.error, Error::Authentication)); + assert_eq!(error.attempts, 1); + assert_eq!(requests.lock().await.len(), 1); +} + +#[tokio::test] +async fn malformed_success_body_is_a_decode_failure() { + let (base_url, _) = server(vec![response(200, "not-json", "")]).await; + let error = Client::new(config(base_url)) + .unwrap() + .evaluate(&request()) + .await + .unwrap_err(); + assert!(matches!(error.error, Error::Decode { .. })); +} + +#[tokio::test] +async fn debug_output_redacts_the_api_key() { + let config = config("http://127.0.0.1:1".to_owned()); + let rendered = format!("{config:?}"); + assert!(rendered.contains("[REDACTED]")); + assert!(!rendered.contains("secret-test-key")); + let client = Client::new(config).unwrap(); + let rendered = format!("{client:?}"); + assert!(rendered.contains("[REDACTED]")); + assert!(!rendered.contains("secret-test-key")); +} + +#[test] +fn rejects_invalid_configuration_before_transport() { + let mut empty = ClientConfig::new(""); + empty.base_url = "not a URL".to_owned(); + assert!(matches!( + Client::new(empty), + Err(Error::InvalidConfig { .. }) + )); + let mut invalid_url = ClientConfig::new("key"); + invalid_url.base_url = "not a URL".into(); + assert!(matches!( + Client::new(invalid_url), + Err(Error::InvalidConfig { .. }) + )); +} + +#[test] +fn validates_every_configuration_bound_and_redacted_key_replacement() { + let replaced = ClientConfig::new("old").with_api_key("new-secret"); + let rendered = format!("{replaced:?}"); + assert!(!rendered.contains("new-secret")); + + let mut scheme = ClientConfig::new("key"); + scheme.base_url = "file:///tmp/socket".into(); + assert!(matches!( + Client::new(scheme), + Err(Error::InvalidConfig { .. }) + )); + + for base_url in ["http://example.com", "http://localhost:8080"] { + let mut cleartext = ClientConfig::new("key"); + cleartext.base_url = base_url.into(); + assert!(matches!( + Client::new(cleartext), + Err(Error::InvalidConfig { .. }) + )); + } + let mut secure = ClientConfig::new("key"); + secure.base_url = "https://example.com".into(); + assert!(Client::new(secure).is_ok()); + let mut ipv6_loopback = ClientConfig::new("key"); + ipv6_loopback.base_url = "http://[::1]:8080".into(); + assert!(Client::new(ipv6_loopback).is_ok()); + let mut userinfo = ClientConfig::new("key"); + userinfo.base_url = "https://user:password@example.com".into(); + assert!(matches!( + Client::new(userinfo), + Err(Error::InvalidConfig { .. }) + )); + + let mut timeout = ClientConfig::new("key"); + timeout.timeout = Duration::ZERO; + assert!(matches!( + Client::new(timeout), + Err(Error::InvalidConfig { .. }) + )); + + let mut retry = ClientConfig::new("key"); + retry.retry.initial_backoff = Duration::ZERO; + assert!(matches!( + Client::new(retry), + Err(Error::InvalidConfig { .. }) + )); + let mut unbounded = ClientConfig::new("key"); + unbounded.retry.max_retries = u32::MAX; + assert!(matches!( + Client::new(unbounded), + Err(Error::InvalidConfig { .. }) + )); +} + +#[test] +fn retry_delay_is_exponential_and_bounded() { + let policy = RetryPolicy { + max_retries: 5, + initial_backoff: Duration::from_millis(10), + max_backoff: Duration::from_millis(25), + }; + assert_eq!(policy.delay(1), Duration::from_millis(10)); + assert_eq!(policy.delay(2), Duration::from_millis(20)); + assert_eq!(policy.delay(30), Duration::from_millis(25)); +} + +#[test] +fn status_classification_covers_terminal_and_retryable_classes() { + assert!(matches!( + classify_status(StatusCode::BAD_REQUEST, None), + Failure::Terminal(Error::Unprocessable) + )); + assert!(matches!( + classify_status(StatusCode::NOT_FOUND, None), + Failure::Terminal(Error::HttpStatus { status: 404 }) + )); + assert!(matches!( + classify_status(StatusCode::INTERNAL_SERVER_ERROR, None), + Failure::Retryable { + error: Error::HttpStatus { status: 500 }, + .. + } + )); + assert!(matches!( + classify_status(StatusCode::REQUEST_TIMEOUT, None), + Failure::Retryable { + error: Error::Timeout, + .. + } + )); + assert!(matches!( + classify_status(StatusCode::from_u16(529).unwrap(), None), + Failure::Retryable { + error: Error::Overloaded, + .. + } + )); + assert_eq!( + parse_retry_after(Some(&reqwest::header::HeaderValue::from_static("3"))), + Some(Duration::from_secs(3)) + ); + assert_eq!( + parse_retry_after(Some(&reqwest::header::HeaderValue::from_static("date"))), + None + ); + let now = SystemTime::UNIX_EPOCH + Duration::from_secs(1_000_000); + let future = now + Duration::from_secs(30); + let date = httpdate::fmt_http_date(future); + let header = reqwest::header::HeaderValue::from_str(&date).unwrap(); + assert_eq!( + parse_retry_after_at(Some(&header), now), + Some(Duration::from_secs(30)) + ); +} + +#[tokio::test] +async fn timeout_is_retryable_but_respects_the_attempt_bound() { + let mut config = config(slow_server().await); + config.timeout = Duration::from_millis(5); + let error = Client::new(config) + .unwrap() + .evaluate(&request()) + .await + .unwrap_err(); + assert!(matches!(error.error, Error::Timeout)); + assert_eq!(error.attempts, 1); + assert!(error.latency >= Duration::from_millis(5)); +} + +#[tokio::test] +async fn exhausted_rate_limit_returns_the_classified_error() { + let (base_url, _) = server(vec![response(429, "{}", "")]).await; + let error = Client::new(config(base_url)) + .unwrap() + .evaluate(&request()) + .await + .unwrap_err(); + assert!(matches!(error.error, Error::RateLimited)); + assert_eq!(error.attempts, 1); +} + +#[tokio::test] +async fn local_validation_and_response_validation_report_failure_metadata() { + let client = Client::new(config("http://127.0.0.1:1".into())).unwrap(); + let invalid = EvaluationRequest::jev("state", BTreeMap::new()); + let failure = client.evaluate(&invalid).await.unwrap_err(); + assert!(matches!(failure.error, Error::InvalidRequest { .. })); + assert_eq!(failure.attempts, 0); + + let body = json!({ + "model": "jev-latest", + "answers": {}, + "usage": {"input_tokens": 1, "output_tokens": 1} + }) + .to_string(); + let (base_url, _) = server(vec![response(200, &body, "")]).await; + let failure = Client::new(config(base_url)) + .unwrap() + .evaluate(&request()) + .await + .unwrap_err(); + assert!(matches!(failure.error, Error::InvalidResponse { .. })); + assert_eq!(failure.attempts, 1); +} + +#[tokio::test] +async fn connection_failure_is_classified_as_transport() { + let failure = Client::new(config("http://127.0.0.1:1".into())) + .unwrap() + .evaluate(&request()) + .await + .unwrap_err(); + assert!(matches!(failure.error, Error::Transport { .. })); + assert_eq!(failure.attempts, 1); +} diff --git a/crates/tinyjevclient/src/client/types.rs b/crates/tinyjevclient/src/client/types.rs new file mode 100644 index 0000000..0339068 --- /dev/null +++ b/crates/tinyjevclient/src/client/types.rs @@ -0,0 +1,128 @@ +//! Client configuration and measured result types. + +use std::{fmt, time::Duration}; + +use crate::{Error, EvaluationResponse}; + +/// Async `TypeSafe` System One client. +#[derive(Clone)] +pub struct Client { + pub(super) config: ClientConfig, + pub(super) http: reqwest::Client, +} + +impl fmt::Debug for Client { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Client") + .field("config", &self.config) + .finish_non_exhaustive() + } +} + +/// HTTP client configuration. +#[derive(Clone)] +pub struct ClientConfig { + pub(super) api_key: ApiKey, + /// API root without the versioned endpoint path. + pub base_url: String, + /// Total timeout for one HTTP attempt. + pub timeout: Duration, + /// Transient failure retry policy. + pub retry: RetryPolicy, +} + +impl ClientConfig { + /// Create production configuration for an API key. + #[must_use] + pub fn new(api_key: impl Into) -> Self { + Self { + api_key: ApiKey(api_key.into()), + base_url: "https://api.typesafe.ai".to_owned(), + timeout: Duration::from_secs(30), + retry: RetryPolicy::default(), + } + } + + /// Replace the API key without exposing it through a public field. + #[must_use] + pub fn with_api_key(mut self, api_key: impl Into) -> Self { + self.api_key = ApiKey(api_key.into()); + self + } +} + +impl fmt::Debug for ClientConfig { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("ClientConfig") + .field("api_key", &"[REDACTED]") + .field("base_url", &self.base_url) + .field("timeout", &self.timeout) + .field("retry", &self.retry) + .finish() + } +} + +#[derive(Clone)] +pub(super) struct ApiKey(String); + +impl ApiKey { + pub(super) fn expose(&self) -> &str { + &self.0 + } +} + +/// Retry limits for transient failures. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct RetryPolicy { + /// Additional attempts after the initial request. + pub max_retries: u32, + /// Delay used after the first retryable failure. + pub initial_backoff: Duration, + /// Maximum delay, including provider-requested delays. + pub max_backoff: Duration, +} + +impl RetryPolicy { + pub(super) fn delay(self, attempts: u32) -> Duration { + let exponent = attempts.saturating_sub(1).min(31); + self.initial_backoff + .saturating_mul(1_u32.checked_shl(exponent).unwrap_or(u32::MAX)) + .min(self.max_backoff) + } +} + +impl Default for RetryPolicy { + fn default() -> Self { + Self { + max_retries: 2, + initial_backoff: Duration::from_millis(100), + max_backoff: Duration::from_secs(2), + } + } +} + +/// A validated response with transport measurements. +#[derive(Clone, Debug, PartialEq)] +pub struct EvaluationResult { + /// Typed, validated provider response. + pub response: EvaluationResponse, + /// Provider request id, when returned as a header. + pub request_id: Option, + /// HTTP attempts including the successful or terminal attempt. + pub attempts: u32, + /// End-to-end elapsed time including retry delays. + pub latency: Duration, +} + +/// A failed evaluation with the attempts and elapsed time it spent. +#[derive(Debug, thiserror::Error)] +#[error("{error}")] +pub struct EvaluationFailure { + /// Classified terminal failure. + #[source] + pub error: Error, + /// HTTP attempts made before failure; zero for local request validation. + pub attempts: u32, + /// End-to-end elapsed time including retry delays. + pub latency: Duration, +} diff --git a/crates/tinyjevclient/src/error/README.md b/crates/tinyjevclient/src/error/README.md new file mode 100644 index 0000000..a8cc335 --- /dev/null +++ b/crates/tinyjevclient/src/error/README.md @@ -0,0 +1,5 @@ +# Error module + +The crate-wide `Error` enum classifies configuration, request, transport, HTTP, +decoding, and response-contract failures without retaining credentials or response +bodies. `test.rs` pins safe rendering. diff --git a/crates/tinyjevclient/src/error/mod.rs b/crates/tinyjevclient/src/error/mod.rs new file mode 100644 index 0000000..edbcc77 --- /dev/null +++ b/crates/tinyjevclient/src/error/mod.rs @@ -0,0 +1,86 @@ +//! Crate-wide error and result types. + +/// Errors returned by this crate. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum Error { + /// No API key was configured. + #[error("TYPESAFE_API_KEY must be set or supplied explicitly")] + MissingApiKey, + /// Client configuration is not usable. + #[error("invalid client configuration: {reason}")] + InvalidConfig { + /// Stable explanation of the rejected field. + reason: String, + }, + /// The request violates the local System One contract. + #[error("invalid request: {reason}")] + InvalidRequest { + /// Stable explanation of the rejected value. + reason: String, + }, + /// Authentication was rejected. + #[error("TypeSafe authentication failed")] + Authentication, + /// The provider rejected the request shape. + #[error("TypeSafe rejected the request")] + Unprocessable, + /// The account or endpoint rate limit was reached. + #[error("TypeSafe rate limit exceeded")] + RateLimited, + /// The `TypeSafe` service reported temporary overload. + #[error("TypeSafe service overloaded")] + Overloaded, + /// The endpoint returned another unsuccessful status. + #[error("TypeSafe request failed with status {status}")] + HttpStatus { + /// Returned HTTP status code. + status: u16, + }, + /// The request timed out. + #[error("TypeSafe request timed out")] + Timeout, + /// The HTTP transport failed before a response was available. + #[error("TypeSafe transport failed")] + Transport { + /// Underlying transport failure. + #[source] + source: reqwest::Error, + }, + /// The response body was not valid JSON for the declared wire shape. + #[error("TypeSafe response could not be decoded")] + Decode { + /// Underlying JSON decoding failure. + #[source] + source: serde_json::Error, + }, + /// The decoded response is inconsistent with the request. + #[error("invalid response: {reason}")] + InvalidResponse { + /// Stable explanation of the contract violation. + reason: String, + }, +} + +impl Error { + pub(crate) fn invalid_request(reason: impl Into) -> Self { + Self::InvalidRequest { + reason: reason.into(), + } + } + + pub(crate) fn invalid_response(reason: impl Into) -> Self { + Self::InvalidResponse { + reason: reason.into(), + } + } +} + +/// The crate's standard result type. +/// +/// Use this alias in public signatures instead of spelling out +/// `std::result::Result`. +pub type Result = std::result::Result; + +#[cfg(test)] +mod test; diff --git a/crates/tinyjevclient/src/error/test.rs b/crates/tinyjevclient/src/error/test.rs new file mode 100644 index 0000000..9582bbc --- /dev/null +++ b/crates/tinyjevclient/src/error/test.rs @@ -0,0 +1,33 @@ +//! Error rendering behavior. + +use super::*; + +#[test] +fn messages_do_not_render_secrets_or_response_bodies() { + let errors = [ + Error::MissingApiKey, + Error::Authentication, + Error::Unprocessable, + Error::RateLimited, + Error::Overloaded, + Error::HttpStatus { status: 503 }, + Error::Timeout, + ]; + for error in errors { + let rendered = error.to_string(); + assert!(!rendered.contains("Bearer")); + assert!(!rendered.contains("apikey_")); + } +} + +#[test] +fn contextual_errors_name_their_category() { + assert_eq!( + Error::invalid_request("questions must not be empty").to_string(), + "invalid request: questions must not be empty" + ); + assert_eq!( + Error::invalid_response("answer missing").to_string(), + "invalid response: answer missing" + ); +} diff --git a/crates/tinyjevclient/src/lib.rs b/crates/tinyjevclient/src/lib.rs new file mode 100644 index 0000000..f1adb0f --- /dev/null +++ b/crates/tinyjevclient/src/lib.rs @@ -0,0 +1,47 @@ +//! Typed Rust access to `TypeSafe` AI's System One API and Jev model. +//! +//! A request supplies text or structured state plus independent [`Question`]s. +//! Jev returns typed choices, ordinal scores, and yes/no probabilities for code +//! to compose. This crate validates both sides of that wire contract and owns +//! only the HTTP wait; policy, thresholds, and actions stay with the caller. +//! +//! # Example +//! +//! ```no_run +//! use std::collections::BTreeMap; +//! use serde_json::json; +//! use tinyjevclient::{Choice, Client, EvaluationRequest, Question}; +//! +//! # async fn example() -> Result<(), Box> { +//! let criteria = BTreeMap::from([ +//! ("billing".to_owned(), Some(json!("payments and refunds"))), +//! ("technical".to_owned(), Some(json!("bugs and outages"))), +//! ]); +//! let request = EvaluationRequest::jev( +//! json!({"ticket": "I was charged twice"}), +//! BTreeMap::from([( +//! "route".to_owned(), +//! Question::Choice(Choice { +//! instructions: json!("Which team should handle this ticket?"), +//! criteria, +//! }), +//! )]), +//! ); +//! let result = Client::from_env()?.evaluate(&request).await?; +//! println!("{:?}", result.response.answers["route"]); +//! # Ok(()) +//! # } +//! ``` +//! +//! The crate deliberately does not execute a selected action, infer permission +//! from confidence, or hide a retry behind an unbounded loop. + +mod client; +mod error; +mod request; +mod response; + +pub use client::{Client, ClientConfig, EvaluationFailure, EvaluationResult, RetryPolicy}; +pub use error::{Error, Result}; +pub use request::{Choice, EvaluationRequest, Noul, NoulCriteria, Question, Score}; +pub use response::{Answer, ChoiceAnswer, EvaluationResponse, NoulAnswer, ScoreAnswer, Usage}; diff --git a/crates/tinyjevclient/src/request/README.md b/crates/tinyjevclient/src/request/README.md new file mode 100644 index 0000000..433c4a8 --- /dev/null +++ b/crates/tinyjevclient/src/request/README.md @@ -0,0 +1,5 @@ +# Request module + +This module defines the exact state-and-questions payload sent to TypeSafe's +System One endpoint. `types.rs` holds the serde wire types, `mod.rs` validates +the documented primitive bounds before transport, and `test.rs` pins both. diff --git a/crates/tinyjevclient/src/request/mod.rs b/crates/tinyjevclient/src/request/mod.rs new file mode 100644 index 0000000..c04cf97 --- /dev/null +++ b/crates/tinyjevclient/src/request/mod.rs @@ -0,0 +1,124 @@ +//! Typed System One request values and their local validation. + +#[cfg(test)] +mod test; + +mod types; + +pub use types::{Choice, EvaluationRequest, Noul, NoulCriteria, Question, Score}; + +use crate::{Error, Result}; + +impl EvaluationRequest { + /// Validate this request before any network operation begins. + /// + /// # Errors + /// + /// Returns [`Error::InvalidRequest`] when the model, state, question ids, + /// instructions, or criteria cannot form a valid System One request. + pub fn validate(&self) -> Result<()> { + if self.model.trim().is_empty() { + return Err(Error::invalid_request("model must not be empty")); + } + if !matches!( + self.state, + serde_json::Value::String(_) + | serde_json::Value::Array(_) + | serde_json::Value::Object(_) + ) { + return Err(Error::invalid_request( + "state must be a string, object, or array", + )); + } + if self.questions.is_empty() { + return Err(Error::invalid_request("questions must not be empty")); + } + for (id, question) in &self.questions { + if id.trim().is_empty() { + return Err(Error::invalid_request("question ids must not be empty")); + } + question.validate()?; + } + Ok(()) + } +} + +impl Question { + fn validate(&self) -> Result<()> { + match self { + Self::Choice(question) => question.validate(), + Self::Score(question) => question.validate(), + Self::Noul(question) => question.validate(), + } + } +} + +impl Choice { + fn validate(&self) -> Result<()> { + validate_instructions(&self.instructions)?; + if !(2..=255).contains(&self.criteria.len()) { + return Err(Error::invalid_request( + "choice criteria must contain between 2 and 255 options", + )); + } + if self.criteria.keys().any(|key| key.trim().is_empty()) { + return Err(Error::invalid_request( + "choice option names must not be empty", + )); + } + Ok(()) + } +} + +impl Score { + fn validate(&self) -> Result<()> { + validate_instructions(&self.instructions)?; + if !(2..=10).contains(&self.criteria.len()) { + return Err(Error::invalid_request( + "score criteria must contain between 2 and 10 levels", + )); + } + if self.criteria.iter().any(is_empty_text) { + return Err(Error::invalid_request( + "score level descriptions must not be empty", + )); + } + Ok(()) + } +} + +impl Noul { + fn validate(&self) -> Result<()> { + validate_instructions(&self.instructions)?; + if let Some(criteria) = &self.criteria + && (is_empty_text(&criteria.r#true) || is_empty_text(&criteria.r#false)) + { + return Err(Error::invalid_request( + "noul criteria descriptions must not be empty", + )); + } + Ok(()) + } +} + +fn validate_instructions(value: &serde_json::Value) -> Result<()> { + if !matches!( + value, + serde_json::Value::String(_) | serde_json::Value::Array(_) | serde_json::Value::Object(_) + ) || is_empty_text(value) + { + return Err(Error::invalid_request( + "instructions must be a nonempty string, object, or array", + )); + } + Ok(()) +} + +fn is_empty_text(value: &serde_json::Value) -> bool { + match value { + serde_json::Value::String(text) => text.trim().is_empty(), + serde_json::Value::Array(values) => values.is_empty(), + serde_json::Value::Object(values) => values.is_empty(), + _ => false, + } +} diff --git a/crates/tinyjevclient/src/request/test.rs b/crates/tinyjevclient/src/request/test.rs new file mode 100644 index 0000000..c9b181a --- /dev/null +++ b/crates/tinyjevclient/src/request/test.rs @@ -0,0 +1,200 @@ +//! Request wire and validation tests. + +#![allow(clippy::unwrap_used)] + +use std::collections::BTreeMap; + +use serde_json::json; + +use super::*; + +fn questions() -> BTreeMap { + BTreeMap::from([ + ( + "route".to_owned(), + Question::Choice(Choice { + instructions: json!("Which agent should answer?"), + criteria: BTreeMap::from([ + ("planner".to_owned(), Some(json!("plans work"))), + ("reviewer".to_owned(), None), + ]), + }), + ), + ( + "quality".to_owned(), + Question::Score(Score { + instructions: json!("How strong is the evidence?"), + criteria: vec![json!("unsupported"), json!("direct")], + }), + ), + ( + "unsafe".to_owned(), + Question::Noul(Noul { + instructions: json!("Does this violate a stated constraint?"), + criteria: Some(NoulCriteria { + r#true: json!("a constraint is violated"), + r#false: json!("all constraints are satisfied"), + }), + }), + ), + ]) +} + +#[test] +fn every_primitive_pins_its_wire_shape() { + let request = EvaluationRequest::jev(json!({"message": "review this"}), questions()); + assert_eq!( + serde_json::to_value(request).unwrap(), + json!({ + "state": {"message": "review this"}, + "model": "jev-latest", + "questions": { + "quality": { + "type": "score", + "instructions": "How strong is the evidence?", + "criteria": ["unsupported", "direct"] + }, + "route": { + "type": "choice", + "instructions": "Which agent should answer?", + "criteria": {"planner": "plans work", "reviewer": null} + }, + "unsafe": { + "type": "noul", + "instructions": "Does this violate a stated constraint?", + "criteria": { + "true": "a constraint is violated", + "false": "all constraints are satisfied" + } + } + } + }) + ); +} + +#[test] +fn accepts_string_object_and_array_state() { + for state in [json!("text"), json!({"field": true}), json!([1, 2])] { + EvaluationRequest::jev(state, questions()) + .validate() + .unwrap(); + } +} + +#[test] +fn rejects_empty_or_wrongly_shaped_inputs() { + let cases = [ + EvaluationRequest::jev(json!(null), questions()), + EvaluationRequest::jev(json!(true), questions()), + EvaluationRequest::jev("state", BTreeMap::new()), + ]; + for request in cases { + assert!(request.validate().is_err()); + } +} + +#[test] +fn enforces_choice_and_score_bounds() { + let choice = EvaluationRequest::jev( + "state", + BTreeMap::from([( + "choice".to_owned(), + Question::Choice(Choice { + instructions: json!("choose"), + criteria: BTreeMap::from([("only".to_owned(), None)]), + }), + )]), + ); + let score = EvaluationRequest::jev( + "state", + BTreeMap::from([( + "score".to_owned(), + Question::Score(Score { + instructions: json!("score"), + criteria: vec![json!("only")], + }), + )]), + ); + assert!(choice.validate().is_err()); + assert!(score.validate().is_err()); +} + +#[test] +fn rejects_blank_model_ids_instructions_and_criteria() { + let mut model = EvaluationRequest::jev("state", questions()); + model.model = " ".into(); + assert!(model.validate().is_err()); + + let mut id = EvaluationRequest::jev("state", questions()); + let question = id.questions.remove("route").unwrap(); + id.questions.insert(" ".into(), question); + assert!(id.validate().is_err()); + + let blank_choice = EvaluationRequest::jev( + "state", + BTreeMap::from([( + "choice".into(), + Question::Choice(Choice { + instructions: json!(" "), + criteria: BTreeMap::from([("a".into(), None), ("b".into(), None)]), + }), + )]), + ); + assert!(blank_choice.validate().is_err()); + + for instructions in [json!({}), json!([])] { + let empty_structured = EvaluationRequest::jev( + "state", + BTreeMap::from([( + "choice".into(), + Question::Choice(Choice { + instructions, + criteria: BTreeMap::from([("a".into(), None), ("b".into(), None)]), + }), + )]), + ); + assert!(empty_structured.validate().is_err()); + } + + let empty_option = EvaluationRequest::jev( + "state", + BTreeMap::from([( + "choice".into(), + Question::Choice(Choice { + instructions: json!("choose"), + criteria: BTreeMap::from([(String::new(), None), ("b".into(), None)]), + }), + )]), + ); + assert!(empty_option.validate().is_err()); +} + +#[test] +fn rejects_blank_score_and_noul_descriptions() { + let score = EvaluationRequest::jev( + "state", + BTreeMap::from([( + "score".into(), + Question::Score(Score { + instructions: json!("rate"), + criteria: vec![json!("low"), json!(" ")], + }), + )]), + ); + assert!(score.validate().is_err()); + + let noul = EvaluationRequest::jev( + "state", + BTreeMap::from([( + "noul".into(), + Question::Noul(Noul { + instructions: json!("is it safe?"), + criteria: Some(NoulCriteria { + r#true: json!(" "), + r#false: json!("not safe"), + }), + }), + )]), + ); + assert!(noul.validate().is_err()); +} diff --git a/crates/tinyjevclient/src/request/types.rs b/crates/tinyjevclient/src/request/types.rs new file mode 100644 index 0000000..3e4afcd --- /dev/null +++ b/crates/tinyjevclient/src/request/types.rs @@ -0,0 +1,78 @@ +//! Stable request payload definitions. + +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +/// One complete System One evaluation request. +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +pub struct EvaluationRequest { + /// Unstructured text or structured application state visible to every question. + pub state: Value, + /// System One model identifier, normally `jev-latest`. + pub model: String, + /// Independently evaluated questions keyed by caller-owned ids. + pub questions: BTreeMap, +} + +impl EvaluationRequest { + /// Build a request using the stable Jev alias. + #[must_use] + pub fn jev(state: impl Into, questions: BTreeMap) -> Self { + Self { + state: state.into(), + model: "jev-latest".to_owned(), + questions, + } + } +} + +/// A typed question evaluated independently against shared state. +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum Question { + /// Select exactly one option from a closed set. + Choice(Choice), + /// Place the state along an ordered descriptive scale. + Score(Score), + /// Estimate the probability that a yes/no condition holds. + Noul(Noul), +} + +/// A closed-set selection question. +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +pub struct Choice { + /// Complete meaning of the decision to make. + pub instructions: Value, + /// Option name to optional distinguishing description. + pub criteria: BTreeMap>, +} + +/// An ordered-scale question. +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +pub struct Score { + /// Dimension being rated. + pub instructions: Value, + /// Ordered, standalone level descriptions. + pub criteria: Vec, +} + +/// A yes/no probability question. +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +pub struct Noul { + /// Condition whose probability of being true is requested. + pub instructions: Value, + /// Optional descriptions clarifying both outcomes. + #[serde(skip_serializing_if = "Option::is_none")] + pub criteria: Option, +} + +/// Descriptions of the true and false outcomes of a [`Noul`]. +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +pub struct NoulCriteria { + /// What counts as true. + pub r#true: Value, + /// What counts as false. + pub r#false: Value, +} diff --git a/crates/tinyjevclient/src/response/README.md b/crates/tinyjevclient/src/response/README.md new file mode 100644 index 0000000..7c963ba --- /dev/null +++ b/crates/tinyjevclient/src/response/README.md @@ -0,0 +1,5 @@ +# Response module + +This module decodes System One answers and validates them against the originating +request. `types.rs` mirrors the wire response, `mod.rs` rejects inconsistent ids, +types, distributions, legends, and scores, and `test.rs` pins those guarantees. diff --git a/crates/tinyjevclient/src/response/mod.rs b/crates/tinyjevclient/src/response/mod.rs new file mode 100644 index 0000000..30a03e3 --- /dev/null +++ b/crates/tinyjevclient/src/response/mod.rs @@ -0,0 +1,141 @@ +//! Typed System One responses and cross-checks against their requests. + +#[cfg(test)] +mod test; + +mod types; + +pub use types::{Answer, ChoiceAnswer, EvaluationResponse, NoulAnswer, ScoreAnswer, Usage}; + +use std::collections::BTreeSet; + +use crate::{Error, EvaluationRequest, Question, Result}; + +const PROBABILITY_TOLERANCE: f64 = 0.000_001; +const SCORE_TOLERANCE: f64 = 0.02; + +impl EvaluationResponse { + /// Check this response against the request that produced it. + /// + /// # Errors + /// + /// Returns [`Error::InvalidResponse`] when answer ids or primitive types do + /// not match the request, or when a probability payload is inconsistent. + pub fn validate_for(&self, request: &EvaluationRequest) -> Result<()> { + if self.model.trim().is_empty() { + return Err(Error::invalid_response("response model must not be empty")); + } + let expected: BTreeSet<&str> = request.questions.keys().map(String::as_str).collect(); + let actual: BTreeSet<&str> = self.answers.keys().map(String::as_str).collect(); + if actual != expected { + return Err(Error::invalid_response( + "response answer ids must exactly match request question ids", + )); + } + for (id, question) in &request.questions { + let answer = self + .answers + .get(id) + .ok_or_else(|| Error::invalid_response("response answer is missing"))?; + validate_pair(question, answer)?; + } + Ok(()) + } +} + +fn validate_pair(question: &Question, answer: &Answer) -> Result<()> { + match (question, answer) { + (Question::Choice(question), Answer::Choice(answer)) => { + validate_probability(answer.confidence, "choice confidence")?; + validate_distribution(&answer.probabilities, "choice")?; + let expected: BTreeSet<&str> = question.criteria.keys().map(String::as_str).collect(); + let actual: BTreeSet<&str> = answer.probabilities.keys().map(String::as_str).collect(); + if actual != expected || !expected.contains(answer.choice.as_str()) { + return Err(Error::invalid_response( + "choice labels must exactly match request criteria", + )); + } + let selected = answer.probabilities[&answer.choice]; + if answer + .probabilities + .values() + .any(|probability| *probability > selected + PROBABILITY_TOLERANCE) + { + return Err(Error::invalid_response( + "choice must name a highest-probability option", + )); + } + } + (Question::Score(question), Answer::Score(answer)) => { + validate_probability(answer.confidence, "score confidence")?; + validate_distribution(&answer.probabilities, "score")?; + let expected: BTreeSet = (0..question.criteria.len()) + .map(|index| index.to_string()) + .collect(); + let actual: BTreeSet = answer.probabilities.keys().cloned().collect(); + let legend: BTreeSet = answer.legend.keys().cloned().collect(); + let legend_matches = question + .criteria + .iter() + .enumerate() + .all(|(index, criterion)| answer.legend.get(&index.to_string()) == Some(criterion)); + if actual != expected || legend != expected || !legend_matches { + return Err(Error::invalid_response( + "score levels must exactly match request criteria", + )); + } + if !answer.score.is_finite() { + return Err(Error::invalid_response("score must be finite")); + } + let expected_score: f64 = answer + .probabilities + .iter() + .map(|(level, probability)| level.parse::().unwrap_or_default() * probability) + .sum(); + if (answer.score - expected_score).abs() > SCORE_TOLERANCE { + return Err(Error::invalid_response( + "score must equal the probability-weighted level", + )); + } + } + (Question::Noul(_), Answer::Noul(answer)) => { + validate_probability(answer.noul, "noul")?; + } + _ => { + return Err(Error::invalid_response( + "answer type must match its request question type", + )); + } + } + Ok(()) +} + +fn validate_distribution( + probabilities: &std::collections::BTreeMap, + name: &str, +) -> Result<()> { + if probabilities.is_empty() { + return Err(Error::invalid_response(format!( + "{name} probabilities must not be empty" + ))); + } + for probability in probabilities.values() { + validate_probability(*probability, name)?; + } + let sum: f64 = probabilities.values().sum(); + if (sum - 1.0).abs() > PROBABILITY_TOLERANCE { + return Err(Error::invalid_response(format!( + "{name} probabilities must sum to one" + ))); + } + Ok(()) +} + +fn validate_probability(value: f64, name: &str) -> Result<()> { + if !value.is_finite() || !(0.0..=1.0).contains(&value) { + return Err(Error::invalid_response(format!( + "{name} must be between zero and one" + ))); + } + Ok(()) +} diff --git a/crates/tinyjevclient/src/response/test.rs b/crates/tinyjevclient/src/response/test.rs new file mode 100644 index 0000000..58d90c8 --- /dev/null +++ b/crates/tinyjevclient/src/response/test.rs @@ -0,0 +1,192 @@ +//! Response wire and request-relative validation tests. + +#![allow(clippy::unwrap_used, clippy::panic)] + +use std::collections::BTreeMap; + +use serde_json::json; + +use super::*; +use crate::{Choice, EvaluationRequest, Noul, Question, Score}; + +fn request() -> EvaluationRequest { + EvaluationRequest::jev( + "state", + BTreeMap::from([ + ( + "route".to_owned(), + Question::Choice(Choice { + instructions: json!("route"), + criteria: BTreeMap::from([("a".to_owned(), None), ("b".to_owned(), None)]), + }), + ), + ( + "quality".to_owned(), + Question::Score(Score { + instructions: json!("quality"), + criteria: vec![json!("low"), json!("high")], + }), + ), + ( + "safe".to_owned(), + Question::Noul(Noul { + instructions: json!("safe"), + criteria: None, + }), + ), + ]), + ) +} + +fn response() -> EvaluationResponse { + serde_json::from_value(json!({ + "model": "jev-latest", + "answers": { + "route": { + "type": "choice", + "choice": "b", + "probabilities": {"a": 0.25, "b": 0.75}, + "confidence": 0.5 + }, + "quality": { + "type": "score", + "score": 0.8, + "legend": {"0": "low", "1": "high"}, + "probabilities": {"0": 0.2, "1": 0.8}, + "confidence": 0.6 + }, + "safe": {"type": "noul", "noul": 0.9} + }, + "usage": {"input_tokens": 42, "output_tokens": 3} + })) + .unwrap() +} + +#[test] +fn validates_all_three_answer_types() { + response().validate_for(&request()).unwrap(); + let mut rounded = response(); + let Answer::Score(answer) = rounded.answers.get_mut("quality").unwrap() else { + panic!("fixture answer should be a score") + }; + answer.score = 0.81; + rounded.validate_for(&request()).unwrap(); +} + +#[test] +fn usage_fields_remain_optional() { + let usage: Usage = serde_json::from_value(json!({})).unwrap(); + assert_eq!(usage, Usage::default()); +} + +#[test] +fn rejects_missing_extra_or_wrongly_typed_answers() { + let mut missing = response(); + missing.answers.remove("safe"); + assert!(missing.validate_for(&request()).is_err()); + + let mut wrong = response(); + wrong.answers.insert( + "safe".to_owned(), + Answer::Choice(ChoiceAnswer { + choice: "a".to_owned(), + probabilities: BTreeMap::from([("a".to_owned(), 1.0)]), + confidence: 1.0, + }), + ); + assert!(wrong.validate_for(&request()).is_err()); +} + +#[test] +fn rejects_invalid_distributions_and_inconsistent_scores() { + let mut distribution = response(); + let Answer::Choice(choice) = distribution.answers.get_mut("route").unwrap() else { + panic!("fixture answer should be a choice") + }; + choice.probabilities.insert("a".to_owned(), 0.75); + assert!(distribution.validate_for(&request()).is_err()); + + let mut score = response(); + let Answer::Score(answer) = score.answers.get_mut("quality").unwrap() else { + panic!("fixture answer should be a score") + }; + answer.score = 0.1; + assert!(score.validate_for(&request()).is_err()); +} + +#[test] +fn rejects_empty_model_extra_ids_and_nonmaximal_choice() { + let mut empty_model = response(); + empty_model.model.clear(); + assert!(empty_model.validate_for(&request()).is_err()); + + let mut extra = response(); + extra + .answers + .insert("extra".into(), Answer::Noul(NoulAnswer { noul: 0.5 })); + assert!(extra.validate_for(&request()).is_err()); + + let mut nonmaximal = response(); + let Answer::Choice(choice) = nonmaximal.answers.get_mut("route").unwrap() else { + panic!("fixture answer should be a choice") + }; + choice.choice = "a".into(); + assert!(nonmaximal.validate_for(&request()).is_err()); +} + +#[test] +fn rejects_out_of_range_empty_and_mismatched_probability_payloads() { + let mut confidence = response(); + let Answer::Choice(choice) = confidence.answers.get_mut("route").unwrap() else { + panic!("fixture answer should be a choice") + }; + choice.confidence = 1.1; + assert!(confidence.validate_for(&request()).is_err()); + + let mut empty = response(); + let Answer::Choice(choice) = empty.answers.get_mut("route").unwrap() else { + panic!("fixture answer should be a choice") + }; + choice.probabilities.clear(); + assert!(empty.validate_for(&request()).is_err()); + + let mut labels = response(); + let Answer::Choice(choice) = labels.answers.get_mut("route").unwrap() else { + panic!("fixture answer should be a choice") + }; + choice.probabilities.remove("a"); + choice.probabilities.insert("c".into(), 0.25); + assert!(labels.validate_for(&request()).is_err()); + + let mut noul = response(); + let Answer::Noul(answer) = noul.answers.get_mut("safe").unwrap() else { + panic!("fixture answer should be a noul") + }; + answer.noul = f64::NAN; + assert!(noul.validate_for(&request()).is_err()); +} + +#[test] +fn rejects_nonfinite_score_and_mismatched_legend() { + let mut nonfinite = response(); + let Answer::Score(score) = nonfinite.answers.get_mut("quality").unwrap() else { + panic!("fixture answer should be a score") + }; + score.score = f64::INFINITY; + assert!(nonfinite.validate_for(&request()).is_err()); + + let mut legend = response(); + let Answer::Score(score) = legend.answers.get_mut("quality").unwrap() else { + panic!("fixture answer should be a score") + }; + score.legend.remove("1"); + assert!(legend.validate_for(&request()).is_err()); + + let mut reversed = response(); + let Answer::Score(score) = reversed.answers.get_mut("quality").unwrap() else { + panic!("fixture answer should be a score") + }; + score.legend.insert("0".into(), json!("high")); + score.legend.insert("1".into(), json!("low")); + assert!(reversed.validate_for(&request()).is_err()); +} diff --git a/crates/tinyjevclient/src/response/types.rs b/crates/tinyjevclient/src/response/types.rs new file mode 100644 index 0000000..b328970 --- /dev/null +++ b/crates/tinyjevclient/src/response/types.rs @@ -0,0 +1,71 @@ +//! Stable response payload definitions. + +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +/// One decoded System One response. +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +pub struct EvaluationResponse { + /// Model that performed the evaluation. + pub model: String, + /// Answers keyed by the caller's question ids. + pub answers: BTreeMap, + /// Provider-reported token counts. + pub usage: Usage, +} + +/// Provider-reported token usage. +#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +pub struct Usage { + /// Input tokens, when reported. + #[serde(default)] + pub input_tokens: Option, + /// Output tokens, when reported. + #[serde(default)] + pub output_tokens: Option, +} + +/// One typed answer. +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum Answer { + /// Closed-set selection and distribution. + Choice(ChoiceAnswer), + /// Ordered score and level distribution. + Score(ScoreAnswer), + /// Probability of yes. + Noul(NoulAnswer), +} + +/// Answer to a Choice question. +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +pub struct ChoiceAnswer { + /// Highest-probability option. + pub choice: String, + /// Probability for every requested option. + pub probabilities: BTreeMap, + /// Concentration of the distribution, not correctness probability. + pub confidence: f64, +} + +/// Answer to a Score question. +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +pub struct ScoreAnswer { + /// Probability-weighted position along the scale. + pub score: f64, + /// Requested level descriptions keyed by zero-based index. + pub legend: BTreeMap, + /// Probability for every level. + pub probabilities: BTreeMap, + /// Concentration of the distribution, not correctness probability. + pub confidence: f64, +} + +/// Answer to a Noul question. +#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Serialize)] +pub struct NoulAnswer { + /// Probability that the requested condition is true. + pub noul: f64, +} diff --git a/crates/tinyjevclient/tests/public_api.rs b/crates/tinyjevclient/tests/public_api.rs new file mode 100644 index 0000000..b56bdd9 --- /dev/null +++ b/crates/tinyjevclient/tests/public_api.rs @@ -0,0 +1,26 @@ +//! Public surface smoke tests. + +#![allow(clippy::expect_used)] + +use std::collections::BTreeMap; + +use serde_json::json; +use tinyjevclient::{Choice, EvaluationRequest, Question}; + +#[test] +fn public_types_build_a_valid_jev_request() { + let request = EvaluationRequest::jev( + "route this", + BTreeMap::from([( + "route".to_owned(), + Question::Choice(Choice { + instructions: json!("Who should handle this?"), + criteria: BTreeMap::from([ + ("planner".to_owned(), None), + ("reviewer".to_owned(), None), + ]), + }), + )]), + ); + request.validate().expect("public request should validate"); +} diff --git a/deny.toml b/deny.toml index 1134b6a..e105108 100644 --- a/deny.toml +++ b/deny.toml @@ -11,7 +11,7 @@ ignore = [] [licenses] # Licenses accepted for this crate and its dependencies. Keep GPL-3.0-only for -# the template crate itself; the remaining entries cover compatible dependency +# this crate itself; the remaining entries cover compatible dependency # licenses commonly encountered by Rust projects. allow = [ "Apache-2.0", diff --git a/docs/README.md b/docs/README.md index 0c0f2b1..faec297 100644 --- a/docs/README.md +++ b/docs/README.md @@ -28,10 +28,9 @@ docs/ Complex modules also carry a module-level `README.md` inside `src//` covering their design, public surface, and important constraints. -The current module-release contract is in -[`specs/tinybus-module-release.md`](specs/tinybus-module-release.md), with its -implementation sequence in -[`plans/tinybus-module-release.md`](plans/tinybus-module-release.md). +The current client contract is in +[`specs/system-one-client.md`](specs/system-one-client.md), with its +implementation sequence in [`plans/system-one-client.md`](plans/system-one-client.md). ## Conventions diff --git a/docs/plans/README.md b/docs/plans/README.md index 0a5db16..ec5ef83 100644 --- a/docs/plans/README.md +++ b/docs/plans/README.md @@ -19,4 +19,4 @@ Prefer tasks that can be implemented and reviewed independently. Include short code snippets when they remove ambiguity, but do not paste entire future files into the plan. -See [`example-retry-policy.md`](example-retry-policy.md) for a test-first sample. +The implemented plan is [`system-one-client.md`](system-one-client.md). diff --git a/docs/plans/example-retry-policy.md b/docs/plans/example-retry-policy.md deleted file mode 100644 index fe42c4c..0000000 --- a/docs/plans/example-retry-policy.md +++ /dev/null @@ -1,71 +0,0 @@ -# Example plan: Retry policy - -- **Status:** Example -- **Specification:** - [`../specs/example-retry-policy.md`](../specs/example-retry-policy.md) - -> This is a sample implementation plan, not active work. Replace or remove it -> when generating a real project from this template. - -## Goal - -Add the specified typed retry policy through small red-green-refactor steps, -without adding a runtime, timers, or new dependencies. - -## Task 1: Add the constructor contract - -**Files:** `src/retry/mod.rs`, `src/retry/types.rs`, `src/retry/test.rs` - -1. Create the module skeleton and a failing test for zero attempts: - - ```rust - #[test] - fn rejects_zero_max_attempts() { - assert_eq!( - RetryPolicy::new(0).unwrap_err(), - Error::ZeroMaxAttempts, - ); - } - ``` - -2. Add `Error::ZeroMaxAttempts` in `src/error/mod.rs` and its message assertion - in `src/error/test.rs`. -3. Implement `RetryPolicy::new` using `NonZeroU32`, keeping the field private. -4. Run `cargo test retry` and `cargo clippy --all-targets --all-features -- -D warnings`. - -## Task 2: Add attempt-boundary behavior - -**Files:** `src/retry/mod.rs`, `src/retry/test.rs` - -1. Add failing tests for attempts `0`, `1`, the maximum, and one past it. -2. Implement the smallest boundary check: - - ```rust - #[must_use] - pub fn allows_attempt(self, attempt: u32) -> bool { - attempt != 0 && attempt <= self.max_attempts.get() - } - ``` - -3. Run `cargo test retry`. - -## Task 3: Publish and document the API - -**Files:** `src/lib.rs`, `tests/public_api.rs`, `README.md` - -1. Re-export `RetryPolicy` from `src/lib.rs`. -2. Add an integration test using only `template::{Error, RetryPolicy}`. -3. Add a runnable README example and rustdoc `# Errors` documentation. -4. Run `cargo test --doc` and `cargo test --test public_api`. - -## Task 4: Full verification - -- [ ] `cargo fmt --all -- --check` -- [ ] `cargo clippy --all-targets --all-features -- -D warnings` -- [ ] `cargo build --all-targets --all-features` -- [ ] `cargo test --all-features` -- [ ] `RUSTDOCFLAGS="-D warnings" cargo doc --no-deps --all-features` -- [ ] `cargo deny check all` - -When all checks pass, mark the specification Implemented and replace this -example status with the actual completion state. diff --git a/docs/plans/system-one-client.md b/docs/plans/system-one-client.md new file mode 100644 index 0000000..a07f761 --- /dev/null +++ b/docs/plans/system-one-client.md @@ -0,0 +1,27 @@ +# Implement the System One client + +Linked specification: [`../specs/system-one-client.md`](../specs/system-one-client.md). + +1. Replace the TinyBus template with the ordinary library at + `crates/tinyjevclient/`; remove the obsolete module crate, contract crate, + submodule, packaging workflow, and release documentation. +2. Define and pin Choice, Score, Noul, request, answer, usage, and response + wires under `crates/tinyjevclient/src/{request,response}/`. +3. Validate requests and request-relative response invariants in those module + roots, with every case in their adjacent `test.rs` files. +4. Implement the rustls client, secret redaction, classified failures, failure + measurements, HTTPS policy, and bounded retries under + `crates/tinyjevclient/src/{client,error}/`. +5. Update the crate example, public API test, root documentation, CI, lockfile, + dependency policy, and environment example in the same change. +6. Verify with: + + ```sh + cargo fmt --all -- --check + cargo clippy --all-targets --all-features -- -D warnings + cargo build --all-targets --all-features + cargo test --all-features + RUSTDOCFLAGS="-D warnings" cargo doc --no-deps --all-features + .github/scripts/check-file-coverage.sh 90 coverage.json + cargo deny check all + ``` diff --git a/docs/plans/tinybus-module-release.md b/docs/plans/tinybus-module-release.md deleted file mode 100644 index f9eaa18..0000000 --- a/docs/plans/tinybus-module-release.md +++ /dev/null @@ -1,11 +0,0 @@ -# Implement TinyBus Module Releases - -Linked specification: [`../specs/tinybus-module-release.md`](../specs/tinybus-module-release.md) - -1. Add the pinned TinyBus host types and module SDK as path dependencies. -2. Export the template greeting behavior through TinyBus module ABI v1. -3. Exercise the declared interface over the real in-memory bus. -4. Replace TinyBus host bundles with tagged `template` module archives for - every supported platform runner and distribution container. -5. Run the repository validation and coverage contracts, push `main`, and - trigger a patch release. diff --git a/docs/specs/README.md b/docs/specs/README.md index a8286ae..f8eb150 100644 --- a/docs/specs/README.md +++ b/docs/specs/README.md @@ -20,4 +20,4 @@ After the specification is accepted, create a linked implementation plan in [`../plans/`](../plans/README.md). Keep code snippets small enough to clarify the contract; production code still belongs under `src/`. -See [`example-retry-policy.md`](example-retry-policy.md) for a complete sample. +The implemented contract is [`system-one-client.md`](system-one-client.md). diff --git a/docs/specs/example-retry-policy.md b/docs/specs/example-retry-policy.md deleted file mode 100644 index fe0c1a8..0000000 --- a/docs/specs/example-retry-policy.md +++ /dev/null @@ -1,63 +0,0 @@ -# Example: Retry policy - -- **Status:** Example -- **Owner:** Maintainers -- **Plan:** [`../plans/example-retry-policy.md`](../plans/example-retry-policy.md) - -> This demonstrates the expected specification format. Replace or remove it -> when generating a real project from this template. - -## Problem - -Callers need a typed way to limit retries without duplicating attempt counting -and validation. The crate currently has no retry behavior. - -## Goals - -- Expose an immutable retry policy with a non-zero maximum attempt count. -- Let callers determine whether another attempt is permitted. -- Reject zero attempts through the crate-wide error type. - -## Non-goals - -- Sleeping, backoff, jitter, or executing operations. -- Deciding which application-specific errors are retryable. -- Persisting retry state. - -## Proposed behavior - -The public surface is deliberately small: - -```rust -use template::{RetryPolicy, Result}; - -fn policy() -> Result { - let policy = RetryPolicy::new(3)?; - assert!(policy.allows_attempt(1)); - assert!(!policy.allows_attempt(4)); - Ok(policy) -} -``` - -`RetryPolicy::new(0)` returns a dedicated `Error::ZeroMaxAttempts` variant. -Attempt numbers are one-based: attempt `1` is the initial call, not the first -retry. - -## Invariants and constraints - -- `max_attempts` is always greater than zero after construction. -- `allows_attempt(n)` is true exactly when `1 <= n <= max_attempts`. -- The type is cheap to copy and does not perform I/O or observe time. -- New public items have rustdoc and are re-exported from `src/lib.rs`. - -## Acceptance criteria - -- Construction succeeds for `1` and `u32::MAX` and fails for `0`. -- Boundary checks cover attempts `0`, `1`, `max_attempts`, and - `max_attempts + 1` when representable. -- Integration tests prove the policy and its error are available to consumers. -- Formatting, Clippy, build, tests, rustdoc, and cargo-deny pass. - -## Open questions - -None for this example. diff --git a/docs/specs/system-one-client.md b/docs/specs/system-one-client.md new file mode 100644 index 0000000..8bb6e90 --- /dev/null +++ b/docs/specs/system-one-client.md @@ -0,0 +1,27 @@ +# System One client + +## Contract + +The client mirrors `POST /v1/systemone`: state is a string, object, or array; +questions are a nonempty map of Choice, Score, and Noul values; answers return +under the same ids. Choice accepts 2–255 options, Score accepts 2–10 concrete +levels, and Noul may describe its true and false criteria. + +The client validates request bounds before transport and validates response ids, +answer types, probability ranges and sums, selected maxima, Score legends, and +weighted Score values before returning. Score consistency allows two hundredths +for provider display rounding while probability sums retain strict tolerance. +Typed output is an interface guarantee, +not a truth guarantee; applications evaluate accuracy and thresholds on their +own data. + +## Failure policy + +Authentication and request errors are terminal. Transport failures, timeouts, +rate limits, overload, and server errors use a bounded caller-visible retry +policy. No retry is unbounded, and success or failure reports every attempt and +the full elapsed time. HTTP is allowed only for literal loopback addresses; +every remote endpoint requires HTTPS. + +Credentials never appear in `Debug`, error messages, or retained response +bodies. Application state and provider bodies are not logged by the crate. diff --git a/docs/specs/tinybus-module-release.md b/docs/specs/tinybus-module-release.md deleted file mode 100644 index adae9b4..0000000 --- a/docs/specs/tinybus-module-release.md +++ /dev/null @@ -1,33 +0,0 @@ -# TinyBus Module Release - -## Purpose - -Generated projects must be usable as native TinyBus integrations and -distributable without also shipping the TinyBus host runtime. - -## Contract - -- The library builds as both an `rlib` and a native `cdylib`. -- The `cdylib` exports TinyBus module ABI v1, an embedded manifest, and the - initialization entrypoint. -- The example module provides `ai.tinyhumans.template.Greeting.Greet` at - `/ai/tinyhumans/template/Greeting`. -- Each release archive is named - `template--.` and contains only this - module, its SHA-256 `modules.toml`, license, and installation documentation. -- Each GitHub release publishes a separate `checksum.toml` mapping every - archive filename to its SHA-256 digest for TinyBus's release loader. -- Release builds cover the stable native Ubuntu, macOS, and Windows runners, - Fedora 43/44 containers, and rolling Arch Linux where official runners or - images exist for the architecture. -- TinyBus itself remains a pinned SDK submodule and is not shipped as a release - asset from this repository. - -## Verification - -CI exercises the bus interface through TinyBus's in-memory transport, enforces -90% line coverage in every source file, and builds the `cdylib`. The release -workflow builds each native module from the tagged source and records its exact -digest in the adjacent allowlist. After publishing, it downloads the Ubuntu -x86_64 archive through TinyBus's GitHub release API and calls `Greet` over an -in-memory bus. diff --git a/skills-lock.json b/skills-lock.json new file mode 100644 index 0000000..734aa12 --- /dev/null +++ b/skills-lock.json @@ -0,0 +1,11 @@ +{ + "version": 1, + "skills": { + "typesafe-ai": { + "source": "typesafe-ai/skills", + "sourceType": "github", + "skillPath": "skills/typesafe-ai/SKILL.md", + "computedHash": "9cd84c5e535dec8dec59917c110f9c00b4a61faadb86b432ec7e41051170af12" + } + } +} diff --git a/vendor/tinybus b/vendor/tinybus deleted file mode 160000 index 92b817e..0000000 --- a/vendor/tinybus +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 92b817e42ecb980628166dd31b465524130de2f5