[AAASM-1847] ✨ (core): Add gateway URL / apiKey resolver + local auto-start#47
Conversation
Mirrors the Python SDK's exception contract for the resolver chain landing in this Sub-task (AAASM-1847 / E17 S-G). ConfigurationError signals "cannot resolve gateway config" (e.g. aasm absent from PATH); GatewayError signals "gateway present but unreachable / not ready". Both extend Error directly — node-sdk has no AssemblyError base yet.
Makes the two new error classes consumable by SDK callers via
``import { ConfigurationError } from "@agent-assembly/sdk/errors"``.
Lays down the new module that will host the zero-config resolution logic for initAssembly (AAASM-1847 / E17 S-G). Exports the default gateway URL, healthz path, probe / auto-start timeouts (ms — Node convention), env-var names, and the auto-start argv tuple. No behavior yet.
Async global-fetch GET against ``{baseUrl}/healthz`` with an
AbortController-driven default 500ms timeout. Any network / timeout
error is swallowed and surfaces as false — keeps the local-dev probe
near-instant when nothing is listening.
Three behaviors: 2xx → true (also pins the /healthz suffix), fetch reject → false, non-2xx → false (table-driven across 400/404/500/503). globalThis.fetch is stubbed via vi.fn — no real network.
Polls the healthz endpoint until success or timeout. Used after autoStartGateway to know when the freshly-spawned local CP is ready to accept connections. Default 5000ms budget per Story AC; final re-probe after the deadline ensures borderline races resolve cleanly.
Three behaviors: success on first probe, success after two prior fetch rejections (verifies the poll-then-sleep loop body), and false when the timeout elapses with no success. Stubs globalThis.fetch rather than spyOn the same-module probeHealthz export — ESM named exports aren't mutable from spyOn.
Reads ~/.aasm/config.yaml when present. js-yaml is treated as a soft dependency via dynamic import() — missing module returns an empty record so the resolver falls through to the local-default step. File-missing, OS errors, parse errors, and non-object payloads all collapse to the same empty result. Tilde-expansion via os.homedir().
Required for the loadConfigFile happy-path test in the upcoming commit. js-yaml stays a soft (dynamic-import) dependency at runtime — SDK consumers without it will simply skip the YAML config-file step. The dev pin lets the test suite exercise the parsing branch.
Four behaviors: missing file → {}, well-formed YAML → parsed mapping,
non-mapping root (top-level list) → {}, malformed YAML → {}. Uses
mkdtempSync for an isolated tmp dir; no real ~/.aasm/ touched.
Defines the two private primitives that autoStartGateway will compose: defaultFindAasmOnPath walks process.env.PATH (with .exe/.cmd suffixes on Windows) and defaultSpawnAasm launches the binary detached with stdio ignored — the docker-style daemon hand-off. Both are routed through a mutable _seams object exposed via ``__testing`` so tests can stub them without ESM module-mocking gymnastics.
Composes the seams: findAasmOnPath → ConfigurationError when missing, spawnAasm to launch detached, waitForHealthz to confirm readiness or raise GatewayError after the configured timeout. Matches the Python SDK's _auto_start_gateway contract semantically.
Three behaviors: aasm-missing → ConfigurationError with install hint, spawn succeeds + healthz ready → resolves and the path is passed to the spawn seam, spawn succeeds but timeout elapses → GatewayError. Stubs done via the __testing._seams handle plus fetch — no real subprocess, no real network.
Implements the 4-step precedence chain (explicit > AAASM_GATEWAY_URL > ~/.aasm/config.yaml agent.gateway_url > local default with probe + auto-start). probeHealthz / loadConfigFile / autoStartGateway are now routed through ``_seams`` too so unit tests can stub each step independently without spawning real aasm or hitting the network.
Five tests exercising the 4-step chain: explicit > env > config > local-default; the local-default branch is split into probe-hit (no auto-start) and probe-miss (auto-start invoked with the canonical URL). __testing._seams handles cross-step stubbing without touching the real network or PATH.
Mirrors resolveGatewayUrl's 4-step precedence for apiKey: explicit field → AAASM_API_KEY env → config-file agent.api_key → empty default. No auto-start path — local mode is unauth-accepting per the Epic, so the empty fallback is the documented default rather than an error.
Four tests mirroring resolveGatewayUrl's chain: explicit > env > config > empty-default. No rejection on missing — empty string is the documented local-mode default.
gatewayUrl and apiKey are now optional on AssemblyConfig and the
config arg itself defaults to {}, so initAssembly() with no
arguments works per the Story AC. The body calls resolveGatewayUrl /
resolveApiKey to fill the gaps via the 4-step precedence chain.
Bundled with the type change to keep this commit bisectable — type
relaxation alone left init-assembly.ts uncompilable.
Two scenarios: probe-hit (no auto-start invoked, ctx returned bound to localhost default) and probe-miss (autoStartGateway invoked with the canonical URL). Uses __testing._seams to short-circuit the resolver and skip any real subprocess or network activity.
Story AC: existing callers passing both gatewayUrl and apiKey must be unaffected by the resolver path. Stubs probeHealthz and autoStartGateway with sentinels that throw if invoked — proves the resolver short-circuits on explicit args.
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
|
🤖 Claude Code review record — AAASM-1847 ST-2Reviewer: Claude Code (Opus 4.7, 1M context) CI status
24 of 25 checks green. Per project policy "test coverage / SonarQube failures are ignorable until acceptance signs off", treating the sonar hotspot as non-blocking. Scope coverage vs ticketCross-checked PR diff against AAASM-1847 description:
Acceptance criteria coverage
End-to-end smoke (run 2026-05-23 against
|


Target
Task summary:
Add zero-config gateway resolution to the Node SDK so
initAssembly()with no arguments connects to a local gateway athttp://localhost:7391, auto-startingaasm start --mode local --foregroundif nothing is listening. Mirrors the Python SDK contract shipped in python-sdk #58 (AAASM-1846).Task tickets:
Key point change:
Resolution order
initAssembly({ gatewayUrl: "...", apiKey: "..." })AAASM_GATEWAY_URL,AAASM_API_KEY~/.aasm/config.yaml(soft js-yaml dependency)http://localhost:7391/healthz; if unreachable, spawnaasm start --mode local --foregrounddetached and wait up to 5sEffecting Scope
gatewayUrlandapiKeyare unaffected, regression-tested)gatewayUrlandapiKeyonAssemblyConfigbecome optional; theconfigarg itself defaults to{})initAssembly()callable with no arguments;AssemblyConfig.gatewayUrlandapiKeynow optionalConfigurationErrorandGatewayError, exported fromsrc/errorstests/gateway-resolver.test.tsandtests/init-assembly-zero-config.test.tsjs-yaml+@types/js-yamlas devDependencies only; runtime still soft-importsDescription
New files
src/core/gateway-resolver.ts— publicresolveGatewayUrl,resolveApiKey,probeHealthz,waitForHealthz,loadConfigFile,autoStartGateway; private_seamsexposed via__testingso unit tests can stubfindAasmOnPath,spawnAasm,probeHealthz,loadConfigFile, andautoStartGatewaywithout ESM mocking gymnasticssrc/errors/configuration-error.ts,src/errors/gateway-error.ts— mirror the Python SDK exception contracttests/gateway-resolver.test.ts— 25 unit tests (probe / wait / config-file / auto-start / 5-step precedence × 2 resolvers)tests/init-assembly-zero-config.test.ts— 3 integration tests (zero-arg success, auto-start trigger, explicit-args regression)Edited files
src/core/init-assembly.ts—initAssembly()accepts no args; resolves viaresolveGatewayUrl/resolveApiKey; passes resolved values tocreateNativeClientsrc/types/assembly-config.ts—gatewayUrl/apiKeyoptional with explanatory JSDocsrc/errors/index.ts— re-exports the two new error classesTest result
All subprocess (
child_process.spawn) and network (globalThis.fetch) calls mocked in unit tests via the__testing._seamshandle — no realaasmspawn, no real network.Notes
src/core/init.ts— actual entry issrc/core/init-assembly.ts; targeted the real module.apiKeyalso became optional with the same precedence chain — necessary for the AC "no fields, no env vars → connects".js-yamlis a soft (dynamicimport()) runtime dep; added todevDependenciesonly so the parsing branch is exercised in tests. Consumers without it fall through cleanly to the local-default step.