feat: WASM extension versioning with WIT compat checks (#592)

* feat: add WASM extension versioning with WIT compat checks and CI enforcement

Phase 1 — WIT Versioning & Compatibility Checks:
- Version WIT packages as `package near:[email protected];`
- Add `semver` crate for version parsing and comparison
- Add `WIT_TOOL_VERSION` / `WIT_CHANNEL_VERSION` host constants
- Add `version` and `wit_version` fields to capabilities schemas
- Add `wit_version` column to `wasm_tools` DB table (both backends)
- Add load-time `check_wit_version_compat()` with semver rules
- Add `IncompatibleWitVersion` error variants for tools and channels
- Enhance instantiation errors with WIT version mismatch hints
- Update all 14 capabilities JSON and 14 registry JSON files

Phase 2 — Upgrade-in-Place & Channel DB Storage:
- Change tool store to DELETE-before-INSERT (one version per extension)
- Create `wasm_channels` table (PostgreSQL migration + libSQL schema)
- Add `WasmChannelStore` trait with PostgreSQL and libSQL backends
- Add `extension_info` tool showing version, WIT version, and status
- Wire `ExtensionInfoTool` into tool registry (7 extension tools)

Phase 3 — CI Version-Bump Enforcement:
- Add `scripts/check-version-bumps.sh` checking WIT/tool/channel versions
- Add `version-check` CI job (PR-only) to `.github/workflows/test.yml`
- Support `[skip-version-check]` label/commit message bypass

Includes 7 regression tests for WIT version compatibility checking
and 2 integration tests for WIT version annotation verification.

[skip-regression-check]

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: address PR review feedback for WASM extension versioning

- Wrap PostgreSQL DELETE+INSERT in transactions for both tool and channel
  store() methods to prevent data loss on partial failure (Gemini, Copilot)
- Rename StoredWasmChannelWithBinary.tool → .channel (copy-paste fix)
- Remove unused WasmError::IncompatibleWitVersion variant (dead code)
- Map channel loader WIT mismatch to IncompatibleWitVersion instead of
  generic Config error, simplify variant to single String message
- Fix extension_info description to match actual returned fields
- Add schema test for ExtensionInfoTool matching existing test pattern
- Fix CI script to fail fast on git errors instead of silent bypass

[skip-regression-check]

Co-Authored-By: Claude Opus 4.6 <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
This commit is contained in:
Illia Polosukhin
2026-03-06 04:38:07 +00:00
committed by GitHub
co-authored by Claude Opus 4.6
parent a516e92156
commit 04c5c3fe9f
52 changed files with 1519 additions and 99 deletions
+84 -22
View File
@@ -214,22 +214,21 @@ fn instantiate_tool_component(
// If the WIT added/removed/renamed a function, stub registration
// or instantiation will fail.
{
// Register stubs for both versioned (0.2.0+) and unversioned (pre-0.2.0) interface
// paths so that both old and new WASM artifacts can instantiate.
for interface in &["near:agent/host", "near:agent/[email protected]"] {
let mut root = linker.root();
let mut host = root
.instance("near:agent/host")
.map_err(|e| format!("failed to create host instance: {e}"))?;
if let Ok(mut host) = root.instance(interface) {
stub_shared_host_functions(&mut host)?;
stub_shared_host_functions(&mut host)?;
// tool-invoke is only in the tool host interface, not channel-host
host.func_new("tool-invoke", |_ctx, _args, results| {
results[0] = wasmtime::component::Val::Result(Err(Some(Box::new(
wasmtime::component::Val::String("stub".into()),
))));
Ok(())
})
.map_err(|e| format!("stub 'tool-invoke': {e}"))?;
host.func_new("tool-invoke", |_ctx, _args, results| {
results[0] = wasmtime::component::Val::Result(Err(Some(Box::new(
wasmtime::component::Val::String("stub".into()),
))));
Ok(())
})
.map_err(|e| format!("stub 'tool-invoke': {e}"))?;
}
}
let mut store = Store::new(engine, TestStoreData::new());
@@ -253,15 +252,15 @@ fn instantiate_channel_component(
wasmtime_wasi::add_to_linker_sync(&mut linker)
.map_err(|e| format!("WASI linker failed: {e}"))?;
{
let mut root = linker.root();
let mut host = root
.instance("near:agent/channel-host")
.map_err(|e| format!("failed to create channel-host instance: {e}"))?;
// Register stubs for both versioned (0.2.0+) and unversioned (pre-0.2.0) interface
// paths so that both old and new WASM artifacts can instantiate.
// Register stubs under both versioned and unversioned interface paths.
// This helper avoids repeating the stub registration code.
fn stub_channel_host(
host: &mut wasmtime::component::LinkerInstance<'_, TestStoreData>,
) -> Result<(), String> {
stub_shared_host_functions(host)?;
stub_shared_host_functions(&mut host)?;
// Channel-specific host functions
host.func_new("emit-message", |_ctx, _args, _results| Ok(()))
.map_err(|e| format!("stub 'emit-message': {e}"))?;
@@ -294,6 +293,23 @@ fn instantiate_channel_component(
Ok(())
})
.map_err(|e| format!("stub 'pairing-read-allow-from': {e}"))?;
Ok(())
}
{
let mut root = linker.root();
let mut host = root
.instance("near:agent/channel-host")
.map_err(|e| format!("failed to create unversioned channel-host: {e}"))?;
stub_channel_host(&mut host)?;
}
{
let mut root = linker.root();
let mut host = root
.instance("near:agent/[email protected]")
.map_err(|e| format!("failed to create versioned channel-host: {e}"))?;
stub_channel_host(&mut host)?;
}
let mut store = Store::new(engine, TestStoreData::new());
@@ -477,3 +493,49 @@ fn wit_compat_all_registry_extensions_have_source() {
missing.join("\n")
);
}
#[test]
fn wit_files_contain_version_annotation() {
let repo_root = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
for wit_file in &["wit/tool.wit", "wit/channel.wit"] {
let path = repo_root.join(wit_file);
let content = std::fs::read_to_string(&path)
.unwrap_or_else(|e| panic!("failed to read {wit_file}: {e}"));
assert!(
content.contains("package near:agent@"),
"{wit_file} must contain a versioned package declaration (e.g., 'package near:[email protected];')"
);
}
}
#[test]
fn wit_version_constants_match_wit_files() {
let repo_root = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
let tool_wit = std::fs::read_to_string(repo_root.join("wit/tool.wit"))
.expect("failed to read wit/tool.wit");
let channel_wit = std::fs::read_to_string(repo_root.join("wit/channel.wit"))
.expect("failed to read wit/channel.wit");
let expected_tool = format!(
"package near:agent@{};",
ironclaw::tools::wasm::WIT_TOOL_VERSION
);
let expected_channel = format!(
"package near:agent@{};",
ironclaw::tools::wasm::WIT_CHANNEL_VERSION
);
assert!(
tool_wit.contains(&expected_tool),
"wit/tool.wit version must match WIT_TOOL_VERSION constant ({})",
ironclaw::tools::wasm::WIT_TOOL_VERSION
);
assert!(
channel_wit.contains(&expected_channel),
"wit/channel.wit version must match WIT_CHANNEL_VERSION constant ({})",
ironclaw::tools::wasm::WIT_CHANNEL_VERSION
);
}