Merge pull request #3158 from gsd-build/feat/sdk-runtime-bridge-seam

feat(sdk): deepen runtime bridge seam for native-first SDK dispatch
This commit is contained in:
Tom Boucher
2026-05-05 20:50:26 -04:00
committed by GitHub
20 changed files with 675 additions and 26 deletions

View File

@@ -0,0 +1,5 @@
---
type: Changed
pr: 3158
---
**SDK Runtime Bridge seam deepened** — dispatch is now centralized behind a native-first Runtime Bridge Module with explicit fallback policy (allowFallbackToSubprocess), strict native-only mode (strictSdk), and structured dispatch observability events; architecture/ADR docs updated to reflect the seam.

View File

@@ -138,3 +138,44 @@ All workflow file names use hyphens; `<step name="...">` attributes inside those
### "Follow the X workflow" prose fragments are non-standard — use "Execute end-to-end."
After stripping prose @-refs, some command `<process>` blocks retained bolded "**Follow the X workflow**" fragments. ADR-0002 standard is `Execute end-to-end.` for single-workflow commands. Routing commands with flag dispatch use `execute the X workflow end-to-end.` in routing bullets (no bold, no redundant path).
---
## Recurring CodeRabbit review patterns (2026-05-05, PRs #3152/#3154/#3155)
### Changeset metadata drift (`pr:` points at issue instead of PR)
- In `.changeset/*.md`, reviewers repeatedly flag `pr:` values that accidentally reference issue ids.
- **Rule**: `pr:` must equal the GitHub PR number carrying the change.
- **Pre-flight check**: before push, verify each new changeset file against current branch PR number.
### Test diagnostics quality for command-output parsing
- Even when behavior is correct, CR requests clearer failure surfaces before `.map()` on parsed output.
- **Rule**: after `JSON.parse`, assert output object shape (e.g., `Array.isArray(output.phases)`) with raw-output-prefix diagnostics.
- This prevents opaque `TypeError` failures and shortens triage loops when CLI output shape changes.
### Merge gate discipline: CodeRabbit pass is necessary but not sufficient
- CI/checks can be green while unresolved review threads still block clean merge policy.
- **Rule**: always gate on all three together: required checks green, CodeRabbit pass, unresolved thread count = 0.
- Keep using GraphQL `reviewThreads` as authoritative unresolved state, not summary comments/check badge alone.
---
## SDK Runtime Bridge review synthesis (PR #3158, 2026-05-05)
### What we fixed
- Deepened one **SDK Runtime Bridge Module** seam (`sdk/src/query-runtime-bridge.ts`) for dispatch routing and observability.
- Replaced orphan event typing with a canonical union (`RuntimeBridgeEvent`).
- Made bridge observability non-intrusive: `onDispatchEvent` now runs behind a safe emitter so callback failures cannot alter dispatch outcomes.
- Corrected strict-mode event semantics: strict native-adapter rejection now reports `dispatchMode: 'native'` (no fake subprocess attempt).
- Preserved execution policy defaults by passing `allowFallbackToSubprocess` through as `undefined` when unset (no forced override in `GSDTools`).
- Fixed transport decision ordering: fallback-disabled guard now throws before emitting subprocess decision events.
- Added explicit invariant in `subprocessReason` for impossible states (fail loud on contract drift).
- Updated user-facing docs (`README.md`, `docs/CLI-TOOLS.md`, `docs/ARCHITECTURE.md`) and ADR narrative consistency.
### What we should not do again
- Do not let observability callbacks sit on the critical path without isolation.
- Do not emit structured events that claim a transport mode that never happened.
- Do not force option defaults at call sites when policy Modules already define defaults.
- Do not keep duplicate/inert exported types; expose one canonical union Interface.
- Do not emit decision events before guard checks that may reject the path.
- Do not leave architectural docs with ambiguous seam ownership between CLI and SDK paths.

View File

@@ -215,6 +215,7 @@ For the full configuration reference — all settings, git branching strategies,
| [Commands](docs/COMMANDS.md) | Every command with flags and examples |
| [Configuration](docs/CONFIGURATION.md) | Full config schema, model profiles, git branching |
| [Architecture](docs/ARCHITECTURE.md) | How the multi-agent orchestration works |
| [CLI Tools](docs/CLI-TOOLS.md) | `gsd-sdk query` and programmatic SDK dispatch seams |
| [Features](docs/FEATURES.md) | Complete feature index |
| [Changelog](CHANGELOG.md) | What changed in each release |

View File

@@ -55,7 +55,7 @@ GSD is a **meta-prompting framework** that sits between the user and AI coding a
┌──────▼──────────────▼─────────────────▼──────────────┐
│ CLI TOOLS LAYER │
│ gsd-sdk query (sdk/src/query) + gsd-tools.cjs │
│ (State, config, phase, roadmap, verify, templates) │
│ Programmatic SDK bridge: GSDTools/query-runtime-bridge.ts │
└──────────────────────┬───────────────────────────────┘
│
┌──────────────────────▼───────────────────────────────┐
@@ -266,6 +266,17 @@ Runtime hooks that integrate with the host AI agent:
See [`docs/INVENTORY.md`](INVENTORY.md#hooks-11-shipped) for the authoritative 11-hook roster.
### SDK Runtime Bridge Module (`sdk/src/query-runtime-bridge.ts`)
Programmatic SDK callers (`GSDTools`) route through one seam that owns query dispatch policy:
- Native registry dispatch preference
- Explicit subprocess fallback policy (`allowFallbackToSubprocess`)
- Strict SDK mode (`strictSdk`) for fail-fast native-only enforcement
- Structured dispatch observability (`onDispatchEvent`) with mode, reason, duration, and outcome
This keeps callers thin adapters and centralizes transport decisions for SDK publishability.
### CLI Tools (`get-shit-done/bin/`)
Node.js CLI utility (`gsd-tools.cjs`) with domain modules split across `get-shit-done/bin/lib/` (see [`docs/INVENTORY.md`](INVENTORY.md#cli-modules-33-shipped) for the authoritative roster):

View File

@@ -45,7 +45,10 @@ Use this when authoring workflows, not when you only need the command list below
**2. TypeScript — `@gsd-build/sdk` (`GSDTools`, `createRegistry`)**
- `GSDTools` (used by `PhaseRunner`, `InitRunner`, and `GSD.createTools()`) always shells out to `gsd-tools.cjs` via `execFile` — there is no in-process registry path on this class. For typed, in-process dispatch use `createRegistry()` from `sdk/src/query/index.ts`, or invoke `gsd-sdk query` (see [QUERY-HANDLERS.md](../sdk/src/query/QUERY-HANDLERS.md)).
- `GSDTools` now routes through the **SDK Runtime Bridge Module** (`sdk/src/query-runtime-bridge.ts`). Native registry dispatch is preferred; subprocess fallback is explicit policy (`allowFallbackToSubprocess`) and can be disabled for strict SDK-only execution.
- `strictSdk` mode fails fast when a command has no native adapter, making SDK publish/readiness checks deterministic.
- Structured bridge observability is available via `onDispatchEvent` (dispatch mode, fallback reason, duration, outcome, error kind).
- For direct typed dispatch without `GSDTools`, use `createRegistry()` from `sdk/src/query/index.ts`, or invoke `gsd-sdk query` (see [QUERY-HANDLERS.md](../sdk/src/query/QUERY-HANDLERS.md)).
- Conventions: mutation event wiring, `GSDError` vs `{ data: { error } }`, locks, and stubs — [QUERY-HANDLERS.md](../sdk/src/query/QUERY-HANDLERS.md).
**CJS → SDK examples (same project directory):**
@@ -59,7 +62,7 @@ Use this when authoring workflows, not when you only need the command list below
| `node gsd-tools.cjs roadmap analyze` | `gsd-sdk query roadmap analyze` |
**SDK state reads:** `gsd-sdk query state json` / `state.json` and `gsd-sdk query state load` / `state.load` currently share one native handler (rebuilt STATE.md frontmatter — CJS `cmdStateJson`). The legacy CJS `state load` payload (`config`, `state_raw`, existence flags) is still **CLI-only** via `node …/gsd-tools.cjs state load` until a separate registry handler exists. Full routing and golden rules: [QUERY-HANDLERS.md](../sdk/src/query/QUERY-HANDLERS.md).
**SDK state reads:** `state.json` and `state.load` are both registered query handlers with parity coverage. You can invoke them through `gsd-sdk query …` and through the SDK Runtime Bridge (`GSDTools` → `sdk/src/query-runtime-bridge.ts`), honoring `allowFallbackToSubprocess` / `strictSdk` and emitting `onDispatchEvent` observability. For direct typed dispatch, use `createRegistry()` from `sdk/src/query/index.ts`. Full routing and golden rules: [QUERY-HANDLERS.md](../sdk/src/query/QUERY-HANDLERS.md).
**CLI-only (not in registry):** e.g. **graphify**, **from-gsd2** / **gsd2-import** — call `gsd-tools.cjs` until registered.

View File

@@ -1,5 +1,8 @@
# Dispatch policy module as single seam for query execution outcomes
- **Status:** Accepted
- **Date:** 2026-05-03
We decided to centralize query dispatch outcomes in one Dispatch Policy Module that returns a structured union result (`ok` success or failure with typed `kind`, `details`, and final `exit_code`) instead of mixing throws and ad-hoc error mapping across CLI and SDK paths. This keeps fallback policy, timeout classification, and exit mapping in one place for better locality, prevents drift between native and fallback behavior, and makes callers thin adapters over a stable interface.
## Amendment (2026-05-03): query seam deepening completion
@@ -24,3 +27,15 @@ Removed wrapper Modules after call-site convergence:
- `query-registry-capability.ts`
This amendment preserves the original ADR direction: keep policy depth high, adapters thin, and locality concentrated in explicit modules.
## Amendment (2026-05-05): SDK Runtime Bridge seam deepening
To make SDK dispatch a cleaner publishable seam, we deepened `GSDTools` dispatch behind one **SDK Runtime Bridge Module** (`sdk/src/query-runtime-bridge.ts`) and converged policy wiring into that seam:
- `GSDTools` callers now route through one runtime bridge Interface for command resolution, execution, and hotpath dispatch.
- Added explicit fallback policy at the seam (`allowFallbackToSubprocess`) instead of implicit transport behavior.
- Added strict native-only enforcement mode (`strictSdk`) so SDK consumers can fail fast when a command lacks a native adapter.
- Added structured bridge observability (`onDispatchEvent`) for dispatch mode, fallback reason, latency, outcome, and error kind.
- Kept transport and command callers as thin adapters over the bridge seam.
This continues the dispatch-policy design goal: deep policy Modules, thin Adapters, and high locality for behavior changes.

View File

@@ -171,20 +171,46 @@ describe('GSDTools', () => {
`process.stdout.write(JSON.stringify({ from: 'subprocess-fallback' }));`,
);
const tools = new GSDTools({ projectDir: tmpDir, gsdToolsPath: scriptPath });
const tools = new GSDTools({
projectDir: tmpDir,
gsdToolsPath: scriptPath,
allowFallbackToSubprocess: true,
});
setTransportPolicy('verify.path-exists', { allowFallbackToSubprocess: true });
const result = await tools.exec('verify.path-exists', []);
expect(result).toEqual({ from: 'subprocess-fallback' });
});
it('fails fast in strictSdk mode when command has no native adapter', async () => {
const scriptPath = await createScript(
'strict-should-not-run.cjs',
`process.stdout.write(JSON.stringify({ should: 'not-run' }));`,
);
const tools = new GSDTools({
projectDir: tmpDir,
gsdToolsPath: scriptPath,
strictSdk: true,
allowFallbackToSubprocess: true,
});
await expect(tools.exec('graphify', [])).rejects.toThrow(
"Strict SDK mode: command 'graphify' has no native adapter",
);
});
it('preserves GSDToolsError contract when native handler throws and fallback disabled', async () => {
const scriptPath = await createScript(
'should-not-run.cjs',
`process.stdout.write(JSON.stringify({ should: 'not-run' }));`,
);
const tools = new GSDTools({ projectDir: tmpDir, gsdToolsPath: scriptPath });
const tools = new GSDTools({
projectDir: tmpDir,
gsdToolsPath: scriptPath,
allowFallbackToSubprocess: false,
});
setTransportPolicy('verify.path-exists', { allowFallbackToSubprocess: false });
try {

View File

@@ -15,13 +15,12 @@ import type { InitNewProjectInfo, PhaseOpInfo, PhasePlanIndex, RoadmapAnalysis }
import type { GSDEventStream } from './event-stream.js';
import { toToolsErrorFromUnknown } from './query-tools-error-factory.js';
import { GSDToolsError } from './gsd-tools-error.js';
import { resolveQueryCommand, type QueryCommandResolution } from './query/query-command-resolution-strategy.js';
import { QueryExecutionPolicy } from './query-execution-policy.js';
import { QueryNativeHotpathAdapter } from './query-native-hotpath-adapter.js';
import type { QueryCommandResolution } from './query/query-command-resolution-strategy.js';
import { resolveGsdToolsPath } from './query-gsd-tools-path.js';
import { createGSDToolsRuntime } from './query-gsd-tools-runtime.js';
import { QueryCommandExecutor } from './query-command-executor.js';
import { QueryHotpathMethods } from './query-hotpath-methods.js';
import { QueryRuntimeBridge, type RuntimeBridgeOptions } from './query-runtime-bridge.js';
export { GSDToolsError } from './gsd-tools-error.js';
@@ -35,10 +34,8 @@ export class GSDTools {
private readonly gsdToolsPath: string;
private readonly timeoutMs: number;
private readonly workstream?: string;
private readonly registry: ReturnType<typeof createGSDToolsRuntime>['registry'];
private readonly bridge: QueryRuntimeBridge;
private readonly preferNativeQuery: boolean;
private readonly executionPolicy: QueryExecutionPolicy;
private readonly nativeHotpathAdapter: QueryNativeHotpathAdapter;
private readonly commandExecutor: QueryCommandExecutor;
private readonly hotpathMethods: QueryHotpathMethods;
@@ -56,6 +53,12 @@ export class GSDTools {
* Set false in tests that substitute a mock `gsdToolsPath` script.
*/
preferNativeQuery?: boolean;
/** When true, fail if a command has no native registry adapter. */
strictSdk?: boolean;
/** Explicit subprocess bridge policy. Default false for SDK-native mode. */
allowFallbackToSubprocess?: boolean;
/** Structured runtime bridge dispatch observability callback. */
onDispatchEvent?: RuntimeBridgeOptions['onDispatchEvent'];
}) {
this.projectDir = opts.projectDir;
this.gsdToolsPath =
@@ -74,14 +77,15 @@ export class GSDTools {
shouldUseNativeQuery: () => this.shouldUseNativeQuery(),
execJsonFallback: (legacyCommand, legacyArgs) => this.exec(legacyCommand, legacyArgs),
execRawFallback: (legacyCommand, legacyArgs) => this.execRaw(legacyCommand, legacyArgs),
strictSdk: opts.strictSdk,
allowFallbackToSubprocess: opts.allowFallbackToSubprocess,
onDispatchEvent: opts.onDispatchEvent,
});
this.registry = runtime.registry;
this.executionPolicy = runtime.executionPolicy;
this.nativeHotpathAdapter = runtime.nativeHotpathAdapter;
this.bridge = runtime.bridge;
this.commandExecutor = new QueryCommandExecutor({
nativeMatch: (command, args) => this.nativeMatch(command, args),
execute: async (input) => this.executionPolicy.execute({
execute: async (input) => this.bridge.execute({
legacyCommand: input.legacyCommand,
legacyArgs: input.legacyArgs,
registryCommand: input.registryCommand,
@@ -89,7 +93,6 @@ export class GSDTools {
mode: input.mode,
projectDir: this.projectDir,
workstream: this.workstream,
preferNativeQuery: this.shouldUseNativeQuery(),
}),
});
@@ -104,7 +107,7 @@ export class GSDTools {
}
private nativeMatch(command: string, args: string[]): QueryCommandResolution | null {
return resolveQueryCommand(command, args, this.registry);
return this.bridge.resolve(command, args);
}
private async dispatchNativeHotpath(
@@ -115,7 +118,7 @@ export class GSDTools {
mode: 'json' | 'raw',
): Promise<unknown> {
return this.executeWithToolsError(legacyCommand, legacyArgs, () =>
this.nativeHotpathAdapter.dispatch(
this.bridge.dispatchHotpath(
legacyCommand,
legacyArgs,
registryCommand,

View File

@@ -234,6 +234,32 @@ describe('GSDTransport', () => {
expect(adapters.execSubprocessJson).toHaveBeenCalledOnce();
});
it('fails when command is unregistered and subprocess fallback is disabled', async () => {
const registry = new QueryRegistry();
const adapters = {
dispatchNative: vi.fn(async () => ({ data: { ok: true } })),
execSubprocessJson: vi.fn(async () => ({ ok: 'fallback' })),
execSubprocessRaw: vi.fn(async () => 'fallback-raw'),
};
const transport = new GSDTransport(registry, adapters);
await expect(transport.run({
legacyCommand: 'unknown',
legacyArgs: [],
registryCommand: 'unknown',
registryArgs: [],
mode: 'json',
projectDir: '/tmp',
}, {
preferNative: true,
allowFallbackToSubprocess: false,
})).rejects.toThrow("Subprocess fallback disabled");
expect(adapters.execSubprocessJson).not.toHaveBeenCalled();
});
it('forces raw subprocess path when workstream present and mode is raw', async () => {
const registry = new QueryRegistry();
registry.register('commit', async () => ({ data: { hash: 'abc' } }));

View File

@@ -2,6 +2,7 @@ import type { QueryResult } from './query/utils.js';
import type { QueryRegistry } from './query/registry.js';
import type { TransportMode } from './gsd-transport-policy.js';
import { toFailureSignal } from './query-failure-classification.js';
import { GSDToolsError } from './gsd-tools-error.js';
export interface TransportRequest {
legacyCommand: string;
@@ -25,20 +26,43 @@ export interface TransportPolicyLike {
allowFallbackToSubprocess: boolean;
}
export interface TransportDecision {
dispatchMode: 'native' | 'subprocess';
reason?: 'workstream_forced' | 'native_not_preferred' | 'native_unregistered' | 'native_failure_fallback';
}
export class GSDTransport {
constructor(
private readonly registry: QueryRegistry,
private readonly adapters: TransportAdapters,
) {}
async run(request: TransportRequest, policy: TransportPolicyLike): Promise<unknown> {
if (this.shouldUseNative(request, policy)) {
async run(
request: TransportRequest,
policy: TransportPolicyLike,
onDecision?: (decision: TransportDecision) => void,
): Promise<unknown> {
const useNative = this.shouldUseNative(request, policy);
if (useNative) {
try {
const native = await this.adapters.dispatchNative(request);
onDecision?.({ dispatchMode: 'native' });
return this.projectNativeOutput(request, native.data);
} catch (error) {
if (this.shouldRethrowNativeError(error, policy)) throw error;
onDecision?.({ dispatchMode: 'subprocess', reason: 'native_failure_fallback' });
}
} else {
const reason = this.subprocessReason(request, policy);
if (!policy.allowFallbackToSubprocess && reason === 'native_unregistered') {
throw GSDToolsError.failure(
`Subprocess fallback disabled: command '${request.registryCommand}' cannot run without native dispatch`,
request.legacyCommand,
request.legacyArgs,
null,
);
}
onDecision?.({ dispatchMode: 'subprocess', reason });
}
return this.dispatchSubprocess(request);
@@ -49,6 +73,16 @@ export class GSDTransport {
return !forceSubprocess && policy.preferNative && this.registry.has(request.registryCommand);
}
private subprocessReason(request: TransportRequest, policy: TransportPolicyLike): TransportDecision['reason'] {
if (request.workstream) return 'workstream_forced';
if (!policy.preferNative) return 'native_not_preferred';
if (!this.registry.has(request.registryCommand)) return 'native_unregistered';
throw new Error(
`Unexpected subprocess reason state for command '${request.registryCommand}' with preferNative=${String(policy.preferNative)} and workstream=${String(request.workstream)}`,
);
}
private shouldRethrowNativeError(error: unknown, policy: TransportPolicyLike): boolean {
if (!policy.allowFallbackToSubprocess) return true;
// Do not subprocess-fallback after a timed-out native dispatch:

View File

@@ -50,6 +50,8 @@ export class GSD {
private readonly defaultMaxTurns: number;
private readonly autoMode: boolean;
private readonly workstream?: string;
private readonly strictSdk?: boolean;
private readonly allowFallbackToSubprocess?: boolean;
readonly eventStream: GSDEventStream;
constructor(options: GSDOptions) {
@@ -62,6 +64,8 @@ export class GSD {
this.defaultMaxTurns = options.maxTurns ?? 50;
this.autoMode = options.autoMode ?? false;
this.workstream = options.workstream;
this.strictSdk = options.strictSdk;
this.allowFallbackToSubprocess = options.allowFallbackToSubprocess;
this.eventStream = new GSDEventStream();
}
@@ -128,6 +132,16 @@ export class GSD {
workstream: this.workstream,
eventStream: this.eventStream,
sessionId: this.sessionId,
strictSdk: this.strictSdk,
allowFallbackToSubprocess: this.allowFallbackToSubprocess,
onDispatchEvent: (event) => {
this.eventStream.emitEvent({
type: GSDEventType.StreamEvent,
timestamp: new Date().toISOString(),
sessionId: this.sessionId ?? '',
event,
});
},
});
}

View File

@@ -28,4 +28,25 @@ describe('QueryExecutionPolicy', () => {
expect(policyArg).toEqual({ preferNative: true, allowFallbackToSubprocess: false });
});
it('allows runtime override for allowFallbackToSubprocess', async () => {
const run = vi.fn().mockResolvedValue({ ok: true });
const policy = new QueryExecutionPolicy({ run } as never);
setTransportPolicy('verify.path-exists', { preferNative: true, allowFallbackToSubprocess: true });
await policy.execute({
legacyCommand: 'verify.path-exists',
legacyArgs: [],
registryCommand: 'verify.path-exists',
registryArgs: [],
mode: 'json',
projectDir: '/tmp/project',
preferNativeQuery: true,
allowFallbackToSubprocess: false,
});
const [, policyArg] = run.mock.calls[0];
expect(policyArg).toEqual({ preferNative: true, allowFallbackToSubprocess: false });
});
});

View File

@@ -1,5 +1,5 @@
import { resolveTransportPolicy } from './gsd-transport-policy.js';
import type { GSDTransport } from './gsd-transport.js';
import type { GSDTransport, TransportDecision } from './gsd-transport.js';
import type { TransportMode } from './gsd-transport-policy.js';
export interface QueryExecutionRequest {
@@ -11,6 +11,8 @@ export interface QueryExecutionRequest {
projectDir: string;
workstream?: string;
preferNativeQuery: boolean;
allowFallbackToSubprocess?: boolean;
onTransportDecision?: (decision: TransportDecision) => void;
}
/**
@@ -35,8 +37,10 @@ export class QueryExecutionPolicy {
},
{
preferNative: request.preferNativeQuery && policy.preferNative,
allowFallbackToSubprocess: policy.allowFallbackToSubprocess,
allowFallbackToSubprocess:
request.allowFallbackToSubprocess ?? policy.allowFallbackToSubprocess,
},
request.onTransportDecision,
);
}
}

View File

@@ -7,11 +7,10 @@ import { QueryNativeDirectAdapter } from './query-native-direct-adapter.js';
import { QueryNativeHotpathAdapter } from './query-native-hotpath-adapter.js';
import { formatQueryRawOutput } from './query-raw-output-projection.js';
import { createQueryNativeErrorFactory, createQueryToolsErrorFactory } from './query-tools-error-factory.js';
import { QueryRuntimeBridge, type RuntimeBridgeOptions } from './query-runtime-bridge.js';
export interface GSDToolsRuntime {
registry: ReturnType<typeof createRegistry>;
executionPolicy: QueryExecutionPolicy;
nativeHotpathAdapter: QueryNativeHotpathAdapter;
bridge: QueryRuntimeBridge;
}
export function createGSDToolsRuntime(opts: {
@@ -24,6 +23,9 @@ export function createGSDToolsRuntime(opts: {
shouldUseNativeQuery: () => boolean;
execJsonFallback: (legacyCommand: string, legacyArgs: string[]) => Promise<unknown>;
execRawFallback: (legacyCommand: string, legacyArgs: string[]) => Promise<string>;
strictSdk?: boolean;
allowFallbackToSubprocess?: boolean;
onDispatchEvent?: RuntimeBridgeOptions['onDispatchEvent'];
}): GSDToolsRuntime {
const registry = createRegistry(opts.eventStream, opts.sessionId);
@@ -65,5 +67,17 @@ export function createGSDToolsRuntime(opts: {
opts.execRawFallback,
);
return { registry, executionPolicy, nativeHotpathAdapter };
const bridge = new QueryRuntimeBridge(
registry,
executionPolicy,
nativeHotpathAdapter,
opts.shouldUseNativeQuery,
{
strictSdk: opts.strictSdk,
allowFallbackToSubprocess: opts.allowFallbackToSubprocess,
onDispatchEvent: opts.onDispatchEvent,
},
);
return { bridge };
}

View File

@@ -0,0 +1,150 @@
import { describe, it, expect, vi } from 'vitest';
import { QueryRuntimeBridge } from './query-runtime-bridge.js';
import { GSDToolsError } from './gsd-tools-error.js';
describe('QueryRuntimeBridge observability', () => {
it('emits query_dispatch success event with transport decision', async () => {
const onDispatchEvent = vi.fn();
const executionPolicy = {
execute: vi.fn(async (request: { onTransportDecision?: (d: unknown) => void }) => {
request.onTransportDecision?.({ dispatchMode: 'subprocess', reason: 'workstream_forced' });
return { ok: true };
}),
};
const bridge = new QueryRuntimeBridge(
{ has: () => true } as never,
executionPolicy as never,
{ dispatch: vi.fn() } as never,
() => true,
{ onDispatchEvent },
);
await bridge.execute({
legacyCommand: 'state',
legacyArgs: ['load'],
registryCommand: 'state.load',
registryArgs: [],
mode: 'json',
projectDir: '/tmp',
workstream: 'ws-1',
});
expect(onDispatchEvent).toHaveBeenCalledWith(
expect.objectContaining({
type: 'query_dispatch',
command: 'state.load',
dispatchMode: 'subprocess',
reason: 'workstream_forced',
outcome: 'success',
}),
);
});
it('emits query_dispatch error event with errorKind', async () => {
const onDispatchEvent = vi.fn();
const executionPolicy = {
execute: vi.fn(async () => {
throw GSDToolsError.timeout('timeout', 'state', ['load'], '', 500);
}),
};
const bridge = new QueryRuntimeBridge(
{ has: () => true } as never,
executionPolicy as never,
{ dispatch: vi.fn() } as never,
() => true,
{ onDispatchEvent },
);
await expect(
bridge.execute({
legacyCommand: 'state',
legacyArgs: ['load'],
registryCommand: 'state.load',
registryArgs: [],
mode: 'json',
projectDir: '/tmp',
}),
).rejects.toThrow();
expect(onDispatchEvent).toHaveBeenCalledWith(
expect.objectContaining({
type: 'query_dispatch',
command: 'state.load',
outcome: 'error',
errorKind: 'timeout',
}),
);
});
it('emits hotpath event', async () => {
const onDispatchEvent = vi.fn();
const bridge = new QueryRuntimeBridge(
{ has: () => true } as never,
{ execute: vi.fn() } as never,
{ dispatch: vi.fn(async () => 'ok') } as never,
() => true,
{ onDispatchEvent },
);
await bridge.dispatchHotpath('commit', ['msg'], 'commit', ['msg'], 'raw');
expect(onDispatchEvent).toHaveBeenCalledWith(
expect.objectContaining({
type: 'query_hotpath_dispatch',
command: 'commit',
dispatchMode: 'native_hotpath',
outcome: 'success',
}),
);
});
it('emits subprocess hotpath event when native query is disabled', async () => {
const onDispatchEvent = vi.fn();
const bridge = new QueryRuntimeBridge(
{ has: () => true } as never,
{ execute: vi.fn() } as never,
{ dispatch: vi.fn(async () => 'ok') } as never,
() => false,
{ onDispatchEvent, allowFallbackToSubprocess: true },
);
await bridge.dispatchHotpath('commit', ['msg'], 'commit', ['msg'], 'raw');
expect(onDispatchEvent).toHaveBeenCalledWith(
expect.objectContaining({
type: 'query_hotpath_dispatch',
command: 'commit',
dispatchMode: 'subprocess',
reason: 'native_disabled',
outcome: 'success',
}),
);
});
it('blocks subprocess hotpath when fallback is disabled', async () => {
const onDispatchEvent = vi.fn();
const bridge = new QueryRuntimeBridge(
{ has: () => true } as never,
{ execute: vi.fn() } as never,
{ dispatch: vi.fn(async () => 'ok') } as never,
() => false,
{ onDispatchEvent, allowFallbackToSubprocess: false },
);
await expect(
bridge.dispatchHotpath('commit', ['msg'], 'commit', ['msg'], 'raw'),
).rejects.toThrow("Subprocess fallback disabled");
expect(onDispatchEvent).toHaveBeenCalledWith(
expect.objectContaining({
type: 'query_hotpath_dispatch',
command: 'commit',
dispatchMode: 'subprocess',
reason: 'policy_blocked',
outcome: 'error',
}),
);
});
});

View File

@@ -0,0 +1,215 @@
import type { QueryRegistry } from './query/registry.js';
import type { TransportMode } from './gsd-transport-policy.js';
import type { QueryCommandResolution } from './query/query-command-resolution-strategy.js';
import { resolveQueryCommand } from './query/query-command-resolution-strategy.js';
import { QueryExecutionPolicy } from './query-execution-policy.js';
import { QueryNativeHotpathAdapter } from './query-native-hotpath-adapter.js';
import { GSDToolsError } from './gsd-tools-error.js';
import type { TransportDecision } from './gsd-transport.js';
export interface RuntimeBridgeExecuteInput {
legacyCommand: string;
legacyArgs: string[];
registryCommand: string;
registryArgs: string[];
mode: TransportMode;
projectDir: string;
workstream?: string;
}
export interface RuntimeBridgeDispatchEvent {
type: 'query_dispatch';
command: string;
legacyCommand: string;
mode: TransportMode;
dispatchMode: 'native' | 'subprocess' | 'native_hotpath';
reason?: TransportDecision['reason'];
durationMs: number;
outcome: 'success' | 'error';
errorKind?: 'timeout' | 'failure';
}
export interface RuntimeBridgeHotpathEvent {
type: 'query_hotpath_dispatch';
command: string;
legacyCommand: string;
mode: TransportMode;
dispatchMode: 'native_hotpath' | 'subprocess';
reason?: 'native_disabled' | 'policy_blocked';
durationMs: number;
outcome: 'success' | 'error';
errorKind?: 'timeout' | 'failure';
}
export type RuntimeBridgeEvent = RuntimeBridgeDispatchEvent | RuntimeBridgeHotpathEvent;
export interface RuntimeBridgeOptions {
strictSdk?: boolean;
allowFallbackToSubprocess?: boolean;
onDispatchEvent?: (event: RuntimeBridgeEvent) => void;
}
/**
* SDK Runtime Bridge Module.
* Owns dispatch routing through the execution policy seam and hotpath/native fallback behavior.
*/
export class QueryRuntimeBridge {
constructor(
private readonly registry: QueryRegistry,
private readonly executionPolicy: QueryExecutionPolicy,
private readonly nativeHotpathAdapter: QueryNativeHotpathAdapter,
private readonly shouldUseNativeQuery: () => boolean,
private readonly options?: RuntimeBridgeOptions,
) {}
getRegistry(): QueryRegistry {
return this.registry;
}
resolve(command: string, args: string[]): QueryCommandResolution | null {
return resolveQueryCommand(command, args, this.registry);
}
private emit(event: RuntimeBridgeEvent): void {
try {
this.options?.onDispatchEvent?.(event);
} catch {
// Observability must never break dispatch behavior.
}
}
async execute(input: RuntimeBridgeExecuteInput): Promise<unknown> {
const startedAt = Date.now();
if (this.options?.strictSdk && !this.registry.has(input.registryCommand)) {
const error = GSDToolsError.failure(
`Strict SDK mode: command '${input.registryCommand}' has no native adapter`,
input.legacyCommand,
input.legacyArgs,
null,
);
this.emit({
type: 'query_dispatch',
command: input.registryCommand,
legacyCommand: input.legacyCommand,
mode: input.mode,
dispatchMode: 'native',
reason: 'native_unregistered',
durationMs: Date.now() - startedAt,
outcome: 'error',
errorKind: 'failure',
});
throw error;
}
let transportDecision: TransportDecision | undefined;
try {
const result = await this.executionPolicy.execute({
legacyCommand: input.legacyCommand,
legacyArgs: input.legacyArgs,
registryCommand: input.registryCommand,
registryArgs: input.registryArgs,
mode: input.mode,
projectDir: input.projectDir,
workstream: input.workstream,
preferNativeQuery: this.shouldUseNativeQuery(),
allowFallbackToSubprocess: this.options?.allowFallbackToSubprocess,
onTransportDecision: (decision) => {
transportDecision = decision;
},
});
this.emit({
type: 'query_dispatch',
command: input.registryCommand,
legacyCommand: input.legacyCommand,
mode: input.mode,
dispatchMode: transportDecision?.dispatchMode ?? 'native',
reason: transportDecision?.reason,
durationMs: Date.now() - startedAt,
outcome: 'success',
});
return result;
} catch (error) {
const kind = error instanceof GSDToolsError ? error.classification.kind : 'failure';
this.emit({
type: 'query_dispatch',
command: input.registryCommand,
legacyCommand: input.legacyCommand,
mode: input.mode,
dispatchMode: transportDecision?.dispatchMode ?? 'native',
reason: transportDecision?.reason,
durationMs: Date.now() - startedAt,
outcome: 'error',
errorKind: kind,
});
throw error;
}
}
async dispatchHotpath(
legacyCommand: string,
legacyArgs: string[],
registryCommand: string,
registryArgs: string[],
mode: TransportMode,
): Promise<unknown> {
const startedAt = Date.now();
const useNative = this.shouldUseNativeQuery();
if (!useNative && this.options?.allowFallbackToSubprocess === false) {
const error = GSDToolsError.failure(
`Subprocess fallback disabled: command '${registryCommand}' cannot run without native dispatch`,
legacyCommand,
legacyArgs,
null,
);
this.emit({
type: 'query_hotpath_dispatch',
command: registryCommand,
legacyCommand,
mode,
dispatchMode: 'subprocess',
reason: 'policy_blocked',
durationMs: Date.now() - startedAt,
outcome: 'error',
errorKind: 'failure',
});
throw error;
}
try {
const result = await this.nativeHotpathAdapter.dispatch(
legacyCommand,
legacyArgs,
registryCommand,
registryArgs,
mode,
);
this.emit({
type: 'query_hotpath_dispatch',
command: registryCommand,
legacyCommand,
mode,
dispatchMode: useNative ? 'native_hotpath' : 'subprocess',
reason: useNative ? undefined : 'native_disabled',
durationMs: Date.now() - startedAt,
outcome: 'success',
});
return result;
} catch (error) {
const kind = error instanceof GSDToolsError ? error.classification.kind : 'failure';
this.emit({
type: 'query_hotpath_dispatch',
command: registryCommand,
legacyCommand,
mode,
dispatchMode: useNative ? 'native_hotpath' : 'subprocess',
reason: useNative ? undefined : 'native_disabled',
durationMs: Date.now() - startedAt,
outcome: 'error',
errorKind: kind,
});
throw error;
}
}
}

View File

@@ -0,0 +1,20 @@
import { describe, it, expect, vi } from 'vitest';
import { createGSDToolsRuntime } from './query-gsd-tools-runtime.js';
describe('SDK Runtime Bridge seam coverage', () => {
it('exposes bridge as the single runtime seam', () => {
const runtime = createGSDToolsRuntime({
projectDir: '/tmp/project',
gsdToolsPath: '/tmp/gsd-tools.cjs',
timeoutMs: 1_000,
shouldUseNativeQuery: () => true,
execJsonFallback: vi.fn(async () => ({})),
execRawFallback: vi.fn(async () => ''),
});
expect(Object.keys(runtime)).toEqual(['bridge']);
expect(typeof runtime.bridge.resolve).toBe('function');
expect(typeof runtime.bridge.execute).toBe('function');
expect(typeof runtime.bridge.dispatchHotpath).toBe('function');
});
});

View File

@@ -24,6 +24,15 @@ These families are sourced from `command-manifest.*.ts` files and expanded into
CJS routing seams mirror these families with thin adapters (`state/verify/init/phase/phases/validate/roadmap-command-router.cjs`) so `gsd-tools.cjs` stays orchestration-only.
## SDK Runtime Bridge Module (`GSDTools` path)
`GSDTools` dispatch routes through `sdk/src/query-runtime-bridge.ts`.
- Native registry dispatch is preferred at the bridge seam.
- Subprocess fallback is explicit (`allowFallbackToSubprocess`), not implicit.
- `strictSdk` can fail fast when a command has no native adapter.
- `onDispatchEvent` emits structured dispatch observability (`query_dispatch` / `query_hotpath_dispatch`) with dispatch mode, fallback reason, latency, outcome, and error kind.
## `gsd-sdk query` routing
1. **`normalizeQueryCommand()`** (`query-command-resolution-strategy.ts`) — maps the first argv tokens to the same **command + subcommand** patterns as `gsd-tools` `runCommand()` where needed (e.g. `state json` → `state.json`, `init execute-phase 9` → `init.execute-phase` with args `['9']`, `scaffold …` → `phase.scaffold`). Re-exported from **`@gsd-build/sdk`** and **`createRegistry`’s module** (`sdk/src/query/index.ts`) so programmatic callers can mirror CLI tokenization without importing a deep path.

View File

@@ -0,0 +1,33 @@
import { describe, it, expect } from 'vitest';
import { GSD } from './index.js';
import { GSDEventType, type GSDEvent } from './types.js';
describe('GSD runtime bridge options', () => {
it('strictSdk option is honored by createTools dispatch seam', async () => {
const gsd = new GSD({
projectDir: process.cwd(),
strictSdk: true,
allowFallbackToSubprocess: true,
sessionId: 'test-session',
});
const events: GSDEvent[] = [];
gsd.onEvent((event) => events.push(event));
await expect(gsd.createTools().exec('nonexistent-command', [])).rejects.toThrow(
"Strict SDK mode: command 'nonexistent-command' has no native adapter",
);
const streamEvent = events.find((event) => event.type === GSDEventType.StreamEvent);
expect(streamEvent).toBeDefined();
expect(streamEvent).toMatchObject({
type: GSDEventType.StreamEvent,
sessionId: 'test-session',
event: {
type: 'query_dispatch',
command: 'nonexistent-command',
outcome: 'error',
},
});
});
});

View File

@@ -203,6 +203,10 @@ export interface GSDOptions {
* Optional session correlation id for query mutation events when using {@link GSD.createTools}.
*/
sessionId?: string;
/** Strict SDK runtime bridge mode: fail fast when a query command has no native adapter. */
strictSdk?: boolean;
/** Explicit subprocess fallback policy for the runtime bridge. Default false. */
allowFallbackToSubprocess?: boolean;
/** Model to use for execution sessions. */
model?: string;
/** Maximum budget per plan execution in USD. Default: 5.0. */