From 5cfd874058f3e123af171c41dc78be769b0bef88 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Mon, 4 May 2026 19:57:17 -0400 Subject: [PATCH] refactor: add typed query failure signals --- sdk/src/gsd-tools-error.ts | 10 +++++++- sdk/src/query-failure-classification.test.ts | 9 +++++++ sdk/src/query-failure-classification.ts | 24 +++++++++++++++++++ sdk/src/query-gsd-tools-runtime.ts | 7 +++--- sdk/src/query-subprocess-adapter.ts | 5 +++- sdk/src/query-tools-error-mapper.ts | 13 ++++++++-- .../query/query-dispatch-error-mapper.test.ts | 13 ++++++++++ sdk/src/query/query-dispatch-error-mapper.ts | 14 +++++------ 8 files changed, 81 insertions(+), 14 deletions(-) diff --git a/sdk/src/gsd-tools-error.ts b/sdk/src/gsd-tools-error.ts index 7898885e3..68a5a1bc6 100644 --- a/sdk/src/gsd-tools-error.ts +++ b/sdk/src/gsd-tools-error.ts @@ -1,3 +1,8 @@ +export interface GSDToolsErrorClassification { + kind: 'timeout' | 'failure'; + timeoutMs?: number; +} + export class GSDToolsError extends Error { constructor( message: string, @@ -5,9 +10,12 @@ export class GSDToolsError extends Error { public readonly args: string[], public readonly exitCode: number | null, public readonly stderr: string, - options?: { cause?: unknown }, + options?: { cause?: unknown; classification?: GSDToolsErrorClassification }, ) { super(message, options); this.name = 'GSDToolsError'; + this.classification = options?.classification; } + + public readonly classification?: GSDToolsErrorClassification; } diff --git a/sdk/src/query-failure-classification.test.ts b/sdk/src/query-failure-classification.test.ts index 7675f9012..4232304c7 100644 --- a/sdk/src/query-failure-classification.test.ts +++ b/sdk/src/query-failure-classification.test.ts @@ -5,7 +5,9 @@ import { isTimeoutMessage, parseTimeoutMs, timeoutMessage, + toFailureSignal, } from './query-failure-classification.js'; +import { GSDToolsError } from './gsd-tools-error.js'; describe('query failure classification', () => { it('extracts timeout metadata from message', () => { @@ -25,4 +27,11 @@ describe('query failure classification', () => { expect(errorMessage('boom')).toBe('boom'); expect(errorMessage(new Error('x'))).toBe('x'); }); + + it('prefers typed classification from GSDToolsError', () => { + const err = new GSDToolsError('x', 'state', ['load'], null, '', { + classification: { kind: 'timeout', timeoutMs: 2000 }, + }); + expect(toFailureSignal(err)).toEqual({ kind: 'timeout', message: 'x', timeoutMs: 2000 }); + }); }); diff --git a/sdk/src/query-failure-classification.ts b/sdk/src/query-failure-classification.ts index 63ddbd3f1..5fdda719e 100644 --- a/sdk/src/query-failure-classification.ts +++ b/sdk/src/query-failure-classification.ts @@ -1,3 +1,11 @@ +import { GSDToolsError } from './gsd-tools-error.js'; + +export interface QueryFailureSignal { + kind: 'timeout' | 'failure'; + message: string; + timeoutMs?: number; +} + export function errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } @@ -22,3 +30,19 @@ export function isTimeoutLikeError(error: unknown): boolean { export function timeoutMessage(command: string, args: string[], timeoutMs: number): string { return `gsd-tools timed out after ${timeoutMs}ms: ${command} ${args.join(' ')}`; } + +export function toFailureSignal(error: unknown): QueryFailureSignal { + if (error instanceof GSDToolsError && error.classification) { + return { + kind: error.classification.kind, + message: error.message, + timeoutMs: error.classification.timeoutMs, + }; + } + + const message = errorMessage(error); + if (isTimeoutMessage(message)) { + return { kind: 'timeout', message, timeoutMs: parseTimeoutMs(message) }; + } + return { kind: 'failure', message }; +} diff --git a/sdk/src/query-gsd-tools-runtime.ts b/sdk/src/query-gsd-tools-runtime.ts index e6c871e21..1ca5dfd75 100644 --- a/sdk/src/query-gsd-tools-runtime.ts +++ b/sdk/src/query-gsd-tools-runtime.ts @@ -33,14 +33,15 @@ export function createGSDToolsRuntime(opts: { gsdToolsPath: opts.gsdToolsPath, timeoutMs: opts.timeoutMs, workstream: opts.workstream, - createToolsError: (message, command, args, exitCode, stderr) => - new GSDToolsError(message, command, args, exitCode, stderr), + createToolsError: (message, command, args, exitCode, stderr, classification) => + new GSDToolsError(message, command, args, exitCode, stderr, classification ? { classification } : undefined), }); const nativeDirectAdapter = new QueryNativeDirectAdapter({ timeoutMs: opts.timeoutMs, dispatch: (registryCommand, registryArgs) => registry.dispatch(registryCommand, registryArgs, opts.projectDir), - createTimeoutError: (message, command, args) => new GSDToolsError(message, command, args, null, ''), + createTimeoutError: (message, command, args) => + new GSDToolsError(message, command, args, null, '', { classification: { kind: 'timeout', timeoutMs: opts.timeoutMs } }), }); const transport = new GSDTransport(registry, { diff --git a/sdk/src/query-subprocess-adapter.ts b/sdk/src/query-subprocess-adapter.ts index e63df45f7..474892dd7 100644 --- a/sdk/src/query-subprocess-adapter.ts +++ b/sdk/src/query-subprocess-adapter.ts @@ -1,7 +1,7 @@ import { execFile } from 'node:child_process'; import { readFile } from 'node:fs/promises'; import { timeoutMessage } from './query-failure-classification.js'; -import type { GSDToolsError } from './gsd-tools-error.js'; +import type { GSDToolsError, GSDToolsErrorClassification } from './gsd-tools-error.js'; export interface QuerySubprocessAdapterDeps { projectDir: string; @@ -14,6 +14,7 @@ export interface QuerySubprocessAdapterDeps { args: string[], exitCode: number | null, stderr: string, + classification?: GSDToolsErrorClassification, ) => GSDToolsError; } @@ -46,6 +47,7 @@ export class QuerySubprocessAdapter { args, null, stderrStr, + { kind: 'timeout', timeoutMs: this.deps.timeoutMs }, ), ); return; @@ -112,6 +114,7 @@ export class QuerySubprocessAdapter { args, null, stderrStr, + { kind: 'timeout', timeoutMs: this.deps.timeoutMs }, ), ); return; diff --git a/sdk/src/query-tools-error-mapper.ts b/sdk/src/query-tools-error-mapper.ts index 913b91d6f..44898ed03 100644 --- a/sdk/src/query-tools-error-mapper.ts +++ b/sdk/src/query-tools-error-mapper.ts @@ -1,6 +1,6 @@ import { GSDError, exitCodeFor } from './errors.js'; import { GSDToolsError } from './gsd-tools-error.js'; -import { errorMessage } from './query-failure-classification.js'; +import { isTimeoutMessage, errorMessage, parseTimeoutMs } from './query-failure-classification.js'; /** * Module owning projection of internal errors to GSDToolsError contract. @@ -24,6 +24,15 @@ export function toGSDToolsError(command: string, args: string[], err: unknown): args, 1, '', - err instanceof Error ? { cause: err } : undefined, + err instanceof Error + ? { + cause: err, + ...(isTimeoutMessage(msg) + ? { classification: { kind: 'timeout' as const, timeoutMs: parseTimeoutMs(msg) } } + : undefined), + } + : (isTimeoutMessage(msg) + ? { classification: { kind: 'timeout' as const, timeoutMs: parseTimeoutMs(msg) } } + : undefined), ); } diff --git a/sdk/src/query/query-dispatch-error-mapper.test.ts b/sdk/src/query/query-dispatch-error-mapper.test.ts index 01c4c2e45..0fa140f89 100644 --- a/sdk/src/query/query-dispatch-error-mapper.test.ts +++ b/sdk/src/query/query-dispatch-error-mapper.test.ts @@ -4,6 +4,7 @@ import { mapFallbackDispatchError, toDispatchFailure, } from './query-dispatch-error-mapper.js'; +import { GSDToolsError } from '../gsd-tools-error.js'; describe('query dispatch error mapper', () => { it('maps native timeout errors', () => { @@ -24,6 +25,18 @@ describe('query dispatch error mapper', () => { expect(err.details).toMatchObject({ command: 'state.json', args: [] }); }); + it('maps typed timeout classification from GSDToolsError', () => { + const err = mapNativeDispatchError( + new GSDToolsError('timeout', 'state', ['load'], null, '', { + classification: { kind: 'timeout', timeoutMs: 1234 }, + }), + 'state.load', + [], + ); + expect(err.kind).toBe('native_timeout'); + expect(err.details).toMatchObject({ timeout_ms: 1234 }); + }); + it('maps fallback errors', () => { const err = mapFallbackDispatchError(new Error('spawn ENOENT'), 'state', ['load']); expect(err.kind).toBe('fallback_failure'); diff --git a/sdk/src/query/query-dispatch-error-mapper.ts b/sdk/src/query/query-dispatch-error-mapper.ts index 4d5a8e48a..5d4a725af 100644 --- a/sdk/src/query/query-dispatch-error-mapper.ts +++ b/sdk/src/query/query-dispatch-error-mapper.ts @@ -1,5 +1,5 @@ import type { QueryDispatchError, QueryDispatchResult } from './query-dispatch-contract.js'; -import { errorMessage, isTimeoutMessage, parseTimeoutMs } from '../query-failure-classification.js'; +import { toFailureSignal } from '../query-failure-classification.js'; import { fallbackFailureError, nativeFailureError, nativeTimeoutError } from './query-error-taxonomy.js'; import { dispatchFailure } from './query-dispatch-result-builder.js'; @@ -11,14 +11,14 @@ export function toDispatchFailure( } export function mapNativeDispatchError(error: unknown, command: string, args: string[]): QueryDispatchError { - const message = errorMessage(error); - if (isTimeoutMessage(message)) { - return nativeTimeoutError({ message, command, args, timeoutMs: parseTimeoutMs(message) }); + const signal = toFailureSignal(error); + if (signal.kind === 'timeout') { + return nativeTimeoutError({ message: signal.message, command, args, timeoutMs: signal.timeoutMs }); } - return nativeFailureError({ message, command, args }); + return nativeFailureError({ message: signal.message, command, args }); } export function mapFallbackDispatchError(error: unknown, command: string, args: string[]): QueryDispatchError { - const message = errorMessage(error); - return fallbackFailureError({ message, command, args, backend: 'cjs' }); + const signal = toFailureSignal(error); + return fallbackFailureError({ message: signal.message, command, args, backend: 'cjs' }); }