From 0026065c7a31694340196220facec72197d857e8 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Tue, 5 May 2026 19:23:39 -0400 Subject: [PATCH] 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, }); }