From 98dd9e4afbda5f10111257572354afb77672b8bc Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Tue, 5 May 2026 19:21:31 -0400 Subject: [PATCH 01/10] refactor(sdk): add runtime bridge seam for query dispatch --- sdk/src/gsd-tools.ts | 20 ++++----- sdk/src/query-gsd-tools-runtime.ts | 14 +++++-- sdk/src/query-runtime-bridge.ts | 66 ++++++++++++++++++++++++++++++ 3 files changed, 83 insertions(+), 17 deletions(-) create mode 100644 sdk/src/query-runtime-bridge.ts diff --git a/sdk/src/gsd-tools.ts b/sdk/src/gsd-tools.ts index a6fdbbd90..17f019766 100644 --- a/sdk/src/gsd-tools.ts +++ b/sdk/src/gsd-tools.ts @@ -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 } 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['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; @@ -76,12 +73,10 @@ export class GSDTools { execRawFallback: (legacyCommand, legacyArgs) => this.execRaw(legacyCommand, legacyArgs), }); - 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 +84,6 @@ export class GSDTools { mode: input.mode, projectDir: this.projectDir, workstream: this.workstream, - preferNativeQuery: this.shouldUseNativeQuery(), }), }); @@ -104,7 +98,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 +109,7 @@ export class GSDTools { mode: 'json' | 'raw', ): Promise { return this.executeWithToolsError(legacyCommand, legacyArgs, () => - this.nativeHotpathAdapter.dispatch( + this.bridge.dispatchHotpath( legacyCommand, legacyArgs, registryCommand, diff --git a/sdk/src/query-gsd-tools-runtime.ts b/sdk/src/query-gsd-tools-runtime.ts index c86efc6cd..b29384d9d 100644 --- a/sdk/src/query-gsd-tools-runtime.ts +++ b/sdk/src/query-gsd-tools-runtime.ts @@ -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 } from './query-runtime-bridge.js'; export interface GSDToolsRuntime { - registry: ReturnType; - executionPolicy: QueryExecutionPolicy; - nativeHotpathAdapter: QueryNativeHotpathAdapter; + bridge: QueryRuntimeBridge; } export function createGSDToolsRuntime(opts: { @@ -65,5 +64,12 @@ export function createGSDToolsRuntime(opts: { opts.execRawFallback, ); - return { registry, executionPolicy, nativeHotpathAdapter }; + const bridge = new QueryRuntimeBridge( + registry, + executionPolicy, + nativeHotpathAdapter, + opts.shouldUseNativeQuery, + ); + + return { bridge }; } diff --git a/sdk/src/query-runtime-bridge.ts b/sdk/src/query-runtime-bridge.ts new file mode 100644 index 000000000..e249214a6 --- /dev/null +++ b/sdk/src/query-runtime-bridge.ts @@ -0,0 +1,66 @@ +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'; + +export interface RuntimeBridgeExecuteInput { + legacyCommand: string; + legacyArgs: string[]; + registryCommand: string; + registryArgs: string[]; + mode: TransportMode; + projectDir: string; + workstream?: string; +} + +/** + * 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, + ) {} + + getRegistry(): QueryRegistry { + return this.registry; + } + + resolve(command: string, args: string[]): QueryCommandResolution | null { + return resolveQueryCommand(command, args, this.registry); + } + + async execute(input: RuntimeBridgeExecuteInput): Promise { + return 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(), + }); + } + + async dispatchHotpath( + legacyCommand: string, + legacyArgs: string[], + registryCommand: string, + registryArgs: string[], + mode: TransportMode, + ): Promise { + return this.nativeHotpathAdapter.dispatch( + legacyCommand, + legacyArgs, + registryCommand, + registryArgs, + mode, + ); + } +} From 0026065c7a31694340196220facec72197d857e8 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Tue, 5 May 2026 19:23:39 -0400 Subject: [PATCH 02/10] feat(sdk): add strict mode and explicit fallback policy to runtime bridge --- sdk/src/gsd-tools.test.ts | 30 ++++++++++++++++++++++++-- sdk/src/gsd-tools.ts | 6 ++++++ sdk/src/query-execution-policy.test.ts | 21 ++++++++++++++++++ sdk/src/query-execution-policy.ts | 4 +++- sdk/src/query-gsd-tools-runtime.ts | 6 ++++++ sdk/src/query-runtime-bridge.ts | 15 +++++++++++++ 6 files changed, 79 insertions(+), 3 deletions(-) diff --git a/sdk/src/gsd-tools.test.ts b/sdk/src/gsd-tools.test.ts index e21a6b7da..55163cdb8 100644 --- a/sdk/src/gsd-tools.test.ts +++ b/sdk/src/gsd-tools.test.ts @@ -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('nonexistent-command', [])).rejects.toThrow( + "Strict SDK mode: command 'nonexistent-command' 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 { diff --git a/sdk/src/gsd-tools.ts b/sdk/src/gsd-tools.ts index 17f019766..129967dda 100644 --- a/sdk/src/gsd-tools.ts +++ b/sdk/src/gsd-tools.ts @@ -53,6 +53,10 @@ 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; }) { this.projectDir = opts.projectDir; this.gsdToolsPath = @@ -71,6 +75,8 @@ 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 ?? false, }); this.bridge = runtime.bridge; diff --git a/sdk/src/query-execution-policy.test.ts b/sdk/src/query-execution-policy.test.ts index 146801a5a..2e1942942 100644 --- a/sdk/src/query-execution-policy.test.ts +++ b/sdk/src/query-execution-policy.test.ts @@ -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 }); + }); }); diff --git a/sdk/src/query-execution-policy.ts b/sdk/src/query-execution-policy.ts index 1c874b4ce..155b43b76 100644 --- a/sdk/src/query-execution-policy.ts +++ b/sdk/src/query-execution-policy.ts @@ -11,6 +11,7 @@ export interface QueryExecutionRequest { projectDir: string; workstream?: string; preferNativeQuery: boolean; + allowFallbackToSubprocess?: boolean; } /** @@ -35,7 +36,8 @@ export class QueryExecutionPolicy { }, { preferNative: request.preferNativeQuery && policy.preferNative, - allowFallbackToSubprocess: policy.allowFallbackToSubprocess, + allowFallbackToSubprocess: + request.allowFallbackToSubprocess ?? policy.allowFallbackToSubprocess, }, ); } diff --git a/sdk/src/query-gsd-tools-runtime.ts b/sdk/src/query-gsd-tools-runtime.ts index b29384d9d..b2e43a738 100644 --- a/sdk/src/query-gsd-tools-runtime.ts +++ b/sdk/src/query-gsd-tools-runtime.ts @@ -23,6 +23,8 @@ export function createGSDToolsRuntime(opts: { shouldUseNativeQuery: () => boolean; execJsonFallback: (legacyCommand: string, legacyArgs: string[]) => Promise; execRawFallback: (legacyCommand: string, legacyArgs: string[]) => Promise; + strictSdk?: boolean; + allowFallbackToSubprocess?: boolean; }): GSDToolsRuntime { const registry = createRegistry(opts.eventStream, opts.sessionId); @@ -69,6 +71,10 @@ export function createGSDToolsRuntime(opts: { executionPolicy, nativeHotpathAdapter, opts.shouldUseNativeQuery, + { + strictSdk: opts.strictSdk, + allowFallbackToSubprocess: opts.allowFallbackToSubprocess, + }, ); return { bridge }; diff --git a/sdk/src/query-runtime-bridge.ts b/sdk/src/query-runtime-bridge.ts index e249214a6..620f01b0c 100644 --- a/sdk/src/query-runtime-bridge.ts +++ b/sdk/src/query-runtime-bridge.ts @@ -4,6 +4,7 @@ import type { QueryCommandResolution } from './query/query-command-resolution-st 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'; export interface RuntimeBridgeExecuteInput { legacyCommand: string; @@ -25,6 +26,10 @@ export class QueryRuntimeBridge { private readonly executionPolicy: QueryExecutionPolicy, private readonly nativeHotpathAdapter: QueryNativeHotpathAdapter, private readonly shouldUseNativeQuery: () => boolean, + private readonly options?: { + strictSdk?: boolean; + allowFallbackToSubprocess?: boolean; + }, ) {} getRegistry(): QueryRegistry { @@ -36,6 +41,15 @@ export class QueryRuntimeBridge { } async execute(input: RuntimeBridgeExecuteInput): Promise { + if (this.options?.strictSdk && !this.registry.has(input.registryCommand)) { + throw GSDToolsError.failure( + `Strict SDK mode: command '${input.registryCommand}' has no native adapter`, + input.legacyCommand, + input.legacyArgs, + null, + ); + } + return this.executionPolicy.execute({ legacyCommand: input.legacyCommand, legacyArgs: input.legacyArgs, @@ -45,6 +59,7 @@ export class QueryRuntimeBridge { projectDir: input.projectDir, workstream: input.workstream, preferNativeQuery: this.shouldUseNativeQuery(), + allowFallbackToSubprocess: this.options?.allowFallbackToSubprocess, }); } From 1bd11ab699dfafb313220a624fc7455ccb71b2cd Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Tue, 5 May 2026 19:26:01 -0400 Subject: [PATCH 03/10] feat(sdk): emit runtime bridge dispatch observability events --- sdk/src/gsd-tools.ts | 5 +- sdk/src/gsd-transport.ts | 25 ++++- sdk/src/query-execution-policy.ts | 4 +- sdk/src/query-gsd-tools-runtime.ts | 4 +- sdk/src/query-runtime-bridge.test.ts | 102 ++++++++++++++++++ sdk/src/query-runtime-bridge.ts | 148 ++++++++++++++++++++++----- 6 files changed, 260 insertions(+), 28 deletions(-) create mode 100644 sdk/src/query-runtime-bridge.test.ts diff --git a/sdk/src/gsd-tools.ts b/sdk/src/gsd-tools.ts index 129967dda..d8c37fcf3 100644 --- a/sdk/src/gsd-tools.ts +++ b/sdk/src/gsd-tools.ts @@ -20,7 +20,7 @@ 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 } from './query-runtime-bridge.js'; +import { QueryRuntimeBridge, type RuntimeBridgeOptions } from './query-runtime-bridge.js'; export { GSDToolsError } from './gsd-tools-error.js'; @@ -57,6 +57,8 @@ export class GSDTools { 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 = @@ -77,6 +79,7 @@ export class GSDTools { execRawFallback: (legacyCommand, legacyArgs) => this.execRaw(legacyCommand, legacyArgs), strictSdk: opts.strictSdk, allowFallbackToSubprocess: opts.allowFallbackToSubprocess ?? false, + onDispatchEvent: opts.onDispatchEvent, }); this.bridge = runtime.bridge; diff --git a/sdk/src/gsd-transport.ts b/sdk/src/gsd-transport.ts index 7b9cefc8b..92409dbbb 100644 --- a/sdk/src/gsd-transport.ts +++ b/sdk/src/gsd-transport.ts @@ -25,20 +25,34 @@ 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 { - if (this.shouldUseNative(request, policy)) { + async run( + request: TransportRequest, + policy: TransportPolicyLike, + onDecision?: (decision: TransportDecision) => void, + ): Promise { + 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 { + onDecision?.({ dispatchMode: 'subprocess', reason: this.subprocessReason(request, policy) }); } return this.dispatchSubprocess(request); @@ -49,6 +63,13 @@ 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'; + return 'native_not_preferred'; + } + private shouldRethrowNativeError(error: unknown, policy: TransportPolicyLike): boolean { if (!policy.allowFallbackToSubprocess) return true; // Do not subprocess-fallback after a timed-out native dispatch: diff --git a/sdk/src/query-execution-policy.ts b/sdk/src/query-execution-policy.ts index 155b43b76..03df7141e 100644 --- a/sdk/src/query-execution-policy.ts +++ b/sdk/src/query-execution-policy.ts @@ -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 { @@ -12,6 +12,7 @@ export interface QueryExecutionRequest { workstream?: string; preferNativeQuery: boolean; allowFallbackToSubprocess?: boolean; + onTransportDecision?: (decision: TransportDecision) => void; } /** @@ -39,6 +40,7 @@ export class QueryExecutionPolicy { allowFallbackToSubprocess: request.allowFallbackToSubprocess ?? policy.allowFallbackToSubprocess, }, + request.onTransportDecision, ); } } diff --git a/sdk/src/query-gsd-tools-runtime.ts b/sdk/src/query-gsd-tools-runtime.ts index b2e43a738..ff4566a62 100644 --- a/sdk/src/query-gsd-tools-runtime.ts +++ b/sdk/src/query-gsd-tools-runtime.ts @@ -7,7 +7,7 @@ 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 } from './query-runtime-bridge.js'; +import { QueryRuntimeBridge, type RuntimeBridgeOptions } from './query-runtime-bridge.js'; export interface GSDToolsRuntime { bridge: QueryRuntimeBridge; @@ -25,6 +25,7 @@ export function createGSDToolsRuntime(opts: { execRawFallback: (legacyCommand: string, legacyArgs: string[]) => Promise; strictSdk?: boolean; allowFallbackToSubprocess?: boolean; + onDispatchEvent?: RuntimeBridgeOptions['onDispatchEvent']; }): GSDToolsRuntime { const registry = createRegistry(opts.eventStream, opts.sessionId); @@ -74,6 +75,7 @@ export function createGSDToolsRuntime(opts: { { strictSdk: opts.strictSdk, allowFallbackToSubprocess: opts.allowFallbackToSubprocess, + onDispatchEvent: opts.onDispatchEvent, }, ); diff --git a/sdk/src/query-runtime-bridge.test.ts b/sdk/src/query-runtime-bridge.test.ts new file mode 100644 index 000000000..d7dfe494a --- /dev/null +++ b/sdk/src/query-runtime-bridge.test.ts @@ -0,0 +1,102 @@ +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', + }), + ); + }); +}); diff --git a/sdk/src/query-runtime-bridge.ts b/sdk/src/query-runtime-bridge.ts index 620f01b0c..e1875cf5f 100644 --- a/sdk/src/query-runtime-bridge.ts +++ b/sdk/src/query-runtime-bridge.ts @@ -5,6 +5,7 @@ import { resolveQueryCommand } from './query/query-command-resolution-strategy.j 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; @@ -16,6 +17,39 @@ export interface RuntimeBridgeExecuteInput { 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'; + durationMs: number; + outcome: 'success' | 'error'; + errorKind?: 'timeout' | 'failure'; +} + +export interface RuntimeBridgeEvent { + type: 'query_dispatch' | 'query_hotpath_dispatch'; +} + +export interface RuntimeBridgeOptions { + strictSdk?: boolean; + allowFallbackToSubprocess?: boolean; + onDispatchEvent?: (event: RuntimeBridgeDispatchEvent | RuntimeBridgeHotpathEvent) => void; +} + /** * SDK Runtime Bridge Module. * Owns dispatch routing through the execution policy seam and hotpath/native fallback behavior. @@ -26,10 +60,7 @@ export class QueryRuntimeBridge { private readonly executionPolicy: QueryExecutionPolicy, private readonly nativeHotpathAdapter: QueryNativeHotpathAdapter, private readonly shouldUseNativeQuery: () => boolean, - private readonly options?: { - strictSdk?: boolean; - allowFallbackToSubprocess?: boolean; - }, + private readonly options?: RuntimeBridgeOptions, ) {} getRegistry(): QueryRegistry { @@ -41,26 +72,71 @@ export class QueryRuntimeBridge { } async execute(input: RuntimeBridgeExecuteInput): Promise { + const startedAt = Date.now(); if (this.options?.strictSdk && !this.registry.has(input.registryCommand)) { - throw GSDToolsError.failure( + const error = GSDToolsError.failure( `Strict SDK mode: command '${input.registryCommand}' has no native adapter`, input.legacyCommand, input.legacyArgs, null, ); + this.options?.onDispatchEvent?.({ + type: 'query_dispatch', + command: input.registryCommand, + legacyCommand: input.legacyCommand, + mode: input.mode, + dispatchMode: 'subprocess', + reason: 'native_unregistered', + durationMs: Date.now() - startedAt, + outcome: 'error', + errorKind: 'failure', + }); + throw error; } - return 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, - }); + 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.options?.onDispatchEvent?.({ + 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.options?.onDispatchEvent?.({ + 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( @@ -70,12 +146,38 @@ export class QueryRuntimeBridge { registryArgs: string[], mode: TransportMode, ): Promise { - return this.nativeHotpathAdapter.dispatch( - legacyCommand, - legacyArgs, - registryCommand, - registryArgs, - mode, - ); + const startedAt = Date.now(); + try { + const result = await this.nativeHotpathAdapter.dispatch( + legacyCommand, + legacyArgs, + registryCommand, + registryArgs, + mode, + ); + this.options?.onDispatchEvent?.({ + type: 'query_hotpath_dispatch', + command: registryCommand, + legacyCommand, + mode, + dispatchMode: 'native_hotpath', + durationMs: Date.now() - startedAt, + outcome: 'success', + }); + return result; + } catch (error) { + const kind = error instanceof GSDToolsError ? error.classification.kind : 'failure'; + this.options?.onDispatchEvent?.({ + type: 'query_hotpath_dispatch', + command: registryCommand, + legacyCommand, + mode, + dispatchMode: 'native_hotpath', + durationMs: Date.now() - startedAt, + outcome: 'error', + errorKind: kind, + }); + throw error; + } } } From 54b06e653e1aa08ea94aa9eaee1015d9e8f5cbe6 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Tue, 5 May 2026 19:29:59 -0400 Subject: [PATCH 04/10] docs(sdk): document runtime bridge seam, strict mode, and fallback policy --- docs/ARCHITECTURE.md | 13 ++++++++++++- docs/CLI-TOOLS.md | 5 ++++- docs/adr/0001-dispatch-policy-module.md | 15 +++++++++++++++ sdk/src/query/QUERY-HANDLERS.md | 9 +++++++++ 4 files changed, 40 insertions(+), 2 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 6268a4a9c..4bb55b400 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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) │ +│ SDK Runtime Bridge Module routes native vs fallback │ └──────────────────────┬───────────────────────────────┘ │ ┌──────────────────────▼───────────────────────────────┐ @@ -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): diff --git a/docs/CLI-TOOLS.md b/docs/CLI-TOOLS.md index b5d09cdd0..c21a84a76 100644 --- a/docs/CLI-TOOLS.md +++ b/docs/CLI-TOOLS.md @@ -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):** diff --git a/docs/adr/0001-dispatch-policy-module.md b/docs/adr/0001-dispatch-policy-module.md index a85d16105..17ec2d013 100644 --- a/docs/adr/0001-dispatch-policy-module.md +++ b/docs/adr/0001-dispatch-policy-module.md @@ -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. diff --git a/sdk/src/query/QUERY-HANDLERS.md b/sdk/src/query/QUERY-HANDLERS.md index 474844579..afe6f283e 100644 --- a/sdk/src/query/QUERY-HANDLERS.md +++ b/sdk/src/query/QUERY-HANDLERS.md @@ -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. From 00ba404b60aaad5b1890f96796cd3873cb2163a3 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Tue, 5 May 2026 19:31:03 -0400 Subject: [PATCH 05/10] test(sdk): enforce runtime bridge seam and explicit no-fallback behavior --- sdk/src/gsd-transport.test.ts | 26 +++++++++++++++++++++ sdk/src/gsd-transport.ts | 12 +++++++++- sdk/src/query-runtime-seam-coverage.test.ts | 20 ++++++++++++++++ 3 files changed, 57 insertions(+), 1 deletion(-) create mode 100644 sdk/src/query-runtime-seam-coverage.test.ts diff --git a/sdk/src/gsd-transport.test.ts b/sdk/src/gsd-transport.test.ts index af5a71669..2a0f3a2b0 100644 --- a/sdk/src/gsd-transport.test.ts +++ b/sdk/src/gsd-transport.test.ts @@ -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' } })); diff --git a/sdk/src/gsd-transport.ts b/sdk/src/gsd-transport.ts index 92409dbbb..3857d2886 100644 --- a/sdk/src/gsd-transport.ts +++ b/sdk/src/gsd-transport.ts @@ -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; @@ -52,7 +53,16 @@ export class GSDTransport { onDecision?.({ dispatchMode: 'subprocess', reason: 'native_failure_fallback' }); } } else { - onDecision?.({ dispatchMode: 'subprocess', reason: this.subprocessReason(request, policy) }); + const reason = this.subprocessReason(request, policy); + onDecision?.({ dispatchMode: 'subprocess', reason }); + if (!policy.allowFallbackToSubprocess) { + throw GSDToolsError.failure( + `Subprocess fallback disabled: command '${request.registryCommand}' cannot run without native dispatch`, + request.legacyCommand, + request.legacyArgs, + null, + ); + } } return this.dispatchSubprocess(request); diff --git a/sdk/src/query-runtime-seam-coverage.test.ts b/sdk/src/query-runtime-seam-coverage.test.ts new file mode 100644 index 000000000..325a67ddf --- /dev/null +++ b/sdk/src/query-runtime-seam-coverage.test.ts @@ -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'); + }); +}); From 51b809e8e9deec2721efc9ababf94ed3b9948276 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Tue, 5 May 2026 19:36:47 -0400 Subject: [PATCH 06/10] feat(sdk): expose runtime bridge controls via GSD options --- sdk/src/gsd-transport.ts | 2 +- sdk/src/index.ts | 14 +++++++++++ sdk/src/runtime-bridge-options.test.ts | 33 ++++++++++++++++++++++++++ sdk/src/types.ts | 4 ++++ 4 files changed, 52 insertions(+), 1 deletion(-) create mode 100644 sdk/src/runtime-bridge-options.test.ts diff --git a/sdk/src/gsd-transport.ts b/sdk/src/gsd-transport.ts index 3857d2886..eecea7238 100644 --- a/sdk/src/gsd-transport.ts +++ b/sdk/src/gsd-transport.ts @@ -55,7 +55,7 @@ export class GSDTransport { } else { const reason = this.subprocessReason(request, policy); onDecision?.({ dispatchMode: 'subprocess', reason }); - if (!policy.allowFallbackToSubprocess) { + if (!policy.allowFallbackToSubprocess && reason === 'native_unregistered') { throw GSDToolsError.failure( `Subprocess fallback disabled: command '${request.registryCommand}' cannot run without native dispatch`, request.legacyCommand, diff --git a/sdk/src/index.ts b/sdk/src/index.ts index d9e8f811a..5e83fc430 100644 --- a/sdk/src/index.ts +++ b/sdk/src/index.ts @@ -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, + }); + }, }); } diff --git a/sdk/src/runtime-bridge-options.test.ts b/sdk/src/runtime-bridge-options.test.ts new file mode 100644 index 000000000..443aaae0f --- /dev/null +++ b/sdk/src/runtime-bridge-options.test.ts @@ -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', + }, + }); + }); +}); diff --git a/sdk/src/types.ts b/sdk/src/types.ts index b57b07363..f0958e648 100644 --- a/sdk/src/types.ts +++ b/sdk/src/types.ts @@ -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. */ From fb58731008db19a99559c4940e66cbad65123297 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Tue, 5 May 2026 19:40:33 -0400 Subject: [PATCH 07/10] chore(changeset): add changelog fragment for sdk runtime bridge seam --- .changeset/eager-badgers-purr.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/eager-badgers-purr.md diff --git a/.changeset/eager-badgers-purr.md b/.changeset/eager-badgers-purr.md new file mode 100644 index 000000000..a41a3f5e9 --- /dev/null +++ b/.changeset/eager-badgers-purr.md @@ -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. From 8ad2e3877ff8692d225f5ca632b1f2e9a14e1230 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Tue, 5 May 2026 19:59:56 -0400 Subject: [PATCH 08/10] fix(sdk): address CodeRabbit runtime bridge and docs findings --- CONTEXT.md | 41 +++++++++++++++++++++++++++++++++ README.md | 1 + docs/ARCHITECTURE.md | 2 +- docs/CLI-TOOLS.md | 2 +- sdk/src/gsd-tools.ts | 2 +- sdk/src/gsd-transport.ts | 7 ++++-- sdk/src/query-runtime-bridge.ts | 26 +++++++++++++-------- 7 files changed, 66 insertions(+), 15 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index 5055bb85a..1b414ce80 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -138,3 +138,44 @@ All workflow file names use hyphens; `` attributes inside those ### "Follow the X workflow" prose fragments are non-standard — use "Execute end-to-end." After stripping prose @-refs, some command `` 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. diff --git a/README.md b/README.md index 4670edbb5..e08f19ba9 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 4bb55b400..6a28c605f 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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 │ -│ SDK Runtime Bridge Module routes native vs fallback │ +│ Programmatic SDK bridge: GSDTools/query-runtime-bridge.ts │ └──────────────────────┬───────────────────────────────┘ │ ┌──────────────────────▼───────────────────────────────┐ diff --git a/docs/CLI-TOOLS.md b/docs/CLI-TOOLS.md index c21a84a76..06bbbe428 100644 --- a/docs/CLI-TOOLS.md +++ b/docs/CLI-TOOLS.md @@ -62,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. diff --git a/sdk/src/gsd-tools.ts b/sdk/src/gsd-tools.ts index d8c37fcf3..be721e17f 100644 --- a/sdk/src/gsd-tools.ts +++ b/sdk/src/gsd-tools.ts @@ -78,7 +78,7 @@ export class GSDTools { execJsonFallback: (legacyCommand, legacyArgs) => this.exec(legacyCommand, legacyArgs), execRawFallback: (legacyCommand, legacyArgs) => this.execRaw(legacyCommand, legacyArgs), strictSdk: opts.strictSdk, - allowFallbackToSubprocess: opts.allowFallbackToSubprocess ?? false, + allowFallbackToSubprocess: opts.allowFallbackToSubprocess, onDispatchEvent: opts.onDispatchEvent, }); diff --git a/sdk/src/gsd-transport.ts b/sdk/src/gsd-transport.ts index eecea7238..26f436b66 100644 --- a/sdk/src/gsd-transport.ts +++ b/sdk/src/gsd-transport.ts @@ -54,7 +54,6 @@ export class GSDTransport { } } else { const reason = this.subprocessReason(request, policy); - onDecision?.({ dispatchMode: 'subprocess', reason }); if (!policy.allowFallbackToSubprocess && reason === 'native_unregistered') { throw GSDToolsError.failure( `Subprocess fallback disabled: command '${request.registryCommand}' cannot run without native dispatch`, @@ -63,6 +62,7 @@ export class GSDTransport { null, ); } + onDecision?.({ dispatchMode: 'subprocess', reason }); } return this.dispatchSubprocess(request); @@ -77,7 +77,10 @@ export class GSDTransport { if (request.workstream) return 'workstream_forced'; if (!policy.preferNative) return 'native_not_preferred'; if (!this.registry.has(request.registryCommand)) return 'native_unregistered'; - return 'native_not_preferred'; + + 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 { diff --git a/sdk/src/query-runtime-bridge.ts b/sdk/src/query-runtime-bridge.ts index e1875cf5f..5011d4421 100644 --- a/sdk/src/query-runtime-bridge.ts +++ b/sdk/src/query-runtime-bridge.ts @@ -40,14 +40,12 @@ export interface RuntimeBridgeHotpathEvent { errorKind?: 'timeout' | 'failure'; } -export interface RuntimeBridgeEvent { - type: 'query_dispatch' | 'query_hotpath_dispatch'; -} +export type RuntimeBridgeEvent = RuntimeBridgeDispatchEvent | RuntimeBridgeHotpathEvent; export interface RuntimeBridgeOptions { strictSdk?: boolean; allowFallbackToSubprocess?: boolean; - onDispatchEvent?: (event: RuntimeBridgeDispatchEvent | RuntimeBridgeHotpathEvent) => void; + onDispatchEvent?: (event: RuntimeBridgeEvent) => void; } /** @@ -71,6 +69,14 @@ export class QueryRuntimeBridge { 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 { const startedAt = Date.now(); if (this.options?.strictSdk && !this.registry.has(input.registryCommand)) { @@ -80,12 +86,12 @@ export class QueryRuntimeBridge { input.legacyArgs, null, ); - this.options?.onDispatchEvent?.({ + this.emit({ type: 'query_dispatch', command: input.registryCommand, legacyCommand: input.legacyCommand, mode: input.mode, - dispatchMode: 'subprocess', + dispatchMode: 'native', reason: 'native_unregistered', durationMs: Date.now() - startedAt, outcome: 'error', @@ -111,7 +117,7 @@ export class QueryRuntimeBridge { }, }); - this.options?.onDispatchEvent?.({ + this.emit({ type: 'query_dispatch', command: input.registryCommand, legacyCommand: input.legacyCommand, @@ -124,7 +130,7 @@ export class QueryRuntimeBridge { return result; } catch (error) { const kind = error instanceof GSDToolsError ? error.classification.kind : 'failure'; - this.options?.onDispatchEvent?.({ + this.emit({ type: 'query_dispatch', command: input.registryCommand, legacyCommand: input.legacyCommand, @@ -155,7 +161,7 @@ export class QueryRuntimeBridge { registryArgs, mode, ); - this.options?.onDispatchEvent?.({ + this.emit({ type: 'query_hotpath_dispatch', command: registryCommand, legacyCommand, @@ -167,7 +173,7 @@ export class QueryRuntimeBridge { return result; } catch (error) { const kind = error instanceof GSDToolsError ? error.classification.kind : 'failure'; - this.options?.onDispatchEvent?.({ + this.emit({ type: 'query_hotpath_dispatch', command: registryCommand, legacyCommand, From fe16143e2920a8790f65c298590ec8bb48508a72 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Tue, 5 May 2026 20:22:03 -0400 Subject: [PATCH 09/10] fix(sdk): align hotpath observability with actual dispatch mode --- sdk/src/gsd-tools.test.ts | 4 +-- sdk/src/query-runtime-bridge.test.ts | 46 ++++++++++++++++++++++++++++ sdk/src/query-runtime-bridge.ts | 28 +++++++++++++++-- 3 files changed, 73 insertions(+), 5 deletions(-) diff --git a/sdk/src/gsd-tools.test.ts b/sdk/src/gsd-tools.test.ts index 55163cdb8..035fdbe50 100644 --- a/sdk/src/gsd-tools.test.ts +++ b/sdk/src/gsd-tools.test.ts @@ -195,8 +195,8 @@ describe('GSDTools', () => { allowFallbackToSubprocess: true, }); - await expect(tools.exec('nonexistent-command', [])).rejects.toThrow( - "Strict SDK mode: command 'nonexistent-command' has no native adapter", + await expect(tools.exec('graphify', [])).rejects.toThrow( + "Strict SDK mode: command 'graphify' has no native adapter", ); }); diff --git a/sdk/src/query-runtime-bridge.test.ts b/sdk/src/query-runtime-bridge.test.ts index d7dfe494a..903ec3f2b 100644 --- a/sdk/src/query-runtime-bridge.test.ts +++ b/sdk/src/query-runtime-bridge.test.ts @@ -99,4 +99,50 @@ describe('QueryRuntimeBridge observability', () => { }), ); }); + + 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', + 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', + outcome: 'error', + }), + ); + }); }); diff --git a/sdk/src/query-runtime-bridge.ts b/sdk/src/query-runtime-bridge.ts index 5011d4421..ea2611bde 100644 --- a/sdk/src/query-runtime-bridge.ts +++ b/sdk/src/query-runtime-bridge.ts @@ -34,7 +34,7 @@ export interface RuntimeBridgeHotpathEvent { command: string; legacyCommand: string; mode: TransportMode; - dispatchMode: 'native_hotpath'; + dispatchMode: 'native_hotpath' | 'subprocess'; durationMs: number; outcome: 'success' | 'error'; errorKind?: 'timeout' | 'failure'; @@ -153,6 +153,28 @@ export class QueryRuntimeBridge { mode: TransportMode, ): Promise { 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', + durationMs: Date.now() - startedAt, + outcome: 'error', + errorKind: 'failure', + }); + throw error; + } + try { const result = await this.nativeHotpathAdapter.dispatch( legacyCommand, @@ -166,7 +188,7 @@ export class QueryRuntimeBridge { command: registryCommand, legacyCommand, mode, - dispatchMode: 'native_hotpath', + dispatchMode: useNative ? 'native_hotpath' : 'subprocess', durationMs: Date.now() - startedAt, outcome: 'success', }); @@ -178,7 +200,7 @@ export class QueryRuntimeBridge { command: registryCommand, legacyCommand, mode, - dispatchMode: 'native_hotpath', + dispatchMode: useNative ? 'native_hotpath' : 'subprocess', durationMs: Date.now() - startedAt, outcome: 'error', errorKind: kind, From 3785c09307d6c887adca9323c9e096d478bd3720 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Tue, 5 May 2026 20:29:43 -0400 Subject: [PATCH 10/10] fix(sdk): include hotpath fallback reason in bridge observability --- sdk/src/query-runtime-bridge.test.ts | 2 ++ sdk/src/query-runtime-bridge.ts | 4 ++++ 2 files changed, 6 insertions(+) diff --git a/sdk/src/query-runtime-bridge.test.ts b/sdk/src/query-runtime-bridge.test.ts index 903ec3f2b..cb9233c90 100644 --- a/sdk/src/query-runtime-bridge.test.ts +++ b/sdk/src/query-runtime-bridge.test.ts @@ -117,6 +117,7 @@ describe('QueryRuntimeBridge observability', () => { type: 'query_hotpath_dispatch', command: 'commit', dispatchMode: 'subprocess', + reason: 'native_disabled', outcome: 'success', }), ); @@ -141,6 +142,7 @@ describe('QueryRuntimeBridge observability', () => { type: 'query_hotpath_dispatch', command: 'commit', dispatchMode: 'subprocess', + reason: 'policy_blocked', outcome: 'error', }), ); diff --git a/sdk/src/query-runtime-bridge.ts b/sdk/src/query-runtime-bridge.ts index ea2611bde..f37bca580 100644 --- a/sdk/src/query-runtime-bridge.ts +++ b/sdk/src/query-runtime-bridge.ts @@ -35,6 +35,7 @@ export interface RuntimeBridgeHotpathEvent { legacyCommand: string; mode: TransportMode; dispatchMode: 'native_hotpath' | 'subprocess'; + reason?: 'native_disabled' | 'policy_blocked'; durationMs: number; outcome: 'success' | 'error'; errorKind?: 'timeout' | 'failure'; @@ -168,6 +169,7 @@ export class QueryRuntimeBridge { legacyCommand, mode, dispatchMode: 'subprocess', + reason: 'policy_blocked', durationMs: Date.now() - startedAt, outcome: 'error', errorKind: 'failure', @@ -189,6 +191,7 @@ export class QueryRuntimeBridge { legacyCommand, mode, dispatchMode: useNative ? 'native_hotpath' : 'subprocess', + reason: useNative ? undefined : 'native_disabled', durationMs: Date.now() - startedAt, outcome: 'success', }); @@ -201,6 +204,7 @@ export class QueryRuntimeBridge { legacyCommand, mode, dispatchMode: useNative ? 'native_hotpath' : 'subprocess', + reason: useNative ? undefined : 'native_disabled', durationMs: Date.now() - startedAt, outcome: 'error', errorKind: kind,