Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
62 commits
Select commit Hold shift + click to select a range
39a863a
docs: add unified module storage & arc sharing fix design
bkrabach Mar 10, 2026
394c763
test: add failing test for Coordinator::hooks_shared() Arc sharing
bkrabach Mar 10, 2026
6837c7b
feat: wrap HookRegistry in Arc inside Coordinator, add hooks_shared()
bkrabach Mar 10, 2026
ecd4e65
feat: replace create_coordinator() factory with coordinator getter sh…
bkrabach Mar 10, 2026
4b709ce
docs: document &mut self on coordinator getter for NAPI single-thread…
bkrabach Mar 10, 2026
2123fb8
feat: replace create_hook_registry() factory with hooks getter sharin…
bkrabach Mar 10, 2026
f74319f
refactor: delete dead code, update Node tests for Arc-shared coordina…
bkrabach Mar 10, 2026
ed99030
test: add failing tests for 4 new load_grpc_* transport functions
bkrabach Mar 10, 2026
f6d06cf
feat: add load_grpc_provider, load_grpc_hook, load_grpc_context, load…
bkrabach Mar 10, 2026
62d064b
style: consistent doc comments and trait imports in transport.rs
bkrabach Mar 10, 2026
4f78b24
refactor: decouple LoadedModule from wasm feature gate
bkrabach Mar 10, 2026
cdfbf20
test: verify module resolver and LoadedModule work without wasm feature
bkrabach Mar 10, 2026
eadfeeb
docs: add /// # Examples doc-tests on hooks_shared, load_grpc_*, and …
bkrabach Mar 10, 2026
170b46d
fix: suppress all compiler warnings in test code and Python bindings
bkrabach Mar 10, 2026
23fd3e9
chore: update lock files and generated artifacts
bkrabach Mar 10, 2026
333056f
fix: bump amplifier-core-node version to 1.1.1 (was missed during rel…
bkrabach Mar 10, 2026
283558b
fix: rename TestCoordinator to MockCoordinator to suppress PytestColl…
bkrabach Mar 10, 2026
626ce8c
docs: add session init polyglot dispatch design
bkrabach Mar 10, 2026
2a923e5
test: add delegation tests for session.py -> _session_init
bkrabach Mar 10, 2026
5f48bfb
refactor: delegate AmplifierSession.initialize() to shared _session_i…
bkrabach Mar 10, 2026
3c0aa24
test: add failing test for WASM dispatch through loader.load()
bkrabach Mar 10, 2026
d33c648
style: use pathlib.Path in wasm fixture for codebase consistency
bkrabach Mar 10, 2026
e3b4507
refactor: extract repeated "echo-tool" string to MODULE_ID constant
bkrabach Mar 10, 2026
b355a0e
test: add failing test for gRPC dispatch through loader.load()
bkrabach Mar 10, 2026
94ba8d2
feat: add polyglot transport dispatch to ModuleLoader.load()
bkrabach Mar 10, 2026
eaec77a
docs: add clarifying comments to transport dispatch code per review
bkrabach Mar 10, 2026
12d62c5
refactor: delete loader_dispatch.py and migrate tests to loader.load()
bkrabach Mar 10, 2026
d1571c9
fix: add type-ignore comments for pyright false positives in WASM test
bkrabach Mar 10, 2026
eb2e7ca
feat: add PyWasmProvider wrapper in Rust PyO3 bindings
bkrabach Mar 10, 2026
5c095d4
test: add missing parse_tool_calls and __repr__ tests for WasmProvider
bkrabach Mar 10, 2026
db23f01
feat: add PyWasmHook, PyWasmContext, PyWasmOrchestrator, PyWasmApprov…
bkrabach Mar 10, 2026
f55953b
test: add WASM integration tests for all 6 module types (marked slow …
bkrabach Mar 10, 2026
4fdd8cf
docs: add docstrings on loader.load() coordinator param and PyWasm* w…
bkrabach Mar 10, 2026
3d07e3e
docs: add app-layer services & review fixes design
bkrabach Mar 10, 2026
65444a2
test: add failing tests for ApprovalProvider on Coordinator
bkrabach Mar 10, 2026
18f802a
feat: wire ApprovalProvider to Coordinator with field + accessors
bkrabach Mar 10, 2026
1d05277
docs: include has_approval_provider in to_dict() doc comment
bkrabach Mar 10, 2026
eb95c23
feat: add PyApprovalProviderBridge wrapping Python ApprovalSystem as …
bkrabach Mar 10, 2026
1073d15
style: move import to file level and handle discarded try_attach result
bkrabach Mar 10, 2026
256e436
fix: improve PyO3 bridge error handling and support clearing approval…
bkrabach Mar 10, 2026
19ddd4d
test: add failing tests for DisplayService on Coordinator
bkrabach Mar 10, 2026
45c2e2e
feat: add DisplayService trait + Coordinator field + FakeDisplayService
bkrabach Mar 10, 2026
4e644bb
fix: remove stale doc comment on FakeDisplayService
bkrabach Mar 10, 2026
23604aa
docs: add show_message parameter docs and update to_dict key list
bkrabach Mar 10, 2026
825bb91
feat: add PyDisplayServiceBridge wrapping Python DisplaySystem as Rus…
bkrabach Mar 10, 2026
cc1b915
fix: differentiate duplicate error messages in PyDisplayServiceBridge
bkrabach Mar 10, 2026
1f58a17
style: apply cargo fmt formatting to coordinator, testing, and node b…
bkrabach Mar 10, 2026
4ce0a7b
feat: add get-subscriptions to WIT hook-handler interface and guest SDK
bkrabach Mar 10, 2026
d5ca362
feat: update deny-hook fixture with get-subscriptions support
bkrabach Mar 10, 2026
8eb5686
feat: implement host-side hook registration via get-subscriptions
bkrabach Mar 10, 2026
826b221
feat: add GetSubscriptions RPC to gRPC HookService proto
bkrabach Mar 10, 2026
6bf12dc
fix: promote resolve_module failure logging from debug to warning
bkrabach Mar 10, 2026
0002d03
style: add clarifying comment to _validate_module mock in test
bkrabach Mar 10, 2026
70dd6d0
docs: improve PyWasmOrchestrator documentation and add mount warning
bkrabach Mar 10, 2026
c244c8f
refactor: deduplicate _safe_exception_str — import from _session_init
bkrabach Mar 10, 2026
c98a927
style: remove unused import and fix trailing newline in test file
bkrabach Mar 10, 2026
156d8e2
style: apply cargo fmt to wasm_hook.rs
bkrabach Mar 10, 2026
bc85065
chore: regenerate Python gRPC stubs for GetSubscriptions RPC
bkrabach Mar 10, 2026
19d314c
chore: add deny-hook fixture Cargo.lock for reproducibility
bkrabach Mar 10, 2026
e43dd17
test: add integration test for real session init loading pipeline
bkrabach Mar 10, 2026
56a101f
feat: add GetSubscriptions to GrpcHookBridge with UNIMPLEMENTED fallback
bkrabach Mar 10, 2026
d350aca
feat: add get_subscriptions to WasmHookBridge with graceful fallback …
bkrabach Mar 10, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion bindings/node/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "amplifier-core-node"
version = "1.0.10"
version = "1.1.1"
edition = "2021"
description = "Napi-RS bridge for amplifier-core Rust kernel"
license = "MIT"
Expand Down
22 changes: 13 additions & 9 deletions bindings/node/__tests__/coordinator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,21 +30,25 @@ describe('JsCoordinator', () => {
expect(result).toBeNull()
})

// createHookRegistry() creates a NEW detached instance each call — this is the
// known limitation documented by the rename from `.hooks` getter. Use a
// shared JsHookRegistry if you need persistent hook registration.
it('createHookRegistry() returns a JsHookRegistry with listHandlers', () => {
// hooks is now a getter that returns the coordinator's real Arc<HookRegistry>.
it('hooks getter returns a JsHookRegistry with listHandlers', () => {
const coord = new JsCoordinator(emptyConfig)
const hooks = coord.createHookRegistry()
const hooks = coord.hooks
expect(hooks).toBeDefined()
expect(typeof hooks.listHandlers).toBe('function')
})

it('createHookRegistry creates a new instance each call (pins detached behavior)', () => {
it('hooks getter shares state — handlers registered on one call are visible on next', () => {
const coord = new JsCoordinator(emptyConfig)
const h1 = coord.createHookRegistry()
const h2 = coord.createHookRegistry()
expect(h1).not.toBe(h2)
const h1 = coord.hooks
// Register a handler on h1
h1.register('test:event', (_event: string, _data: string) => {
return JSON.stringify({ action: 'Continue' })
}, 0, 'test-handler')
// h2 should see the same handler
const h2 = coord.hooks
const handlers = h2.listHandlers()
expect(handlers['test:event']).toContain('test-handler')
})

it('provides access to cancellation subsystem (coord.cancellation.isCancelled === false)', () => {
Expand Down
4 changes: 2 additions & 2 deletions bindings/node/__tests__/integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@ describe('Full session lifecycle', () => {
expect(session.sessionId).toBeTruthy()
expect(session.isInitialized).toBe(false)

// Access coordinator (createCoordinator returns a new instance from cached config)
const coord = session.createCoordinator()
// Access coordinator (getter returns session's real Arc<Coordinator>)
const coord = session.coordinator
expect(coord).toBeDefined()

// Register capability and verify roundtrip
Expand Down
20 changes: 12 additions & 8 deletions bindings/node/__tests__/session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,22 +34,26 @@ describe('JsAmplifierSession', () => {
expect(session.status).toBe('running')
})

// createCoordinator() creates a NEW Coordinator from cached config each call —
// this is the known limitation documented by the rename from `.coordinator` getter.
it('createCoordinator() returns a coordinator built from session config', () => {
// coordinator is now a getter that returns the session's real Arc<Coordinator>.
it('coordinator getter returns the session coordinator', () => {
const session = new JsAmplifierSession(validConfig)
const coord = session.createCoordinator()
const coord = session.coordinator
expect(coord).toBeDefined()
// Verify coordinator was constructed from the session's config, not a default
const coordConfig = JSON.parse(coord.config)
expect(coordConfig).toHaveProperty('session')
})

it('createCoordinator creates a new instance each call (pins detached behavior)', () => {
it('coordinator getter returns the same instance on repeated calls', () => {
const session = new JsAmplifierSession(validConfig)
const c1 = session.createCoordinator()
const c2 = session.createCoordinator()
expect(c1).not.toBe(c2)
const c1 = session.coordinator
const c2 = session.coordinator
// Both should wrap the same underlying Arc<Coordinator>.
// We verify by registering a capability on one and reading it from the other.
c1.registerCapability('test-cap', JSON.stringify(true))
const result = c2.getCapability('test-cap')
expect(result).not.toBeNull()
expect(JSON.parse(result as string)).toBe(true)
})

it('setInitialized marks session as initialized', () => {
Expand Down
45 changes: 14 additions & 31 deletions bindings/node/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,22 +162,13 @@ export declare class JsCoordinator {
registerCapability(name: string, valueJson: string): void
getCapability(name: string): string | null
/**
* Creates a new **detached** (empty) JsHookRegistry.
* The coordinator's hook registry — shared via `Arc`, not copied.
*
* ⚠吅 **Each call returns a brand-new, empty registry** — hooks registered
* on one instance are invisible to the next. This is a known limitation:
* `Coordinator` owns its `HookRegistry` by value, not behind `Arc`, so
* the binding cannot share state across calls.
*
* The method name (`createHookRegistry`) intentionally signals "creates new
* instance" — a getter property would imply referential stability in JS.
*
* **Workaround:** create a `JsHookRegistry` directly and hold a reference.
*
* Future TODO #1: restructure the kernel to hold `Arc<HookRegistry>` inside
* `Coordinator` so this method can share the same registry instance.
* Returns a `JsHookRegistry` wrapping the coordinator's real
* `Arc<HookRegistry>` obtained via `hooks_shared()`. Hooks registered
* on the returned instance are visible to the Coordinator and vice versa.
*/
createHookRegistry(): JsHookRegistry
get hooks(): JsHookRegistry
get cancellation(): JsCancellationToken
get config(): string
resetTurn(): void
Expand All @@ -190,9 +181,9 @@ export declare class JsCoordinator {
* Lifecycle: `new AmplifierSession(config) → initialize() → execute(prompt) → cleanup()`.
* Wires together Coordinator, HookRegistry, and CancellationToken.
*
* Known limitation: `coordinator` getter creates a separate Coordinator instance
* because the kernel Session owns its Coordinator by value, not behind Arc.
* Sharing requires restructuring the Rust kernel — tracked as Future TODO #1.
* The `coordinator` getter returns the session's real `Arc<Coordinator>`,
* and `coordinator.hooks` returns the real `Arc<HookRegistry>` — both
* shared, not copied.
*/
export declare class JsAmplifierSession {
constructor(configJson: string, sessionId?: string | undefined | null, parentId?: string | undefined | null)
Expand All @@ -212,23 +203,15 @@ export declare class JsAmplifierSession {
*/
get status(): string
/**
* Creates a new **fresh** JsCoordinator from this session's cached config.
*
* ⚠吅 **Each call allocates a new Coordinator** — capabilities registered on
* one instance are invisible to the next. This is a known limitation:
* `Session` owns its `Coordinator` by value, not behind `Arc`, so the
* binding cannot expose the session's live coordinator.
*
* The method name (`createCoordinator`) intentionally signals "creates new
* instance" — a getter property would imply referential stability in JS.
* The session's coordinator — shared via `Arc`, not copied.
*
* **Workaround:** call `createCoordinator()` once, hold the returned instance,
* and register capabilities on it before passing it to other APIs.
* Returns a `JsCoordinator` wrapping the session's real `Arc<Coordinator>`.
* Repeated calls return the same underlying coordinator instance.
*
* Future TODO #1: restructure the kernel to hold `Arc<Coordinator>` inside
* `Session` so this method can return a handle to the session's actual coordinator.
* Takes `&mut self` because the first call caches the coordinator internally.
* This is safe because NAPI JS objects are single-threaded — no concurrent access.
*/
createCoordinator(): JsCoordinator
get coordinator(): JsCoordinator
setInitialized(): void
cleanup(): Promise<void>
}
Expand Down
114 changes: 45 additions & 69 deletions bindings/node/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -385,18 +385,6 @@ impl JsHookRegistry {
}
}

/// Creates a new **detached** (empty) registry.
///
/// Unlike `JsCancellationToken::from_inner`, HookRegistry cannot be cheaply
/// cloned or wrapped from a reference, so this always creates an empty
/// registry. When Coordinator manages ownership, this should accept
/// `Arc<HookRegistry>` to share state.
pub fn new_detached() -> Self {
Self {
inner: Arc::new(amplifier_core::HookRegistry::new()),
}
}

/// Register a hook handler for the given event name.
///
/// ## Handler signature
Expand Down Expand Up @@ -519,28 +507,16 @@ impl JsCoordinator {
}
}

/// Creates a new **detached** (empty) JsHookRegistry.
///
/// ⚠️ **Each call returns a brand-new, empty registry** — hooks registered
/// on one instance are invisible to the next. This is a known limitation:
/// `Coordinator` owns its `HookRegistry` by value, not behind `Arc`, so
/// the binding cannot share state across calls.
///
/// The method name (`createHookRegistry`) intentionally signals "creates new
/// instance" — a getter property would imply referential stability in JS.
/// The coordinator's hook registry — shared via `Arc`, not copied.
///
/// **Workaround:** create a `JsHookRegistry` directly and hold a reference.
///
/// Future TODO #1: restructure the kernel to hold `Arc<HookRegistry>` inside
/// `Coordinator` so this method can share the same registry instance.
#[napi]
pub fn create_hook_registry(&self) -> JsHookRegistry {
log::warn!(
"JsCoordinator::createHookRegistry() — returns a new detached HookRegistry; \
hooks registered on one call are NOT visible via the Coordinator's internal \
registry. Hold the returned instance directly. (Future TODO #1)"
);
JsHookRegistry::new_detached()
/// Returns a `JsHookRegistry` wrapping the coordinator's real
/// `Arc<HookRegistry>` obtained via `hooks_shared()`. Hooks registered
/// on the returned instance are visible to the Coordinator and vice versa.
#[napi(getter)]
pub fn hooks(&self) -> JsHookRegistry {
JsHookRegistry {
inner: self.inner.hooks_shared(),
}
}

#[napi(getter)]
Expand Down Expand Up @@ -579,15 +555,15 @@ impl JsCoordinator {
/// Lifecycle: `new AmplifierSession(config) → initialize() → execute(prompt) → cleanup()`.
/// Wires together Coordinator, HookRegistry, and CancellationToken.
///
/// Known limitation: `coordinator` getter creates a separate Coordinator instance
/// because the kernel Session owns its Coordinator by value, not behind Arc.
/// Sharing requires restructuring the Rust kernel — tracked as Future TODO #1.
/// The `coordinator` getter returns the session's real `Arc<Coordinator>`,
/// and `coordinator.hooks` returns the real `Arc<HookRegistry>` — both
/// shared, not copied.
#[napi]
pub struct JsAmplifierSession {
inner: Arc<Mutex<amplifier_core::Session>>,
cached_session_id: String,
cached_parent_id: Option<String>,
cached_config: HashMap<String, serde_json::Value>,
cached_coordinator: Option<JsCoordinator>,
}

#[napi]
Expand All @@ -601,20 +577,17 @@ impl JsAmplifierSession {
let value: serde_json::Value = serde_json::from_str(&config_json)
.map_err(|e| Error::from_reason(format!("Invalid config JSON: {e}")))?;

let config = amplifier_core::SessionConfig::from_value(value.clone())
let config = amplifier_core::SessionConfig::from_value(value)
.map_err(|e| Error::from_reason(e.to_string()))?;

let cached_config: HashMap<String, serde_json::Value> = serde_json::from_value(value)
.map_err(|e| Error::from_reason(format!("invalid JSON: {e}")))?;

let session = amplifier_core::Session::new(config, session_id.clone(), parent_id.clone());
let cached_session_id = session.session_id().to_string();

Ok(Self {
inner: Arc::new(Mutex::new(session)),
cached_session_id,
cached_parent_id: parent_id,
cached_config,
cached_coordinator: None,
})
}

Expand Down Expand Up @@ -657,42 +630,45 @@ impl JsAmplifierSession {
}
}

/// Creates a new **fresh** JsCoordinator from this session's cached config.
///
/// ⚠️ **Each call allocates a new Coordinator** — capabilities registered on
/// one instance are invisible to the next. This is a known limitation:
/// `Session` owns its `Coordinator` by value, not behind `Arc`, so the
/// binding cannot expose the session's live coordinator.
///
/// The method name (`createCoordinator`) intentionally signals "creates new
/// instance" — a getter property would imply referential stability in JS.
/// The session's coordinator — shared via `Arc`, not copied.
///
/// **Workaround:** call `createCoordinator()` once, hold the returned instance,
/// and register capabilities on it before passing it to other APIs.
/// Returns a `JsCoordinator` wrapping the session's real `Arc<Coordinator>`.
/// Repeated calls return the same underlying coordinator instance.
///
/// Future TODO #1: restructure the kernel to hold `Arc<Coordinator>` inside
/// `Session` so this method can return a handle to the session's actual coordinator.
#[napi]
pub fn create_coordinator(&self) -> JsCoordinator {
log::warn!(
"JsAmplifierSession::createCoordinator() — returns a new Coordinator built from \
cached config; capabilities registered on one call are NOT visible on the next. \
Hold the returned instance directly. (Future TODO #1)"
);
JsCoordinator {
inner: Arc::new(amplifier_core::Coordinator::new(self.cached_config.clone())),
/// Takes `&mut self` because the first call caches the coordinator internally.
/// This is safe because NAPI JS objects are single-threaded — no concurrent access.
#[napi(getter)]
pub fn coordinator(&mut self) -> JsCoordinator {
if let Some(ref cached) = self.cached_coordinator {
return JsCoordinator {
inner: Arc::clone(&cached.inner),
};
}
// First call: extract the Arc<Coordinator> from the session.
// try_lock is safe here — the Mutex is only held during async execute/cleanup.
let coord_arc = match self.inner.try_lock() {
Ok(session) => session.coordinator_shared(),
Err(_) => {
log::warn!(
"JsAmplifierSession::coordinator() — session lock held, \
creating coordinator from default config as fallback"
);
Arc::new(amplifier_core::Coordinator::new(Default::default()))
}
};
let js_coord = JsCoordinator { inner: coord_arc };
self.cached_coordinator = Some(JsCoordinator {
inner: Arc::clone(&js_coord.inner),
});
js_coord
}

#[napi]
pub fn set_initialized(&self) {
match self.inner.try_lock() {
Ok(session) => session.set_initialized(),
// State mutation failed — unlike read-only getters, this warrants a warning.
// Lock contention only occurs during async cleanup(), so this is unlikely
// in practice, but callers should know the mutation didn't happen.
Err(_) => eprintln!(
"amplifier-core-node: set_initialized() skipped — session lock held (cleanup in progress?)"
Err(_) => log::warn!(
"JsAmplifierSession::set_initialized() skipped — session lock held (cleanup in progress?)"
),
}
}
Expand Down
Loading
Loading