mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-06 06:41:56 +00:00
refactor(api): extract versioned update handling to shared utility
Extract duplicate version acknowledgment logic from apiMachine and apiSession into a reusable applyVersionedAck utility. This handles success, version-mismatch, and error responses consistently with customizable parsing and error handling via options object.
This commit is contained in:
+41
-86
@@ -12,6 +12,7 @@ import { backoff } from '@/utils/time'
|
|||||||
import { RpcHandlerManager } from './rpc/RpcHandlerManager'
|
import { RpcHandlerManager } from './rpc/RpcHandlerManager'
|
||||||
import { registerCommonHandlers } from '../modules/common/registerCommonHandlers'
|
import { registerCommonHandlers } from '../modules/common/registerCommonHandlers'
|
||||||
import type { SpawnSessionOptions, SpawnSessionResult } from '../modules/common/rpcTypes'
|
import type { SpawnSessionOptions, SpawnSessionResult } from '../modules/common/rpcTypes'
|
||||||
|
import { applyVersionedAck } from './versionedUpdate'
|
||||||
|
|
||||||
interface ServerToDaemonEvents {
|
interface ServerToDaemonEvents {
|
||||||
update: (data: Update) => void
|
update: (data: Update) => void
|
||||||
@@ -157,49 +158,26 @@ export class ApiMachineClient {
|
|||||||
expectedVersion: this.machine.metadataVersion
|
expectedVersion: this.machine.metadataVersion
|
||||||
}) as unknown
|
}) as unknown
|
||||||
|
|
||||||
if (!answer || typeof answer !== 'object') {
|
applyVersionedAck(answer, {
|
||||||
throw new Error('Invalid machine-update-metadata response')
|
valueKey: 'metadata',
|
||||||
}
|
parseValue: (value) => {
|
||||||
|
const parsed = MachineMetadataSchema.safeParse(value)
|
||||||
const obj = answer as { result?: unknown; version?: unknown; metadata?: unknown }
|
return parsed.success ? parsed.data : null
|
||||||
if (obj.result === 'success' && typeof obj.version === 'number') {
|
},
|
||||||
const next = obj.metadata
|
applyValue: (value) => {
|
||||||
if (next == null) {
|
this.machine.metadata = value
|
||||||
this.machine.metadata = null
|
},
|
||||||
} else {
|
applyVersion: (version) => {
|
||||||
const parsed = MachineMetadataSchema.safeParse(next)
|
this.machine.metadataVersion = version
|
||||||
if (parsed.success) {
|
},
|
||||||
this.machine.metadata = parsed.data
|
logInvalidValue: (context, version) => {
|
||||||
} else {
|
const suffix = context === 'success' ? 'ack' : 'version-mismatch ack'
|
||||||
logger.debug('[API MACHINE] Ignoring invalid metadata value from ack', { version: obj.version })
|
logger.debug(`[API MACHINE] Ignoring invalid metadata value from ${suffix}`, { version })
|
||||||
}
|
},
|
||||||
}
|
invalidResponseMessage: 'Invalid machine-update-metadata response',
|
||||||
this.machine.metadataVersion = obj.version
|
errorMessage: 'Machine metadata update failed',
|
||||||
return
|
versionMismatchMessage: 'Metadata version mismatch'
|
||||||
}
|
})
|
||||||
|
|
||||||
if (obj.result === 'version-mismatch' && typeof obj.version === 'number') {
|
|
||||||
const next = obj.metadata
|
|
||||||
if (next == null) {
|
|
||||||
this.machine.metadata = null
|
|
||||||
} else {
|
|
||||||
const parsed = MachineMetadataSchema.safeParse(next)
|
|
||||||
if (parsed.success) {
|
|
||||||
this.machine.metadata = parsed.data
|
|
||||||
} else {
|
|
||||||
logger.debug('[API MACHINE] Ignoring invalid metadata value from version-mismatch ack', { version: obj.version })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.machine.metadataVersion = obj.version
|
|
||||||
throw new Error('Metadata version mismatch')
|
|
||||||
}
|
|
||||||
|
|
||||||
if (obj.result === 'error') {
|
|
||||||
const reason = typeof (obj as { reason?: unknown }).reason === 'string'
|
|
||||||
? (obj as { reason?: string }).reason
|
|
||||||
: 'unknown'
|
|
||||||
throw new Error(`Machine metadata update failed (${reason})`)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -213,49 +191,26 @@ export class ApiMachineClient {
|
|||||||
expectedVersion: this.machine.daemonStateVersion
|
expectedVersion: this.machine.daemonStateVersion
|
||||||
}) as unknown
|
}) as unknown
|
||||||
|
|
||||||
if (!answer || typeof answer !== 'object') {
|
applyVersionedAck(answer, {
|
||||||
throw new Error('Invalid machine-update-state response')
|
valueKey: 'daemonState',
|
||||||
}
|
parseValue: (value) => {
|
||||||
|
const parsed = DaemonStateSchema.safeParse(value)
|
||||||
const obj = answer as { result?: unknown; version?: unknown; daemonState?: unknown }
|
return parsed.success ? parsed.data : null
|
||||||
if (obj.result === 'success' && typeof obj.version === 'number') {
|
},
|
||||||
const next = obj.daemonState
|
applyValue: (value) => {
|
||||||
if (next == null) {
|
this.machine.daemonState = value
|
||||||
this.machine.daemonState = null
|
},
|
||||||
} else {
|
applyVersion: (version) => {
|
||||||
const parsed = DaemonStateSchema.safeParse(next)
|
this.machine.daemonStateVersion = version
|
||||||
if (parsed.success) {
|
},
|
||||||
this.machine.daemonState = parsed.data
|
logInvalidValue: (context, version) => {
|
||||||
} else {
|
const suffix = context === 'success' ? 'ack' : 'version-mismatch ack'
|
||||||
logger.debug('[API MACHINE] Ignoring invalid daemonState value from ack', { version: obj.version })
|
logger.debug(`[API MACHINE] Ignoring invalid daemonState value from ${suffix}`, { version })
|
||||||
}
|
},
|
||||||
}
|
invalidResponseMessage: 'Invalid machine-update-state response',
|
||||||
this.machine.daemonStateVersion = obj.version
|
errorMessage: 'Machine state update failed',
|
||||||
return
|
versionMismatchMessage: 'Daemon state version mismatch'
|
||||||
}
|
})
|
||||||
|
|
||||||
if (obj.result === 'version-mismatch' && typeof obj.version === 'number') {
|
|
||||||
const next = obj.daemonState
|
|
||||||
if (next == null) {
|
|
||||||
this.machine.daemonState = null
|
|
||||||
} else {
|
|
||||||
const parsed = DaemonStateSchema.safeParse(next)
|
|
||||||
if (parsed.success) {
|
|
||||||
this.machine.daemonState = parsed.data
|
|
||||||
} else {
|
|
||||||
logger.debug('[API MACHINE] Ignoring invalid daemonState value from version-mismatch ack', { version: obj.version })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.machine.daemonStateVersion = obj.version
|
|
||||||
throw new Error('Daemon state version mismatch')
|
|
||||||
}
|
|
||||||
|
|
||||||
if (obj.result === 'error') {
|
|
||||||
const reason = typeof (obj as { reason?: unknown }).reason === 'string'
|
|
||||||
? (obj as { reason?: string }).reason
|
|
||||||
: 'unknown'
|
|
||||||
throw new Error(`Machine state update failed (${reason})`)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+41
-86
@@ -31,6 +31,7 @@ import {
|
|||||||
TerminalResizePayloadSchema,
|
TerminalResizePayloadSchema,
|
||||||
TerminalWritePayloadSchema
|
TerminalWritePayloadSchema
|
||||||
} from '@/terminal/types'
|
} from '@/terminal/types'
|
||||||
|
import { applyVersionedAck } from './versionedUpdate'
|
||||||
|
|
||||||
export class ApiSessionClient extends EventEmitter {
|
export class ApiSessionClient extends EventEmitter {
|
||||||
private readonly token: string
|
private readonly token: string
|
||||||
@@ -459,49 +460,26 @@ export class ApiSessionClient extends EventEmitter {
|
|||||||
metadata: updated
|
metadata: updated
|
||||||
}) as unknown
|
}) as unknown
|
||||||
|
|
||||||
if (!answer || typeof answer !== 'object') {
|
applyVersionedAck(answer, {
|
||||||
throw new Error('Invalid update-metadata response')
|
valueKey: 'metadata',
|
||||||
}
|
parseValue: (value) => {
|
||||||
|
const parsed = MetadataSchema.safeParse(value)
|
||||||
const obj = answer as { result?: unknown; version?: unknown; metadata?: unknown }
|
return parsed.success ? parsed.data : null
|
||||||
if (obj.result === 'success' && typeof obj.version === 'number') {
|
},
|
||||||
const next = obj.metadata
|
applyValue: (value) => {
|
||||||
if (next == null) {
|
this.metadata = value
|
||||||
this.metadata = null
|
},
|
||||||
} else {
|
applyVersion: (version) => {
|
||||||
const parsed = MetadataSchema.safeParse(next)
|
this.metadataVersion = version
|
||||||
if (parsed.success) {
|
},
|
||||||
this.metadata = parsed.data
|
logInvalidValue: (context, version) => {
|
||||||
} else {
|
const suffix = context === 'success' ? 'ack' : 'version-mismatch ack'
|
||||||
logger.debug('[API] Ignoring invalid metadata value from ack', { version: obj.version })
|
logger.debug(`[API] Ignoring invalid metadata value from ${suffix}`, { version })
|
||||||
}
|
},
|
||||||
}
|
invalidResponseMessage: 'Invalid update-metadata response',
|
||||||
this.metadataVersion = obj.version
|
errorMessage: 'Metadata update failed',
|
||||||
return
|
versionMismatchMessage: 'Metadata version mismatch'
|
||||||
}
|
})
|
||||||
|
|
||||||
if (obj.result === 'version-mismatch' && typeof obj.version === 'number') {
|
|
||||||
const next = obj.metadata
|
|
||||||
if (next == null) {
|
|
||||||
this.metadata = null
|
|
||||||
} else {
|
|
||||||
const parsed = MetadataSchema.safeParse(next)
|
|
||||||
if (parsed.success) {
|
|
||||||
this.metadata = parsed.data
|
|
||||||
} else {
|
|
||||||
logger.debug('[API] Ignoring invalid metadata value from version-mismatch ack', { version: obj.version })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.metadataVersion = obj.version
|
|
||||||
throw new Error('Metadata version mismatch')
|
|
||||||
}
|
|
||||||
|
|
||||||
if (obj.result === 'error') {
|
|
||||||
const reason = typeof (obj as { reason?: unknown }).reason === 'string'
|
|
||||||
? (obj as { reason?: string }).reason
|
|
||||||
: 'unknown'
|
|
||||||
throw new Error(`Metadata update failed (${reason})`)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -518,49 +496,26 @@ export class ApiSessionClient extends EventEmitter {
|
|||||||
agentState: updated
|
agentState: updated
|
||||||
}) as unknown
|
}) as unknown
|
||||||
|
|
||||||
if (!answer || typeof answer !== 'object') {
|
applyVersionedAck(answer, {
|
||||||
throw new Error('Invalid update-state response')
|
valueKey: 'agentState',
|
||||||
}
|
parseValue: (value) => {
|
||||||
|
const parsed = AgentStateSchema.safeParse(value)
|
||||||
const obj = answer as { result?: unknown; version?: unknown; agentState?: unknown }
|
return parsed.success ? parsed.data : null
|
||||||
if (obj.result === 'success' && typeof obj.version === 'number') {
|
},
|
||||||
const next = obj.agentState
|
applyValue: (value) => {
|
||||||
if (next == null) {
|
this.agentState = value
|
||||||
this.agentState = null
|
},
|
||||||
} else {
|
applyVersion: (version) => {
|
||||||
const parsed = AgentStateSchema.safeParse(next)
|
this.agentStateVersion = version
|
||||||
if (parsed.success) {
|
},
|
||||||
this.agentState = parsed.data
|
logInvalidValue: (context, version) => {
|
||||||
} else {
|
const suffix = context === 'success' ? 'ack' : 'version-mismatch ack'
|
||||||
logger.debug('[API] Ignoring invalid agentState value from ack', { version: obj.version })
|
logger.debug(`[API] Ignoring invalid agentState value from ${suffix}`, { version })
|
||||||
}
|
},
|
||||||
}
|
invalidResponseMessage: 'Invalid update-state response',
|
||||||
this.agentStateVersion = obj.version
|
errorMessage: 'Agent state update failed',
|
||||||
return
|
versionMismatchMessage: 'Agent state version mismatch'
|
||||||
}
|
})
|
||||||
|
|
||||||
if (obj.result === 'version-mismatch' && typeof obj.version === 'number') {
|
|
||||||
const next = obj.agentState
|
|
||||||
if (next == null) {
|
|
||||||
this.agentState = null
|
|
||||||
} else {
|
|
||||||
const parsed = AgentStateSchema.safeParse(next)
|
|
||||||
if (parsed.success) {
|
|
||||||
this.agentState = parsed.data
|
|
||||||
} else {
|
|
||||||
logger.debug('[API] Ignoring invalid agentState value from version-mismatch ack', { version: obj.version })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.agentStateVersion = obj.version
|
|
||||||
throw new Error('Agent state version mismatch')
|
|
||||||
}
|
|
||||||
|
|
||||||
if (obj.result === 'error') {
|
|
||||||
const reason = typeof (obj as { reason?: unknown }).reason === 'string'
|
|
||||||
? (obj as { reason?: string }).reason
|
|
||||||
: 'unknown'
|
|
||||||
throw new Error(`Agent state update failed (${reason})`)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { applyVersionedAck, type AckResult, type VersionedAckOptions } from './versionedUpdate';
|
||||||
|
|
||||||
|
type TestState = {
|
||||||
|
value: string | null;
|
||||||
|
version: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const baseOptions = (
|
||||||
|
state: TestState,
|
||||||
|
logInvalids: Array<{ context: AckResult; version: number }>,
|
||||||
|
overrides?: Partial<VersionedAckOptions<string, 'metadata'>>
|
||||||
|
): VersionedAckOptions<string, 'metadata'> => ({
|
||||||
|
valueKey: 'metadata',
|
||||||
|
parseValue: (value) => (typeof value === 'string' ? value : null),
|
||||||
|
applyValue: (value) => {
|
||||||
|
state.value = value;
|
||||||
|
},
|
||||||
|
applyVersion: (version) => {
|
||||||
|
state.version = version;
|
||||||
|
},
|
||||||
|
logInvalidValue: (context, version) => {
|
||||||
|
logInvalids.push({ context, version });
|
||||||
|
},
|
||||||
|
invalidResponseMessage: 'Invalid update-metadata response',
|
||||||
|
errorMessage: 'Metadata update failed',
|
||||||
|
versionMismatchMessage: 'Metadata version mismatch',
|
||||||
|
...(overrides ?? {})
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('applyVersionedAck', () => {
|
||||||
|
it('applies value and version on success', () => {
|
||||||
|
const state: TestState = { value: null, version: 0 };
|
||||||
|
const logInvalids: Array<{ context: AckResult; version: number }> = [];
|
||||||
|
const options = baseOptions(state, logInvalids);
|
||||||
|
|
||||||
|
expect(() => applyVersionedAck({
|
||||||
|
result: 'success',
|
||||||
|
version: 2,
|
||||||
|
metadata: 'next'
|
||||||
|
}, options)).not.toThrow();
|
||||||
|
|
||||||
|
expect(state.value).toBe('next');
|
||||||
|
expect(state.version).toBe(2);
|
||||||
|
expect(logInvalids).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies value/version then throws on version mismatch', () => {
|
||||||
|
const state: TestState = { value: 'old', version: 1 };
|
||||||
|
const logInvalids: Array<{ context: AckResult; version: number }> = [];
|
||||||
|
const options = baseOptions(state, logInvalids);
|
||||||
|
|
||||||
|
let caught: unknown;
|
||||||
|
try {
|
||||||
|
applyVersionedAck({
|
||||||
|
result: 'version-mismatch',
|
||||||
|
version: 5,
|
||||||
|
metadata: 'server'
|
||||||
|
}, options);
|
||||||
|
} catch (error) {
|
||||||
|
caught = error;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(caught instanceof Error)) {
|
||||||
|
throw new Error('Expected version mismatch error');
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(caught.message).toBe('Metadata version mismatch');
|
||||||
|
expect(state.value).toBe('server');
|
||||||
|
expect(state.version).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws on error results without mutating state', () => {
|
||||||
|
const state: TestState = { value: 'existing', version: 3 };
|
||||||
|
const logInvalids: Array<{ context: AckResult; version: number }> = [];
|
||||||
|
const options = baseOptions(state, logInvalids);
|
||||||
|
|
||||||
|
expect(() => applyVersionedAck({
|
||||||
|
result: 'error',
|
||||||
|
reason: 'access-denied'
|
||||||
|
}, options)).toThrow('Metadata update failed (access-denied)');
|
||||||
|
|
||||||
|
expect(state.value).toBe('existing');
|
||||||
|
expect(state.version).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws on malformed responses', () => {
|
||||||
|
const state: TestState = { value: 'existing', version: 3 };
|
||||||
|
const logInvalids: Array<{ context: AckResult; version: number }> = [];
|
||||||
|
const options = baseOptions(state, logInvalids);
|
||||||
|
|
||||||
|
expect(() => applyVersionedAck({
|
||||||
|
result: 'success',
|
||||||
|
version: 'nope',
|
||||||
|
metadata: 'value'
|
||||||
|
}, options)).toThrow('Invalid update-metadata response');
|
||||||
|
|
||||||
|
expect(state.value).toBe('existing');
|
||||||
|
expect(state.version).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('logs invalid values but still updates the version', () => {
|
||||||
|
const state: TestState = { value: 'existing', version: 1 };
|
||||||
|
const logInvalids: Array<{ context: AckResult; version: number }> = [];
|
||||||
|
const options = baseOptions(state, logInvalids, {
|
||||||
|
parseValue: () => null
|
||||||
|
});
|
||||||
|
|
||||||
|
applyVersionedAck({
|
||||||
|
result: 'success',
|
||||||
|
version: 4,
|
||||||
|
metadata: 123
|
||||||
|
}, options);
|
||||||
|
|
||||||
|
expect(state.value).toBe('existing');
|
||||||
|
expect(state.version).toBe(4);
|
||||||
|
expect(logInvalids).toEqual([{ context: 'success', version: 4 }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
export type AckResult = 'success' | 'version-mismatch'
|
||||||
|
|
||||||
|
export type VersionedAckResult<ValueKey extends string> =
|
||||||
|
| ({ result: 'success'; version: number } & Record<ValueKey, unknown | null>)
|
||||||
|
| ({ result: 'version-mismatch'; version: number } & Record<ValueKey, unknown | null>)
|
||||||
|
| { result: 'error'; reason?: string }
|
||||||
|
|
||||||
|
export type VersionedAckOptions<TValue, ValueKey extends string> = {
|
||||||
|
valueKey: ValueKey
|
||||||
|
parseValue: (value: unknown) => TValue | null
|
||||||
|
applyValue: (value: TValue | null) => void
|
||||||
|
applyVersion: (version: number) => void
|
||||||
|
logInvalidValue: (context: AckResult, version: number) => void
|
||||||
|
invalidResponseMessage: string
|
||||||
|
errorMessage: string
|
||||||
|
versionMismatchMessage: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const isRecord = (value: unknown): value is Record<string, unknown> => {
|
||||||
|
return typeof value === 'object' && value !== null
|
||||||
|
}
|
||||||
|
|
||||||
|
export const applyVersionedAck = <TValue, ValueKey extends string>(
|
||||||
|
ack: unknown,
|
||||||
|
options: VersionedAckOptions<TValue, ValueKey>
|
||||||
|
): void => {
|
||||||
|
if (!isRecord(ack)) {
|
||||||
|
throw new Error(options.invalidResponseMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = ack.result
|
||||||
|
if (result === 'success' || result === 'version-mismatch') {
|
||||||
|
const version = ack.version
|
||||||
|
if (typeof version !== 'number') {
|
||||||
|
throw new Error(options.invalidResponseMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawValue = ack[options.valueKey]
|
||||||
|
if (rawValue == null) {
|
||||||
|
options.applyValue(null)
|
||||||
|
} else {
|
||||||
|
const parsed = options.parseValue(rawValue)
|
||||||
|
if (parsed === null) {
|
||||||
|
options.logInvalidValue(result, version)
|
||||||
|
} else {
|
||||||
|
options.applyValue(parsed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
options.applyVersion(version)
|
||||||
|
|
||||||
|
if (result === 'version-mismatch') {
|
||||||
|
throw new Error(options.versionMismatchMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result === 'error') {
|
||||||
|
const reason = typeof ack.reason === 'string' ? ack.reason : 'unknown'
|
||||||
|
throw new Error(`${options.errorMessage} (${reason})`)
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(options.invalidResponseMessage)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user