feat(sdk): add strict mode and explicit fallback policy to runtime bridge

This commit is contained in:
Tom Boucher
2026-05-05 19:23:39 -04:00
parent 98dd9e4afb
commit 0026065c7a
6 changed files with 79 additions and 3 deletions

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('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 {

View File

@@ -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;

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

@@ -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,
},
);
}

View File

@@ -23,6 +23,8 @@ export function createGSDToolsRuntime(opts: {
shouldUseNativeQuery: () => boolean;
execJsonFallback: (legacyCommand: string, legacyArgs: string[]) => Promise<unknown>;
execRawFallback: (legacyCommand: string, legacyArgs: string[]) => Promise<string>;
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 };

View File

@@ -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<unknown> {
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,
});
}