diff --git a/.agents/skills/contribute-api/SKILL.md b/.agents/skills/contribute-api/SKILL.md index d28bd9b2..1642815f 100644 --- a/.agents/skills/contribute-api/SKILL.md +++ b/.agents/skills/contribute-api/SKILL.md @@ -1,6 +1,6 @@ --- name: contribute-api -description: Contribute a new NeMo Fabric public API surface safely, with Rust, CLI, Python, schema, adapter, and documentation parity in mind +description: Contribute a new NVIDIA NeMo Fabric public API surface safely, with Rust, CLI, Python, TypeScript, schema, adapter, and documentation parity in mind author: NVIDIA Corporation and Affiliates license: Apache-2.0 --- @@ -20,8 +20,8 @@ runtime or bindings. ## Default Guidance - Start from the shared Rust core behavior first -- Decide whether the CLI, PyO3 binding, Python SDK, type stubs, schemas, or - adapter contract must expose the new surface +- Decide whether the CLI, PyO3 binding, Python SDK, type stubs, schemas, or the + Python and TypeScript adapter-contract bindings must expose the new surface - Keep every affected public surface in parity - Update docs and examples in the same branch @@ -37,4 +37,5 @@ runtime or bindings. - `validate-change` - `review-doc-style` - `docs/python-sdk-contract.md` +- `schemas/SCHEMA.md` - `justfile` diff --git a/.agents/skills/maintain-ci/SKILL.md b/.agents/skills/maintain-ci/SKILL.md index cb967439..131b9fe7 100644 --- a/.agents/skills/maintain-ci/SKILL.md +++ b/.agents/skills/maintain-ci/SKILL.md @@ -29,6 +29,10 @@ reliability, or reproducibility. `actions/cache`. - Use lockfiles or dependency manifests to drive cache invalidation. - Keep deploy and publish permissions isolated to the jobs that need them. +- Publish the TypeScript contract from the dedicated + `publish_typescript.yml` workflow through the protected `npmjs` environment. + Grant `id-token: write` for npm trusted publishing, and do not provide an npm + write token that could mask an OIDC configuration failure. - Read both caller and callee when a workflow uses `workflow_call`. - Keep documentation publish and preview credentials isolated to the Fern docs workflow. @@ -42,8 +46,11 @@ reliability, or reproducibility. - `contents: read` is the default minimum for checkout-based build, test, docs, and packaging jobs. - `pull-requests: read` is required for PR metadata lookup jobs. -- `pages: write` and `id-token: write` should be limited to Pages deployment - jobs and any caller that invokes them through a reusable workflow. +- `pages: write` should be limited to Pages deployment jobs and any caller that + invokes them through a reusable workflow. +- `id-token: write` should be limited to jobs that exchange a GitHub OIDC token + with a protected deployment target, including Pages deployment and the + protected npm publication job. - For reusable workflows, the caller must grant every permission the called jobs require. The callee cannot elevate beyond what the caller provides. @@ -93,6 +100,8 @@ source instead of assuming local success proves remote success. - `.github/workflows/fern-docs.yml` - `.github/workflows/nightly-alpha-tag.yml` - `.github/workflows/publish_rust.yml` +- `.github/workflows/publish_typescript.yml` +- `scripts/ci/publish_typescript_package.py` - `.gitlab-ci.yml` - `RELEASING.md` - `Cargo.lock` diff --git a/.agents/skills/maintain-packaging/SKILL.md b/.agents/skills/maintain-packaging/SKILL.md index 66cd7e32..8beb9fc8 100644 --- a/.agents/skills/maintain-packaging/SKILL.md +++ b/.agents/skills/maintain-packaging/SKILL.md @@ -1,6 +1,6 @@ --- name: maintain-packaging -description: Maintain NeMo Fabric Rust and Python dependencies, package metadata, module paths, native artifacts, lockfiles, license evidence, and release-facing build surfaces +description: Maintain NVIDIA NeMo Fabric Rust, Python, and TypeScript dependencies, package metadata, module paths, native artifacts, lockfiles, license evidence, and release-facing build surfaces author: NVIDIA Corporation and Affiliates license: Apache-2.0 --- @@ -24,9 +24,13 @@ consumed outside the source tree. - Python package metadata in `python/pyproject.toml` - Native extension naming and placement under `python/src/nemo_fabric` - Dependency resolution in `Cargo.lock` and `uv.lock` +- TypeScript adapter-contract metadata and dependency resolution in + `typescript/adapter-contract/package.json` and `package-lock.json` - Documentation tooling metadata in `docs/package.json` and `docs/package-lock.json` - CI workflows, install commands, and example commands +- npm trusted publishing through `.github/workflows/publish_typescript.yml` and + the protected `npmjs` environment - `justfile` build, test, clean, and documentation recipes - Release tags, registry publication, and release-facing documentation in `RELEASING.md` @@ -59,6 +63,10 @@ commitment. `uv run --no-project python scripts/licensing/license_diff.py --base-ref origin/main` after updating manifests and lockfiles, then review added packages and license changes. +- For `typescript/adapter-contract/package-lock.json`, inspect the resolved + package entries and their `license` fields. The adapter-contract package must + keep an empty production dependency graph; build-only dependencies still + require permissive, recorded license evidence. - Regenerate the attribution files with the named pre-commit hooks instead of editing generated output: @@ -78,6 +86,8 @@ compatibility decisions using the distribution and linkage context. - [ ] CI references the same package names as local workflows - [ ] Public packaging changes are reflected in release-facing docs - [ ] Workspace, Python, and lockfile versions remain aligned where required +- [ ] The TypeScript adapter-contract package version follows the workspace + release version without changing its independent wire contract version - [ ] The editable maturin build still produces `nemo_fabric._native` - [ ] New dependencies are necessary, maintained, and narrower than the viable alternatives @@ -96,6 +106,11 @@ compatibility decisions using the distribution and linkage context. - `uv.lock` - `docs/package.json` - `docs/package-lock.json` +- `typescript/adapter-contract/package.json` +- `typescript/adapter-contract/package-lock.json` +- `.github/workflows/ci_typescript.yml` +- `.github/workflows/publish_typescript.yml` +- `scripts/ci/publish_typescript_package.py` - `.github/workflows/ci_python.yml` - `.github/workflows/ci_rust.yml` - `.pre-commit-config.yaml` diff --git a/.agents/skills/update-project-version/SKILL.md b/.agents/skills/update-project-version/SKILL.md index bd8fb93d..33df5e11 100644 --- a/.agents/skills/update-project-version/SKILL.md +++ b/.agents/skills/update-project-version/SKILL.md @@ -1,6 +1,6 @@ --- name: update-project-version -description: Update the NeMo Fabric release version across Cargo, setuptools package metadata, internal Python dependency pins, integration metadata, and lockfiles. Use when bumping, synchronizing, or auditing NeMo Fabric package versions for a release. +description: Update the NVIDIA NeMo Fabric release version across Cargo, Python, and TypeScript package metadata, internal Python dependency pins, integration metadata, and lockfiles. Use when bumping, synchronizing, or auditing NeMo Fabric package versions for a release. author: NVIDIA Corporation and Affiliates license: Apache-2.0 --- @@ -20,7 +20,8 @@ pre-release or build-metadata variants used during packaging. ## Source Of Truth - `Cargo.toml` `[workspace.package].version` is the source of truth for the Rust - workspace and Python build versioning. + workspace and the release version stamped into Python and TypeScript package + metadata. - Keep `Cargo.toml` `[workspace.dependencies]` self-references aligned when the workspace version changes. - `python/pyproject.toml` is the exception among the Python projects: do not add @@ -36,6 +37,8 @@ pre-release or build-metadata variants used during packaging. - All `nemo-fabric-* == ` requirements in the root `pyproject.toml` optional dependencies. - Each adapter's `nemo-fabric-adapters-common == ` dependency. +- Keep `typescript/adapter-contract/package.json` and the root package entries in + its `package-lock.json` aligned with the Cargo SemVer release version. For a normal release, use the same `X.Y.Z` string everywhere. For a prerelease or build-metadata version, use valid Cargo SemVer in `Cargo.toml` and the @@ -45,15 +48,17 @@ versions rather than blindly copying incompatible syntax. ## Workflow -1. Read the current version from `Cargo.toml` and decide the exact Cargo and - Python target version strings. -2. Run `just set-version `. The recipe converts supported Cargo - SemVer prereleases to PEP 440 and updates: +1. Read the current version from `Cargo.toml` and decide the exact Cargo, + Python, and TypeScript target version strings. +2. Run `just set-version `. The recipe preserves the normalized + SemVer for Cargo and TypeScript, converts it to PEP 440 for Python, and + updates: - `Cargo.toml` `[workspace.package].version` - `Cargo.toml` `workspace.dependencies.nemo-fabric-core.version` - The root setuptools `project.version` and every `adapters/**/pyproject.toml` `project.version` - Every internal `nemo-fabric-*` exact-version requirement + - The TypeScript adapter-contract `package.json` and `package-lock.json` - `Cargo.lock` through Cargo metadata resolution - The root, runtime, and adapter `uv.lock` files through `just lock-python` 3. Confirm that `python/pyproject.toml` remains dynamic and unchanged. @@ -62,7 +67,8 @@ versions rather than blindly copying incompatible syntax. If editing the helper code, keep these contracts aligned: -- `set_project_version` must call the Cargo and Python project version helpers. +- `set_project_version` must call the Cargo, Python, and TypeScript project + version helpers. - `set_cargo_workspace_version` must update the workspace version and the `nemo-fabric-core` workspace dependency, then verify every `nemo-fabric-*` workspace package through Cargo metadata. @@ -70,6 +76,8 @@ If editing the helper code, keep these contracts aligned: adapter `pyproject.toml` discovered recursively under `adapters/`, and all internal exact-version pins while rejecting a static version in `python/pyproject.toml`. +- `set_typescript_project_version` must update the package manifest and both + root version entries in the npm lockfile without changing dependency versions. - The `set-version` recipe must run `just lock-python` after source metadata is updated. @@ -81,11 +89,14 @@ If editing the helper code, keep these contracts aligned: `rg -n '^version =|nemo-fabric-[a-z-]+ == ' pyproject.toml adapters --glob 'pyproject.toml'` - Confirm the runtime remains dynamic: `rg -n 'dynamic = \["version"\]' python/pyproject.toml` +- Inspect the TypeScript package and lockfile root versions: + `rg -n '"version":' typescript/adapter-contract/package{,-lock}.json` - Run `cargo check --workspace --locked`. - Run `just build-python` to verify all Python package metadata resolves. - Run `just test-python` when the integration version or Python packaging behavior changes materially. - Run `just wheels` for release-facing validation of every Python wheel. +- Run `just pack-typescript` to verify the stamped TypeScript package metadata. - Run `git diff --check`. ## Avoid @@ -94,6 +105,7 @@ If editing the helper code, keep these contracts aligned: - Adding a literal version to `python/pyproject.toml`; Maturin owns that version. - Updating Python package versions without their exact internal dependency pins. - Forgetting `Cargo.lock`, the root `uv.lock`, or per-project `uv.lock` files. +- Updating the TypeScript package manifest without its npm lockfile root entry. - Blind repository-wide replacement of version-like strings. ## References @@ -106,4 +118,6 @@ If editing the helper code, keep these contracts aligned: - `python/uv.lock` - `adapters/**/pyproject.toml` - `adapters/**/uv.lock` +- `typescript/adapter-contract/package.json` +- `typescript/adapter-contract/package-lock.json` - `justfile` diff --git a/.agents/skills/validate-change/SKILL.md b/.agents/skills/validate-change/SKILL.md index 38c73e90..9cff0ac7 100644 --- a/.agents/skills/validate-change/SKILL.md +++ b/.agents/skills/validate-change/SKILL.md @@ -23,6 +23,8 @@ surfaces touched by a change. test pass. - If Rust code changed, run `cargo fmt --all -- --check` and `just test-rust`. - If Python code or a Python-facing adapter changed, run `just test-python`. +- If the TypeScript adapter contract or one of its source schemas changed, run + `just test-typescript`. - If `crates/fabric-core` changed in a way exposed through Python, run both the Rust and Python suites. - If the PyO3 bridge or package metadata changed, run `just build-python` and @@ -30,10 +32,13 @@ surfaces touched by a change. - If public configuration types changed, confirm the schema snapshot tests in `just test-rust` pass and review generated schema diffs. - If an adapter or integration changed, run its focused tests. -- If a manifest or lockfile changed, run +- If a Cargo or Python manifest or lockfile changed, run `uv run --no-project python scripts/licensing/license_diff.py --base-ref origin/main`, review the transitive license changes, then run the `attributions-rust` and `attributions-python` pre-commit hooks. +- If the TypeScript manifest or npm lockfile changed, inspect the complete npm + dependency tree and license fields, confirm the package still has zero + production dependencies, and run its package and audit checks. - If documentation or examples changed, run `just docs` when practical and verify documented commands against the current repository. - If code changes alter APIs, commands, paths, packaging behavior, telemetry @@ -55,8 +60,9 @@ surfaces touched by a change. - **Harbor integration changed** Run `tests/test_harbor_runner.py`, then `just test-python`. - **Schema or public contract changed** - Run both language suites and review changes under `schemas/` and generated API - references. + Run the Rust, Python, and TypeScript suites and review changes under + `schemas/`, the checked-in Python adapter-contract representations, generated + TypeScript sources, and generated API references. - **Documentation-only change** Use `contribute-docs` and `review-doc-style`. Run `just docs` for docs-site or generated-reference changes. @@ -69,6 +75,7 @@ surfaces touched by a change. ```bash just test-rust just test-python +just test-typescript ``` ## Common Targeted Commands @@ -85,6 +92,11 @@ just build-python just test-python uv run --no-sync pytest -k "" +# TypeScript adapter contract +just build-typescript +just test-typescript +just pack-typescript + # Documentation just docs @@ -114,5 +126,7 @@ Before review or handoff: - Build and test recipes: `justfile` - Python CI: `.github/workflows/ci_python.yml` - Rust CI: `.github/workflows/ci_rust.yml` +- TypeScript CI: `.github/workflows/ci_typescript.yml` - Documentation CI: `.github/workflows/fern-docs.yml` - Public Python contract: `docs/python-sdk-contract.md` +- Public adapter contract: `schemas/SCHEMA.md` diff --git a/.github/workflows/ci_typescript.yml b/.github/workflows/ci_typescript.yml new file mode 100644 index 00000000..5755c94f --- /dev/null +++ b/.github/workflows/ci_typescript.yml @@ -0,0 +1,49 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +name: TypeScript + +on: + pull_request: + push: + branches: [main] + +concurrency: + group: ci-typescript-${{ github.ref }} + cancel-in-progress: true + +defaults: + run: + shell: bash + +jobs: + test: + name: Test (Node ${{ matrix.node-version }}) + runs-on: ubuntu-24.04 + timeout-minutes: 15 + permissions: + contents: read + strategy: + fail-fast: false + matrix: + node-version: ['20.18.3', '24'] + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + persist-credentials: false + + - name: Set up Node.js + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6 + with: + node-version: ${{ matrix.node-version }} + cache: npm + cache-dependency-path: typescript/adapter-contract/package-lock.json + + - name: Install just + uses: taiki-e/install-action@c070f87102a1c75b3183910f391c1cb887fe13c8 # v2.77.6 + with: + tool: just@1.50.0 + + - name: Test TypeScript contract package + run: just test-typescript diff --git a/.github/workflows/publish_typescript.yml b/.github/workflows/publish_typescript.yml new file mode 100644 index 00000000..0a9c67fc --- /dev/null +++ b/.github/workflows/publish_typescript.yml @@ -0,0 +1,122 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +name: Publish TypeScript package + +on: + push: + tags: + - 'v*' + - '!v*-alpha*' + +concurrency: + group: publish-typescript + cancel-in-progress: false + +defaults: + run: + shell: bash + +jobs: + publish-typescript: + name: Publish (npmjs.com) + # Stable, beta, and RC tags publish. Alpha tags remain artifact-only. + if: ${{ github.ref_type == 'tag' && !contains(github.ref_name, '-alpha') }} + runs-on: ubuntu-24.04 + timeout-minutes: 15 + permissions: + # actions/checkout needs read access to the tagged repository contents. + contents: read + # npm trusted publishing exchanges this job's GitHub OIDC token for a + # short-lived registry credential; no long-lived npm token is provided. + id-token: write + environment: npmjs + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + persist-credentials: false + + - name: Set up Node.js + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6 + with: + node-version: '24' + package-manager-cache: false + + - name: Install just + uses: taiki-e/install-action@c070f87102a1c75b3183910f391c1cb887fe13c8 # v2.77.6 + with: + tool: just@1.50.0 + + - name: Validate trusted publishing toolchain + run: | + set -euo pipefail + + node --version + npm_version="$(npm --version)" + echo "$npm_version" + NPM_VERSION="$npm_version" node <<'NODE' + const actual = process.env.NPM_VERSION.split('.').map(Number); + const minimum = [11, 5, 1]; + const comparison = actual.findIndex( + (part, index) => part !== minimum[index], + ); + + if ( + actual.length !== minimum.length || + actual.some(Number.isNaN) || + (comparison !== -1 && actual[comparison] < minimum[comparison]) + ) { + throw new Error('npm 11.5.1 or newer is required for trusted publishing'); + } + NODE + + - name: Prepare release metadata + id: release + env: + RELEASE_TAG: ${{ github.ref_name }} + run: | + set -euo pipefail + + version="$(python3 scripts/ci/normalize_release_tag.py "$RELEASE_TAG")" + if [[ "$version" == *+* ]]; then + echo "npm publication does not support build metadata: $version" >&2 + exit 1 + fi + + case "$version" in + *-alpha*) + echo "Alpha releases are not published to npm: $version" >&2 + exit 1 + ;; + *-beta*|*-rc*) dist_tag="next" ;; + *-*) + echo "Unsupported npm prerelease: $version" >&2 + exit 1 + ;; + *) dist_tag="latest" ;; + esac + + python3 scripts/ci/set_typescript_project_version.py "$version" + git diff --exit-code -- \ + typescript/adapter-contract/package.json \ + typescript/adapter-contract/package-lock.json + { + echo "version=$version" + echo "dist_tag=$dist_tag" + } >> "$GITHUB_OUTPUT" + + - name: Test package + run: just test-typescript + + - name: Publish package + env: + NPM_CONFIG_REGISTRY: https://registry.npmjs.org + RELEASE_VERSION: ${{ steps.release.outputs.version }} + RELEASE_DIST_TAG: ${{ steps.release.outputs.dist_tag }} + run: | + set -euo pipefail + python3 scripts/ci/publish_typescript_package.py \ + --package-directory typescript/adapter-contract \ + --version "$RELEASE_VERSION" \ + --dist-tag "$RELEASE_DIST_TAG" diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 89886fde..61087a10 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -9,7 +9,7 @@ repos: name: copyright header entry: python3 scripts/lint/check_copyright.py language: system - files: '\.(rs|py|pyi|toml|yaml|yml|md|mdx|sh)$|\.gitignore$' + files: '\.(rs|py|pyi|toml|yaml|yml|md|mdx|sh|bash|html|css|scss|js|jsx|mjs|ts|tsx)$|\.gitignore$' exclude: '(/SKILL\.md|node_modules/|target/|\.venv/|^\.github/pull_request_template\.md)$' # Python lint — enforce the flake8-bugbear cached-instance-method rule (B019) diff --git a/AGENTS.md b/AGENTS.md index 3965df66..105ef2cf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -48,7 +48,12 @@ These workflow notes keep public documentation, examples, and PR preparation ali with repository expectations. - Update user-facing entry points when public behavior, the `nemo-fabric` package (imported as `nemo_fabric`), examples, or supported bindings change: `README.md`, the Fern docs under `docs/` (navigation in `docs/index.yml`, site config in `fern/docs.yml`), and the adapter/integration READMEs (`adapters/*/README.md`, `python/src/nemo_fabric/integrations/*/README.md`, `examples/README.md`). -- Keep the Python/Rust binding contract current when the public API changes: `docs/sdk/python.mdx`, the JSON Schema notes in `schemas/SCHEMA.md`, the generated references under `docs/reference/api/`, and the integration skills under `skills/` (which restate public contracts and must be kept in parity). Regenerate docs with `just docs` after changing the docs site. +- Keep public bindings current when the API changes: `docs/sdk/python.mdx` for + the Python SDK; `adapter-contract/` and `typescript/adapter-contract/` for the + southbound adapter contract; the JSON Schema notes in `schemas/SCHEMA.md`; + the generated references under `docs/reference/api/`; and the integration + skills under `skills/` (which restate public contracts and must be kept in + parity). Regenerate docs with `just docs` after changing the docs site. - Keep release policy and the end-to-end maintainer workflow in `RELEASING.md`; keep packaging implementation guidance in `.agents/skills/maintain-packaging/SKILL.md`. Do not move release-history policy into user-facing docs or add a duplicate `CHANGELOG.md`. - Keep the stable public wrapper `scripts/generate_api_docs.sh` at the `scripts/` root in docs and examples. Reference namespaced helper paths under `scripts/docs/` only when documenting internal maintenance work. - Use branch prefixes for your work: `feat/`, `fix/`, `docs/`, `test/`, or `refactor/`. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8ac5816f..6f6387f4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -19,11 +19,12 @@ Install these tools before you start: - **Rust** (stable toolchain) -- install with [rustup](https://rustup.rs/) - **Python** >= 3.11 +- **Node.js** >= 20.18.3 with npm - **uv** -- follow the [uv installation guide](https://docs.astral.sh/uv/getting-started/installation/) - **just** >= 1.50.0 -- `cargo install just --locked` -Clone the repository, create a virtual environment, and build the Rust and -Python packages: +Clone the repository, create a virtual environment, and build the Rust, +Python, and TypeScript packages: ```bash git clone https://github.com/NVIDIA/NeMo-Fabric.git @@ -99,17 +100,25 @@ Follow the existing style in the Python SDK, adapters, examples, and tests. Use type annotations for public APIs and keep native binding declarations in sync with their Rust implementations. +### TypeScript + +Use strict TypeScript for the adapter-contract binding. Preserve the JSON wire +property names, run the checked-in generator instead of editing generated +declarations, and keep production dependencies out of the contract package. + ### General -Use the naming conventions appropriate to each language: Rust and Python use -`snake_case` for functions and variables, Rust types use `PascalCase`, and -Python classes use `PascalCase`. +Use the naming conventions appropriate to each language. Rust and Python use +`snake_case` for functions and variables. Rust, Python, and TypeScript types use +`PascalCase`. TypeScript contract properties preserve the wire `snake_case` +names. ## Testing **Run tests for every language surface affected by your changes.** If a change -touches the Rust core or public schemas, run both the Rust and Python suites -because the Python SDK and adapters depend on the native core contract. +touches the Rust core or public adapter-contract schemas, run the Rust, Python, +and TypeScript suites because both language bindings depend on the generated +wire contract. Run the affected test targets through the repository `justfile`: @@ -120,7 +129,10 @@ just test-rust # Python SDK, adapters, integrations, and examples just test-python -# Both suites +# TypeScript adapter contract +just test-typescript + +# All supported language surfaces just test-all ``` @@ -133,8 +145,9 @@ just no_uv=true test-all ``` When adding functionality, include tests in the corresponding Rust crate or in -the relevant area under `tests/`. Public contract changes must keep the checked-in -JSON Schema snapshots and native Python binding declarations synchronized. +the relevant area under `tests/`. Public contract changes must keep the +checked-in JSON Schema snapshots, Python representations, and generated +TypeScript declarations synchronized. ## Documentation Checklist diff --git a/README.md b/README.md index 5ff881a4..e8f5076c 100644 --- a/README.md +++ b/README.md @@ -261,13 +261,16 @@ through adapters. Use the following reference to compare the integrations: - [Adapter compatibility and guides](adapters/README.md): compare bundled harness support, runtime ownership, telemetry integration, and package guides. +- [Adapter contract](docs/adapter-contract/README.md): build third-party + adapters against the canonical schemas or the dependency-free Python and + TypeScript contract bindings. ## Roadmap -- **Custom harnesses:** Publish the NeMo Fabric adapter contract so third-party - developers can build integrations that are compatible with NeMo Fabric. - Support integrations maintained by NeMo Fabric and compatible third-party - integrations. +- **Custom harnesses:** Publish the NeMo Fabric adapter contract as canonical + schemas and dependency-free language bindings so third-party developers can + build integrations that are compatible with NeMo Fabric. Support integrations + maintained by NeMo Fabric and compatible third-party integrations. - **Custom agents:** Support custom agents built on maintained or third-party harness integrations without requiring an additional, agent-specific adapter. Preserve the normalized NeMo Fabric lifecycle, results, artifacts, and diff --git a/RELEASING.md b/RELEASING.md index 8a43df09..f37339cd 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -31,6 +31,7 @@ The release pipeline publishes these package surfaces from a tag push: | Ecosystem | Published Surface | |---|---| | crates.io | `nemo-fabric-core`, `nemo-fabric-cli` | +| npm | `nemo-fabric-adapter-contract` | | GitHub Actions | `nemo-fabric`, `nemo-fabric-runtime`, `nemo-fabric-adapters-common`, `nemo-fabric-adapters-claude`, `nemo-fabric-adapters-codex`, `nemo-fabric-adapters-deepagents`, and `nemo-fabric-adapters-hermes` wheel artifacts | | Fern | The documentation site | @@ -46,6 +47,10 @@ NeMo Fabric versions are anchored on the workspace SemVer in the repository root - The root `pyproject.toml` and every `adapters/**/pyproject.toml` carry the Python package versions and internal dependency pins and must stay aligned with the same release version. +- `typescript/adapter-contract/package.json` and its lockfile carry the npm + adapter-contract package version and must stay aligned with the same release + version. The package version is independent of the + `fabric.adapter/v1alpha2` wire contract version. - The `nemo-fabric-runtime` Python package version is derived at packaging time. `python/pyproject.toml` stays `dynamic = ["version"]` in the repository, and Maturin derives the version from `crates/fabric-python/Cargo.toml`, which @@ -143,8 +148,11 @@ The helper updates: `nemo-fabric-core`. 3. [`pyproject.toml`](pyproject.toml), every `adapters/**/pyproject.toml`, and their internal dependency pins to the same release version. -4. [`Cargo.lock`](Cargo.lock), [`uv.lock`](uv.lock), and every Python project +4. [`typescript/adapter-contract/package.json`](typescript/adapter-contract/package.json) + and its npm lockfile. +5. [`Cargo.lock`](Cargo.lock), [`uv.lock`](uv.lock), and every Python project lockfile. + Review docs and snippets that mention explicit versions, including: - [`README.md`](README.md) @@ -164,6 +172,7 @@ repository release, the safest baseline is: uv run pre-commit run --all-files just test-rust just test-python +just test-typescript just docs ``` @@ -179,6 +188,65 @@ place. In a disposable CI workspace that is fine. In a local checkout, restore those temporary manifest edits before continuing if you are not committing them. +## Bootstrap npm Trusted Publishing + +The npm package must exist before npm can bind it to a GitHub trusted publisher. +`nemo-fabric-adapter-contract@0.0.0` was published once as an inert registry +bootstrap. It contains only the package metadata, README, and Apache-2.0 +license; the first supported contract release must be published by the trusted +GitHub Actions workflow. Do not publish or tag `0.0.0` again. npm versions are +immutable. + +The bootstrap was published with `--tag next`. The registry also initialized +`latest` to `0.0.0` because it was the package's first version and rejected +removing that only `latest` tag. Leave both tags on the inert bootstrap: RC and +beta releases move `next`, and the first stable release replaces `latest`. + +Before the first supported TypeScript package release: + +1. Add at least one more NVIDIA maintainer to the package so registry + administration does not depend on the bootstrap publisher's account: + + ```bash + npm owner add nemo-fabric-adapter-contract + ``` + + npm sends each new maintainer an email invitation. The maintainer must accept + it before access is granted. Verify every expected account appears in + `npm owner ls nemo-fabric-adapter-contract` before the release. Maintainers + must use account-level two-factor authentication. +2. Create and protect the GitHub `npmjs` environment. Require the release + approvers who should authorize registry publication, and restrict deployment + tags to `v*`. +3. After `publish_typescript.yml` is present on the default branch, configure + the package's single trusted publisher in npm with these exact, + case-sensitive values: + + - Organization or user: `NVIDIA` + - Repository: `NeMo-Fabric` + - Workflow filename: `publish_typescript.yml` + - Environment: `npmjs` + - Allowed action: `npm publish` + +4. Cut the first genuine release tag and approve the `npmjs` environment when + prompted. The workflow tests, packs, and publishes that release through OIDC; + do not manually pre-publish the release version. On a retry, the workflow + exits without republishing only when its repack has byte-identical integrity + and the expected dist-tag is exact. A mismatch fails closed because npm + versions are immutable; do not overwrite or weaken the check. +5. Confirm the release's provenance on npm. + In the npm package settings, require two-factor authentication and disallow + token publication. Then remove or revoke any local credentials used for the + bootstrap. + +The workflow publishes stable versions with the `latest` dist-tag and beta or +RC versions with `next`. Alpha versions are not published. A retry skips only +when the immutable package version, packed artifact integrity, and expected +dist-tag all match. If any of them differs, the workflow fails so a maintainer +can inspect and repair the registry state explicitly. Publication also fails +rather than moving `latest` or `next` backward when cutting a patch from an +older release line. + ## Cut An RC Tag After the release commit is merged and validated, create and push a signed, @@ -312,6 +380,7 @@ Pushing a valid tag triggers : |---|---| | [`.github/workflows/ci_python.yml`](.github/workflows/ci_python.yml) | For all tags including alpha | | [`.github/workflows/publish_rust.yml`](.github/workflows/publish_rust.yml) | For RC, beta and release tags | +| [`.github/workflows/publish_typescript.yml`](.github/workflows/publish_typescript.yml) | For RC, beta and release tags | | [`.github/workflows/fern-docs.yml`](.github/workflows/fern-docs.yml) | For RC, beta and release tags | The release pipeline then: @@ -324,7 +393,11 @@ The release pipeline then: 3. Publishes `nemo-fabric-core` and `nemo-fabric-cli` to crates.io through trusted publishing for stable, beta, and RC tags. Alpha tags are not published to crates.io. -4. Publishes Fern documentation versions for stable, beta, and RC tags. Alpha +4. Publishes `nemo-fabric-adapter-contract` to npm through trusted + publishing for stable, beta, and RC tags. Stable releases use the `latest` + dist-tag; beta and RC releases use `next`. Alpha tags are not published to + npm. +5. Publishes Fern documentation versions for stable, beta, and RC tags. Alpha tags do not publish a separate documentation version. The workflow boundary is split intentionally: @@ -335,6 +408,9 @@ The workflow boundary is split intentionally: and publishes Fern documentation independently from package CI. - [`.github/workflows/publish_rust.yml`](.github/workflows/publish_rust.yml) owns crates.io publication decisions and credentials. +- [`.github/workflows/publish_typescript.yml`](.github/workflows/publish_typescript.yml) + owns npm publication decisions and requests a short-lived npm credential + through GitHub OIDC. It does not receive an npm write token. ## Publish The GitHub Release Entry @@ -372,5 +448,27 @@ After the release is live, verify: - [`nemo-fabric-adapters-codex`](https://pypi.nvidia.com/nemo-fabric-adapters-codex/) - [`nemo-fabric-adapters-deepagents`](https://pypi.nvidia.com/nemo-fabric-adapters-deepagents/) - [`nemo-fabric-adapters-hermes`](https://pypi.nvidia.com/nemo-fabric-adapters-hermes/) -4. The Fern documentation site shows the expected version and release notes. -5. The GitHub Release page is complete and accurate. +4. The TypeScript contract package is visible on npm with the expected version, + dist-tag, and provenance: + + ```bash + ( + set -euo pipefail + npmjs_registry="https://registry.npmjs.org/" + npm view "nemo-fabric-adapter-contract@" version \ + --registry="$npmjs_registry" + npm view "nemo-fabric-adapter-contract" dist-tags \ + --registry="$npmjs_registry" + verification_dir="$(mktemp -d)" + trap 'rm -rf "$verification_dir"' EXIT + cd "$verification_dir" + npm init --yes --registry="$npmjs_registry" + npm install --ignore-scripts --save-exact \ + --registry="$npmjs_registry" \ + "nemo-fabric-adapter-contract@" + npm audit signatures --registry="$npmjs_registry" + ) + ``` + +5. The Fern documentation site shows the expected version and release notes. +6. The GitHub Release page is complete and accurate. diff --git a/crates/fabric-core/src/agent_config.rs b/crates/fabric-core/src/agent_config.rs index b17a753a..aa6b256f 100644 --- a/crates/fabric-core/src/agent_config.rs +++ b/crates/fabric-core/src/agent_config.rs @@ -13,6 +13,7 @@ use serde_json::Value; use crate::config::{ AdapterConfigField, AdapterDescriptor, CapabilityPlan, FabricConfig, InstructionMode, }; +use crate::error::{FabricError, Result}; /// Configuration projected southbound to one adapter target. #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)] @@ -64,10 +65,10 @@ pub struct AgentHarnessConfig { #[serde(deny_unknown_fields)] pub struct AgentModelConfig { /// Model provider identifier. - #[schemars(length(min = 1))] + #[schemars(length(min = 1), regex(pattern = r"\S"))] pub provider: String, /// Provider model identifier. - #[schemars(length(min = 1))] + #[schemars(length(min = 1), regex(pattern = r"\S"))] pub model: String, /// Environment variable containing the provider credential. #[serde(default, skip_serializing_if = "Option::is_none")] @@ -242,6 +243,80 @@ pub struct AgentWorkflowConfig { pub extensions: BTreeMap, } +/// Validate a projected adapter configuration before runtime handoff. +pub(crate) fn validate_agent_config(config: &AgentConfig) -> Result<()> { + for (role, model) in &config.models { + require_non_blank( + format!("agent_config.models.{role}.provider"), + &model.provider, + )?; + require_non_blank(format!("agent_config.models.{role}.model"), &model.model)?; + } + if let Some(system) = config + .instructions + .as_ref() + .and_then(|instructions| instructions.system.as_ref()) + { + require_non_blank("agent_config.instructions.system.content", &system.content)?; + } + if config + .runtime + .as_ref() + .is_some_and(|runtime| runtime.max_turns == Some(0)) + { + return invalid_agent_config( + "agent_config.runtime.max_turns", + "must be greater than zero", + ); + } + if let Some(mcp) = &config.mcp { + for (name, server) in &mcp.servers { + require_non_blank( + format!("agent_config.mcp.servers.{name}.transport"), + &server.transport, + )?; + require_non_blank(format!("agent_config.mcp.servers.{name}.url"), &server.url)?; + } + } + if let Some(tools) = &config.tools { + for (name, definition) in &tools.definitions { + require_non_blank( + format!("agent_config.tools.definitions.{name}.kind"), + &definition.kind, + )?; + require_non_blank( + format!("agent_config.tools.definitions.{name}.ref"), + &definition.r#ref, + )?; + } + } + if let Some(workflow) = &config.workflow { + require_non_blank( + "agent_config.workflow.entrypoint.kind", + &workflow.entrypoint.kind, + )?; + require_non_blank( + "agent_config.workflow.entrypoint.ref", + &workflow.entrypoint.r#ref, + )?; + } + Ok(()) +} + +fn require_non_blank(field: impl Into, value: &str) -> Result<()> { + if value.trim().is_empty() { + return invalid_agent_config(field, "must contain a non-whitespace character"); + } + Ok(()) +} + +fn invalid_agent_config(field: impl Into, reason: impl Into) -> Result { + Err(FabricError::InvalidConfig { + field: field.into(), + reason: reason.into(), + }) +} + /// Project a resolved northbound config into the selected adapter target contract. pub(crate) fn project_agent_config( config: &FabricConfig, diff --git a/crates/fabric-core/src/agent_execution.rs b/crates/fabric-core/src/agent_execution.rs index b805c7ab..7f9780f4 100644 --- a/crates/fabric-core/src/agent_execution.rs +++ b/crates/fabric-core/src/agent_execution.rs @@ -142,8 +142,8 @@ pub enum AgentRunResultValidationError { /// A successful result included an error. #[error("succeeded result must not include an error")] SucceededWithError, - /// An artifact path was empty, absolute, or contained parent traversal. - #[error("artifact path must be non-empty, relative, and contain no parent traversal: {0}")] + /// An artifact path was blank, absolute, or contained parent traversal. + #[error("artifact path must be non-blank and relative, and contain no parent traversal: {0}")] InvalidArtifactPath(PathBuf), } @@ -174,7 +174,7 @@ impl AgentRunResult { fn is_valid_agent_artifact_path(path: &Path) -> bool { let raw = path.to_string_lossy(); - !raw.is_empty() + raw.chars().any(|character| !character.is_whitespace()) && !path.is_absolute() && !raw.starts_with(['/', '\\']) && !raw @@ -197,7 +197,7 @@ where let path = PathBuf::deserialize(deserializer)?; if !is_valid_agent_artifact_path(&path) { return Err(serde::de::Error::custom( - "artifact path must be non-empty, relative, and contain no parent traversal", + "artifact path must be non-blank and relative, and contain no parent traversal", )); } Ok(path) @@ -206,6 +206,7 @@ where fn agent_artifact_path_schema(generator: &mut SchemaGenerator) -> Schema { let mut schema = String::json_schema(generator); schema.insert("minLength".into(), 1.into()); + schema.insert("pattern".into(), r"\S".into()); schema.insert( "not".into(), serde_json::json!({ @@ -304,6 +305,7 @@ mod tests { fn rejects_unsafe_artifact_paths_during_deserialization() { for path in [ "", + " \t", "/tmp/output", "../output", "nested/../output", diff --git a/crates/fabric-core/src/config.rs b/crates/fabric-core/src/config.rs index 73407cd7..25615adf 100644 --- a/crates/fabric-core/src/config.rs +++ b/crates/fabric-core/src/config.rs @@ -196,13 +196,13 @@ pub struct WorkflowConfig { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] pub struct AdapterDescriptor { /// Adapter descriptor contract version. - #[schemars(length(min = 1))] + #[schemars(schema_with = "adapter_contract_version_schema")] pub contract_version: String, /// Unique id for this adapter implementation. - #[schemars(length(min = 1))] + #[schemars(length(min = 1), regex(pattern = r"\S"))] pub adapter_id: String, /// Stable machine-readable harness identifier implemented by this adapter. - #[schemars(length(min = 1))] + #[schemars(length(min = 1), regex(pattern = r"\S"))] pub harness: String, /// Adapter implementation kind. pub adapter_kind: AdapterKind, @@ -242,6 +242,13 @@ pub struct AdapterDescriptor { pub extensions: BTreeMap, } +fn adapter_contract_version_schema(generator: &mut SchemaGenerator) -> Schema { + let mut schema = String::json_schema(generator); + schema.insert("const".into(), ADAPTER_CONTRACT_VERSION.into()); + schema.insert("minLength".into(), 1.into()); + schema +} + fn adapter_extension_schemas_schema(generator: &mut SchemaGenerator) -> Schema { let mut schema = BTreeMap::>::json_schema(generator); @@ -525,12 +532,25 @@ pub enum AdapterConfigField { pub struct AdapterTelemetrySupport { /// Provider-specific telemetry capabilities supported by this adapter. #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + #[schemars(schema_with = "adapter_telemetry_providers_schema")] pub providers: BTreeMap, /// Additive adapter telemetry fields. #[serde(default, flatten)] pub extensions: BTreeMap, } +fn adapter_telemetry_providers_schema(generator: &mut SchemaGenerator) -> Schema { + let mut schema = + BTreeMap::::json_schema(generator); + schema.insert( + "propertyNames".into(), + serde_json::json!({ + "enum": TelemetryProvider::ALL.map(TelemetryProvider::as_str), + }), + ); + schema +} + /// Telemetry capabilities for one adapter-supported provider. #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)] pub struct AdapterTelemetryProviderSupport { @@ -1171,6 +1191,8 @@ pub enum TelemetryProvider { } impl TelemetryProvider { + const ALL: [Self; 2] = [Self::Relay, Self::Native]; + /// Return the stable configuration value for this provider. pub fn as_str(self) -> &'static str { match self { @@ -2427,7 +2449,7 @@ fn resolve_telemetry_plan( let native_provider = telemetry.providers.get(&TelemetryProvider::Native); let relay = config.relay.as_ref(); let relay_enabled = relay_provider.is_some(); - let providers = [TelemetryProvider::Relay, TelemetryProvider::Native] + let providers = TelemetryProvider::ALL .into_iter() .filter(|provider| telemetry.providers.contains_key(provider)) .collect::>(); diff --git a/crates/fabric-core/src/runtime.rs b/crates/fabric-core/src/runtime.rs index a8c5fb71..d928cdc4 100644 --- a/crates/fabric-core/src/runtime.rs +++ b/crates/fabric-core/src/runtime.rs @@ -19,6 +19,7 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; +use crate::agent_config::validate_agent_config; use crate::config::{ AdapterConfigInput, AdapterKind, AgentConfig, CapabilityPlan, CapabilityTarget, ControlLocation, EnvironmentOwnership, FabricConfig, RunPlan, TelemetryPlan, @@ -557,6 +558,7 @@ pub fn prepare_environment(plan: &RunPlan) -> Result { /// Start or connect to a harness runtime. pub fn start_runtime(plan: &RunPlan) -> Result { validate_config(&plan.config)?; + validate_agent_config(&plan.agent_config)?; validate_harness_settings(&plan.config, plan.adapter_descriptor.as_ref())?; validate_workflow(&plan.config, plan.adapter_descriptor.as_ref())?; validate_adapter_compatibility(plan)?; @@ -2881,6 +2883,30 @@ for line in sys.stdin: let _ = fs::remove_dir_all(root); } + #[test] + fn local_host_revalidates_southbound_config_before_runtime_start() { + for field in ["provider", "model"] { + let (root, plan) = local_host_plan("success"); + let mut serialized = serde_json::to_value(plan).expect("serialize plan"); + serialized["agent_config"]["models"]["primary"] = serde_json::json!({ + "provider": "nvidia", + "model": "test-model" + }); + serialized["agent_config"]["models"]["primary"][field] = + Value::String(" \t".to_string()); + let plan: RunPlan = serde_json::from_value(serialized).expect("deserialize run plan"); + + let error = start_runtime(&plan).expect_err("start must reject blank agent config"); + assert!(matches!( + error, + FabricError::InvalidConfig { field: actual, .. } + if actual == format!("agent_config.models.primary.{field}") + )); + assert!(!root.join("artifacts").exists()); + let _ = fs::remove_dir_all(root); + } + } + #[test] fn local_host_revalidates_workflow_before_runtime_start() { let (root, mut plan) = local_host_plan("success"); diff --git a/crates/fabric-core/src/schema.rs b/crates/fabric-core/src/schema.rs index dae592ee..5c6f6bd1 100644 --- a/crates/fabric-core/src/schema.rs +++ b/crates/fabric-core/src/schema.rs @@ -220,6 +220,7 @@ where #[cfg(test)] mod tests { use super::*; + use crate::config::TelemetryProvider; fn schema_dir() -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../schemas") @@ -310,12 +311,18 @@ mod tests { } #[test] - fn adapter_descriptor_schema_rejects_empty_identifiers() { + fn adapter_descriptor_schema_matches_runtime_constraints() { let schema = generate_schema(SchemaName::AdapterDescriptor).expect("schema generation"); + assert_eq!( + schema["properties"]["contract_version"]["const"], + crate::ADAPTER_CONTRACT_VERSION + ); assert_eq!(schema["properties"]["contract_version"]["minLength"], 1); assert_eq!(schema["properties"]["adapter_id"]["minLength"], 1); + assert_eq!(schema["properties"]["adapter_id"]["pattern"], r"\S"); assert_eq!(schema["properties"]["harness"]["minLength"], 1); + assert_eq!(schema["properties"]["harness"]["pattern"], r"\S"); assert_eq!( schema["properties"]["settings_schema"]["type"], serde_json::json!(["object", "null"]) @@ -351,11 +358,62 @@ mod tests { "usage" ]) ); + let declared_providers = &schema["$defs"]["AdapterTelemetrySupport"]["properties"]["providers"] + ["propertyNames"]["enum"]; + assert_eq!(declared_providers, &serde_json::json!(["relay", "native"])); + let provider_schema = + serde_json::to_value(schema_for!(TelemetryProvider)).expect("provider schema"); + let derived_providers = Value::Array( + provider_schema["oneOf"] + .as_array() + .expect("provider variants") + .iter() + .map(|variant| variant["const"].clone()) + .collect(), + ); + assert_eq!( + declared_providers, &derived_providers, + "TelemetryProvider::ALL must include every enum variant" + ); + + let validator = jsonschema::validator_for(&schema).expect("valid descriptor schema"); + let descriptor = serde_json::json!({ + "contract_version": crate::ADAPTER_CONTRACT_VERSION, + "adapter_id": "test.fabric.schema", + "harness": "schema-test", + "adapter_kind": "python", + "telemetry": { + "providers": { + "relay": {} + } + } + }); + assert!(validator.is_valid(&descriptor)); + + let mut unsupported_contract = descriptor.clone(); + unsupported_contract["contract_version"] = serde_json::json!("fabric.adapter/v1alpha3"); + assert!(!validator.is_valid(&unsupported_contract)); + + let mut unsupported_provider = descriptor.clone(); + unsupported_provider["telemetry"]["providers"] = serde_json::json!({"custom": {}}); + assert!(!validator.is_valid(&unsupported_provider)); + + for field in ["adapter_id", "harness"] { + let mut blank_identifier = descriptor.clone(); + blank_identifier[field] = serde_json::json!(" \t"); + assert!(!validator.is_valid(&blank_identifier)); + } } #[test] fn adapter_contract_schemas_bound_rust_integer_types() { let config = generate_schema(SchemaName::AgentConfig).expect("schema generation"); + for field in ["provider", "model"] { + assert_eq!( + config["$defs"]["AgentModelConfig"]["properties"][field]["pattern"], + r"\S" + ); + } assert_eq!( config["$defs"]["AgentRuntimeConfig"]["properties"]["max_turns"]["maximum"], u32::MAX @@ -385,6 +443,7 @@ mod tests { "error": {"code": "target_error", "message": "target failed"} }))); for path in [ + " \t", "nested/../output", r"nested\..\output", r"C:\tmp\output", diff --git a/docs/adapter-contract/README.md b/docs/adapter-contract/README.md index 13548794..081f1a19 100644 --- a/docs/adapter-contract/README.md +++ b/docs/adapter-contract/README.md @@ -81,3 +81,10 @@ Canonical adapter-facing JSON Schemas are published in the repository [`schemas/adapter-contract/` directory](https://github.com/NVIDIA/NeMo-Fabric/tree/main/schemas/adapter-contract). Python adapters can validate the southbound models with `nemo-fabric-adapter-contract` without depending on the NeMo Fabric runtime. +TypeScript adapters can import the descriptor, configuration, runtime-context, +request, and result types from `nemo-fabric-adapter-contract`, matching the +Python package's single model namespace. The request and result types retain +their documented preview status until the typed invocation transport is +negotiated. The TypeScript declarations provide compile-time structure, not +runtime JSON validation; validate untrusted data against the packaged JSON +Schemas. diff --git a/justfile b/justfile index 3dcd67ce..323fbd45 100644 --- a/justfile +++ b/justfile @@ -249,14 +249,22 @@ set_python_project_versions() { "$python_executable" scripts/ci/set_python_project_versions.py "$version" } +set_typescript_project_version() { + local version="$1" + local python_executable="" + python_executable="$(uv_python_executable)" + "$python_executable" scripts/ci/set_typescript_project_version.py "$version" +} + set_project_version() { local version="$1" set_cargo_workspace_version "$version" set_python_project_versions "$version" + set_typescript_project_version "$version" } ''' -# Remove local Rust and Python build and test artifacts. +# Remove local Rust, Python, and TypeScript build and test artifacts. clean: #!/usr/bin/env bash shopt -s globstar nullglob @@ -271,6 +279,8 @@ clean: **/coverage.xml \ **/dist \ docs/node_modules \ + typescript/adapter-contract/node_modules \ + typescript/adapter-contract/*.tgz \ target/ \ **/build/ @@ -298,8 +308,24 @@ build-python: --reinstall-package nemo-fabric-runtime fi +# Install the TypeScript adapter contract dependencies from the lockfile. +install-typescript: + npm ci --prefix typescript/adapter-contract --ignore-scripts + +# Build the TypeScript adapter contract using the locked dependency set. +build-typescript: install-typescript + npm run build --prefix typescript/adapter-contract + +# Generate the TypeScript adapter contract from the committed JSON Schemas. +generate-typescript-contract: install-typescript + npm run generate --prefix typescript/adapter-contract + +# Verify the TypeScript adapter contract package tarball. +pack-typescript: install-typescript + npm run pack:check --prefix typescript/adapter-contract + # Build all supported language packages. -build-all: build-rust build-python +build-all: build-rust build-python build-typescript # Create or update the lockfile for every Python project. lock-python: @@ -372,8 +398,12 @@ test-python: test-rust: cargo test --workspace --locked -# Run all Rust and Python tests. -test-all: test-rust test-python +# Run the TypeScript adapter contract checks using the locked dependency set. +test-typescript: install-typescript + npm test --prefix typescript/adapter-contract + +# Run all Rust, Python, and TypeScript tests. +test-all: test-rust test-python test-typescript # Build wheels for every Python project into the repository dist directory. wheels: diff --git a/schemas/SCHEMA.md b/schemas/SCHEMA.md index 5f954dcd..e3139446 100644 --- a/schemas/SCHEMA.md +++ b/schemas/SCHEMA.md @@ -8,10 +8,12 @@ SPDX-License-Identifier: Apache-2.0 This directory contains committed JSON Schema snapshots for the public NeMo Fabric contract. The files are generated from the Rust core types, not edited by hand. -The Python SDK exposes Pydantic authoring models for application callers. Those -models are hand-maintained against these Rust-generated schemas for now. When a -schema-backed Rust type changes, update the matching Pydantic model and its -schema-alignment tests in the same change. +The Python adapter-contract package exposes dependency-free dataclasses with +optional Pydantic interoperability. Those models are hand-maintained against +these Rust-generated schemas. The TypeScript adapter-contract package generates +compile-time declarations from the committed schema snapshots. When a +schema-backed Rust type changes, update each applicable language binding and +its parity tests in the same change. ## Directory Layout @@ -35,6 +37,17 @@ An adapter author can treat `adapter-contract/` as the complete schema entry point. The `legacy/` subdirectory contains only the transitional local-host payload used while first-party adapters migrate to the typed execution types. +The language bindings preserve this boundary: + +- Python adapters use `nemo-fabric-adapter-contract` for dependency-free + dataclasses and optional Pydantic models. +- TypeScript adapters use `nemo-fabric-adapter-contract` for the descriptor, + configuration, runtime-context, request, and result types, matching the + Python package's single model namespace. Request and result types retain + their documented preview status until the typed invocation transport is + negotiated. The package also includes these canonical schemas for runtime + validation without selecting a validation-library dependency. + `FabricConfig` is the northbound source of consumer intent. Planning produces the `CapabilityPlan` as routed evidence and projects the fields accepted by the selected descriptor into `AgentConfig`, the authoritative southbound adapter @@ -122,3 +135,13 @@ To add a new schema-backed typed model: Run `cargo test` after regenerating schemas. The snapshot tests compare the committed files against the schemas generated from the current Rust types and fail on accidental drift. + +Regenerate the TypeScript projection after an intentional adapter-contract +schema change: + +```bash +just generate-typescript-contract +``` + +Run `just test-typescript` to check generated-file drift, strict compile-time +fixtures, package contents, and clean-consumer imports. diff --git a/schemas/adapter-contract/adapter-descriptor.schema.json b/schemas/adapter-contract/adapter-descriptor.schema.json index 682b784e..f7553d98 100644 --- a/schemas/adapter-contract/adapter-descriptor.schema.json +++ b/schemas/adapter-contract/adapter-descriptor.schema.json @@ -198,6 +198,12 @@ "$ref": "#/$defs/AdapterTelemetryProviderSupport" }, "description": "Provider-specific telemetry capabilities supported by this adapter.", + "propertyNames": { + "enum": [ + "relay", + "native" + ] + }, "type": "object" } }, @@ -242,6 +248,7 @@ "adapter_id": { "description": "Unique id for this adapter implementation.", "minLength": 1, + "pattern": "\\S", "type": "string" }, "adapter_kind": { @@ -266,6 +273,7 @@ "description": "NeMo Fabric config areas this adapter consumes or generates." }, "contract_version": { + "const": "fabric.adapter/v1alpha2", "description": "Adapter descriptor contract version.", "minLength": 1, "type": "string" @@ -303,6 +311,7 @@ "harness": { "description": "Stable machine-readable harness identifier implemented by this adapter.", "minLength": 1, + "pattern": "\\S", "type": "string" }, "model_schema": { diff --git a/schemas/adapter-contract/agent-config.schema.json b/schemas/adapter-contract/agent-config.schema.json index 2aad9230..e533a9b4 100644 --- a/schemas/adapter-contract/agent-config.schema.json +++ b/schemas/adapter-contract/agent-config.schema.json @@ -170,11 +170,13 @@ "model": { "description": "Provider model identifier.", "minLength": 1, + "pattern": "\\S", "type": "string" }, "provider": { "description": "Model provider identifier.", "minLength": 1, + "pattern": "\\S", "type": "string" }, "settings": { diff --git a/schemas/adapter-contract/agent-run-result.schema.json b/schemas/adapter-contract/agent-run-result.schema.json index 0aee5b47..c6ac4081 100644 --- a/schemas/adapter-contract/agent-run-result.schema.json +++ b/schemas/adapter-contract/agent-run-result.schema.json @@ -46,6 +46,7 @@ } ] }, + "pattern": "\\S", "type": "string" } }, diff --git a/schemas/run-plan.schema.json b/schemas/run-plan.schema.json index c40a4e80..06202e10 100644 --- a/schemas/run-plan.schema.json +++ b/schemas/run-plan.schema.json @@ -108,6 +108,7 @@ "adapter_id": { "description": "Unique id for this adapter implementation.", "minLength": 1, + "pattern": "\\S", "type": "string" }, "adapter_kind": { @@ -132,6 +133,7 @@ "description": "NeMo Fabric config areas this adapter consumes or generates." }, "contract_version": { + "const": "fabric.adapter/v1alpha2", "description": "Adapter descriptor contract version.", "minLength": 1, "type": "string" @@ -169,6 +171,7 @@ "harness": { "description": "Stable machine-readable harness identifier implemented by this adapter.", "minLength": 1, + "pattern": "\\S", "type": "string" }, "model_schema": { @@ -339,6 +342,12 @@ "$ref": "#/$defs/AdapterTelemetryProviderSupport" }, "description": "Provider-specific telemetry capabilities supported by this adapter.", + "propertyNames": { + "enum": [ + "relay", + "native" + ] + }, "type": "object" } }, @@ -610,11 +619,13 @@ "model": { "description": "Provider model identifier.", "minLength": 1, + "pattern": "\\S", "type": "string" }, "provider": { "description": "Model provider identifier.", "minLength": 1, + "pattern": "\\S", "type": "string" }, "settings": { diff --git a/scripts/ci/publish_typescript_package.py b/scripts/ci/publish_typescript_package.py new file mode 100644 index 00000000..82f910ad --- /dev/null +++ b/scripts/ci/publish_typescript_package.py @@ -0,0 +1,304 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import argparse +import json +import re +import subprocess +import sys +import time +from collections.abc import Callable, Sequence +from dataclasses import dataclass +from pathlib import Path + + +VERSION_PATTERN = re.compile( + r"^(?P\d+)\.(?P\d+)\.(?P\d+)" + r"(?:-(?P