From a1fdeeb7aee9d7456914ec09b07f9be2e0639b2b Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 18 Feb 2026 21:21:27 +0700 Subject: [PATCH] fix(dictation): avoid timeout from dangling non-final segments --- packages/app/src/contexts/session-context.tsx | 1 + packages/app/src/screens/settings-screen.tsx | 75 +++++++++++++++++- packages/app/src/stores/session-store.ts | 6 ++ .../utils/server-info-capabilities.test.ts | 1 + packages/cli/src/cli.ts | 16 +++- packages/server/src/server/bootstrap.ts | 5 +- .../server/src/server/daemon-version.test.ts | 71 +++++++++++++++++ packages/server/src/server/daemon-version.ts | 25 ++++++ .../dictation-stream-manager.test.ts | 78 +++++++++++++++++++ .../dictation/dictation-stream-manager.ts | 39 +++++++++- packages/server/src/server/package-version.ts | 64 +++++++++++++++ .../websocket-server.notifications.test.ts | 8 +- .../websocket-server.relay-reconnect.test.ts | 7 +- .../server/src/server/websocket-server.ts | 16 +++- packages/server/src/shared/messages.ts | 10 +++ packages/server/src/tasks/cli.ts | 7 +- 16 files changed, 419 insertions(+), 10 deletions(-) create mode 100644 packages/server/src/server/daemon-version.test.ts create mode 100644 packages/server/src/server/daemon-version.ts create mode 100644 packages/server/src/server/package-version.ts diff --git a/packages/app/src/contexts/session-context.tsx b/packages/app/src/contexts/session-context.tsx index 88444fcc4..770198397 100644 --- a/packages/app/src/contexts/session-context.tsx +++ b/packages/app/src/contexts/session-context.tsx @@ -996,6 +996,7 @@ export function SessionProvider({ updateSessionServerInfo(serverId, { serverId: serverInfo.serverId, hostname: serverInfo.hostname, + version: serverInfo.version, ...(serverInfo.capabilities ? { capabilities: serverInfo.capabilities } : {}), diff --git a/packages/app/src/screens/settings-screen.tsx b/packages/app/src/screens/settings-screen.tsx index 92c0cb955..6753bf070 100644 --- a/packages/app/src/screens/settings-screen.tsx +++ b/packages/app/src/screens/settings-screen.tsx @@ -185,6 +185,17 @@ const styles = StyleSheet.create((theme) => ({ color: theme.colors.foregroundMuted, flexShrink: 1, }, + versionPill: { + flexDirection: "row", + alignItems: "center", + paddingHorizontal: theme.spacing[2], + paddingVertical: 4, + borderRadius: theme.borderRadius.full, + borderWidth: 1, + borderColor: theme.colors.border, + backgroundColor: theme.colors.surface3, + maxWidth: 200, + }, hostSettingsButton: { width: 28, height: 28, @@ -407,6 +418,41 @@ const styles = StyleSheet.create((theme) => ({ }, })); +function resolveAppVersion(): string | null { + const expoVersion = Constants.expoConfig?.version; + if (typeof expoVersion === "string" && expoVersion.trim().length > 0) { + return expoVersion.trim(); + } + + const manifestVersion = (Constants as any).manifest?.version; + if (typeof manifestVersion === "string" && manifestVersion.trim().length > 0) { + return manifestVersion.trim(); + } + + return null; +} + +function formatVersionBadgeText(params: { daemonVersion: string | null; appVersion: string | null }): string { + const appVersion = params.appVersion?.trim(); + if (!appVersion) { + if (Platform.OS === "web") { + return "App version unavailable"; + } + return "App unavailable"; + } + + const daemonVersion = params.daemonVersion?.trim(); + if (!daemonVersion) { + if (Platform.OS === "web") { + return `Daemon unavailable | App ${appVersion}`; + } + return `D unavailable | A ${appVersion}`; + } + if (Platform.OS === "web") { + return `Daemon ${daemonVersion} | App ${appVersion}`; + } + return `D ${daemonVersion} | A ${appVersion}`; +} export default function SettingsScreen() { const { theme } = useUnistyles(); @@ -442,7 +488,7 @@ export default function SettingsScreen() { const showDesktopPermissionSection = shouldShowDesktopPermissionSection(); const isMountedRef = useRef(true); const lastHandledEditHostRef = useRef(null); - const appVersion = Constants.expoConfig?.version ?? (Constants as any).manifest?.version ?? "0.1.0"; + const appVersion = resolveAppVersion(); const editingServerId = editingDaemon?.serverId ?? null; const editingDaemonLive = editingServerId ? daemons.find((daemon) => daemon.serverId === editingServerId) ?? null @@ -695,6 +741,7 @@ export default function SettingsScreen() { Version - {appVersion} + {appVersion ?? "Unavailable"} @@ -947,6 +995,7 @@ export default function SettingsScreen() { interface HostDetailModalProps { visible: boolean; host: HostProfile | null; + appVersion: string | null; connectionStatus: ConnectionStatus; activeConnection: ActiveConnection | null; lastError: string | null; @@ -964,6 +1013,7 @@ interface HostDetailModalProps { function HostDetailModal({ visible, host, + appVersion, connectionStatus, activeConnection, lastError, @@ -1002,6 +1052,7 @@ function HostDetailModal({ // Restart logic (moved from DaemonCard) const daemonClient = useSessionStore((state) => host ? (state.sessions[host.serverId]?.client ?? null) : null); const daemonConnection = useSessionStore((state) => host ? (state.sessions[host.serverId]?.connection ?? null) : null); + const daemonVersion = useSessionStore((state) => host ? (state.sessions[host.serverId]?.serverInfo?.version ?? null) : null); const isConnected = daemonConnection?.isConnected ?? false; const isConnectedRef = useRef(isConnected); const [isRestarting, setIsRestarting] = useState(false); @@ -1113,6 +1164,7 @@ function HostDetailModal({ text: activeConnection.display, }; })(); + const versionBadgeText = formatVersionBadgeText({ daemonVersion, appVersion }); const connectionError = typeof lastError === "string" && lastError.trim().length > 0 ? lastError.trim() : null; const handleDraftLabelChange = useCallback((nextValue: string) => { @@ -1154,6 +1206,11 @@ function HostDetailModal({ ) : null} + + + {versionBadgeText} + + {connectionError ? ( @@ -1429,6 +1486,7 @@ function ConnectionRow({ interface DaemonCardProps { daemon: HostProfile; + appVersion: string | null; connectionStatus: ConnectionStatus; activeConnection: ActiveConnection | null; lastError: string | null; @@ -1437,12 +1495,19 @@ interface DaemonCardProps { function DaemonCard({ daemon, + appVersion, connectionStatus, activeConnection, lastError, onOpenSettings, }: DaemonCardProps) { const { theme } = useUnistyles(); + const daemonVersion = useSessionStore( + useCallback( + (state) => state.sessions[daemon.serverId]?.serverInfo?.version ?? null, + [daemon.serverId] + ) + ); const statusLabel = formatConnectionStatus(connectionStatus); const statusTone = getConnectionStatusTone(connectionStatus); const statusColor = @@ -1473,6 +1538,7 @@ function DaemonCard({ text: activeConnection.display, }; })(); + const versionBadgeText = formatVersionBadgeText({ daemonVersion, appVersion }); return ( ) : null} + + + {versionBadgeText} + + [ diff --git a/packages/app/src/stores/session-store.ts b/packages/app/src/stores/session-store.ts index 4520365d5..cdc6cf8d9 100644 --- a/packages/app/src/stores/session-store.ts +++ b/packages/app/src/stores/session-store.ts @@ -156,6 +156,7 @@ export interface DaemonConnectionSnapshot { export type DaemonServerInfo = { serverId: string; hostname: string | null; + version: string | null; capabilities?: ServerCapabilities; }; @@ -455,12 +456,15 @@ export const useSessionStore = create()( const nextHostname = info.hostname?.trim() || null; const prevHostname = session.serverInfo?.hostname?.trim() || null; + const nextVersion = info.version?.trim() || null; + const prevVersion = session.serverInfo?.version?.trim() || null; const nextCapabilities = info.capabilities; const prevCapabilities = session.serverInfo?.capabilities; if ( session.serverInfo?.serverId === info.serverId && prevHostname === nextHostname && + prevVersion === nextVersion && areServerCapabilitiesEqual(prevCapabilities, nextCapabilities) ) { return prev; @@ -469,6 +473,7 @@ export const useSessionStore = create()( logSessionStoreUpdate("updateSessionServerInfo", serverId, { serverId: info.serverId, hostname: nextHostname, + version: nextVersion, }); return { @@ -480,6 +485,7 @@ export const useSessionStore = create()( serverInfo: { serverId: info.serverId, hostname: nextHostname, + version: nextVersion, ...(nextCapabilities ? { capabilities: nextCapabilities } : {}), }, }, diff --git a/packages/app/src/utils/server-info-capabilities.test.ts b/packages/app/src/utils/server-info-capabilities.test.ts index 796eb789e..7d9f33dce 100644 --- a/packages/app/src/utils/server-info-capabilities.test.ts +++ b/packages/app/src/utils/server-info-capabilities.test.ts @@ -11,6 +11,7 @@ function buildServerInfo(capabilities?: ServerCapabilities): DaemonServerInfo { return { serverId: "srv-1", hostname: "test-host", + version: "0.1.0", ...(capabilities ? { capabilities } : {}), }; } diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 9adc5e137..7a7cc77c9 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -22,8 +22,20 @@ import { withOutput } from './output/index.js' import { onboardCommand } from './commands/onboard.js' const require = createRequire(import.meta.url) -const packageJson = require('../package.json') as { version?: string } -const VERSION = typeof packageJson.version === 'string' ? packageJson.version : '0.0.0' + +type CliPackageJson = { + version?: unknown +} + +function resolveCliVersion(): string { + const packageJson = require('../package.json') as CliPackageJson + if (typeof packageJson.version === 'string' && packageJson.version.trim().length > 0) { + return packageJson.version.trim() + } + throw new Error('Unable to resolve @getpaseo/cli version from package.json.') +} + +const VERSION = resolveCliVersion() // Helper function to collect multiple option values into an array function collectMultiple(value: string, previous: string[]): string[] { diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index ce58cd97c..ce9c5b455 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -58,6 +58,7 @@ import { import { loadOrCreateDaemonKeyPair } from "./daemon-keypair.js"; import { startRelayTransport, type RelayTransportController } from "./relay-transport.js"; import { getOrCreateServerId } from "./server-id.js"; +import { resolveDaemonVersion } from "./daemon-version.js"; import type { AgentClient, AgentProvider, @@ -136,6 +137,7 @@ export async function createPaseoDaemon( rootLogger: Logger ): Promise { const logger = rootLogger.child({ module: "bootstrap" }); + const daemonVersion = resolveDaemonVersion(import.meta.url); // Acquire PID lock before expensive bootstrap work so duplicate starts fail immediately. await acquirePidLock(config.paseoHome, config.listen); @@ -496,7 +498,8 @@ export async function createPaseoDaemon( localModels: localModelConfig ?? undefined, getSpeechReadiness, }, - config.agentProviderSettings + config.agentProviderSettings, + daemonVersion ); unsubscribeSpeechReadiness = subscribeSpeechReadiness((snapshot) => { wsServer?.publishSpeechReadiness(snapshot); diff --git a/packages/server/src/server/daemon-version.test.ts b/packages/server/src/server/daemon-version.test.ts new file mode 100644 index 000000000..2aa753d56 --- /dev/null +++ b/packages/server/src/server/daemon-version.test.ts @@ -0,0 +1,71 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { + DaemonVersionResolutionError, + resolveDaemonVersion, +} from "./daemon-version.js"; + +const createdDirs: string[] = []; + +function createTempDir(): string { + const dir = mkdtempSync(path.join(tmpdir(), "paseo-daemon-version-")); + createdDirs.push(dir); + return dir; +} + +afterEach(() => { + for (const dir of createdDirs.splice(0, createdDirs.length)) { + rmSync(dir, { recursive: true, force: true }); + } +}); + +describe("resolveDaemonVersion", () => { + it("resolves server version by walking up to @getpaseo/server package.json", () => { + const root = createTempDir(); + writeFileSync( + path.join(root, "package.json"), + JSON.stringify({ name: "@getpaseo/server", version: "9.8.7" }), + "utf8" + ); + const nestedDir = path.join(root, "dist", "server"); + mkdirSync(nestedDir, { recursive: true }); + + const moduleUrl = pathToFileURL(path.join(nestedDir, "index.js")).href; + expect(resolveDaemonVersion(moduleUrl)).toBe("9.8.7"); + }); + + it("throws when @getpaseo/server package metadata cannot be resolved", () => { + const root = createTempDir(); + writeFileSync( + path.join(root, "package.json"), + JSON.stringify({ name: "not-getpaseo-server", version: "1.2.3" }), + "utf8" + ); + const nestedDir = path.join(root, "dist", "server"); + mkdirSync(nestedDir, { recursive: true }); + + const moduleUrl = pathToFileURL(path.join(nestedDir, "index.js")).href; + expect(() => resolveDaemonVersion(moduleUrl)).toThrow( + DaemonVersionResolutionError + ); + }); + + it("throws when @getpaseo/server version is missing", () => { + const root = createTempDir(); + writeFileSync( + path.join(root, "package.json"), + JSON.stringify({ name: "@getpaseo/server" }), + "utf8" + ); + const nestedDir = path.join(root, "dist", "server"); + mkdirSync(nestedDir, { recursive: true }); + + const moduleUrl = pathToFileURL(path.join(nestedDir, "index.js")).href; + expect(() => resolveDaemonVersion(moduleUrl)).toThrow( + DaemonVersionResolutionError + ); + }); +}); diff --git a/packages/server/src/server/daemon-version.ts b/packages/server/src/server/daemon-version.ts new file mode 100644 index 000000000..2bb21817c --- /dev/null +++ b/packages/server/src/server/daemon-version.ts @@ -0,0 +1,25 @@ +import { + PackageVersionResolutionError, + resolvePackageVersion, +} from "./package-version.js"; + +const SERVER_PACKAGE_NAME = "@getpaseo/server"; + +export class DaemonVersionResolutionError extends PackageVersionResolutionError {} + +export function resolveDaemonVersion(moduleUrl: string = import.meta.url): string { + try { + return resolvePackageVersion({ + moduleUrl, + packageName: SERVER_PACKAGE_NAME, + }); + } catch (error) { + if (error instanceof PackageVersionResolutionError) { + throw new DaemonVersionResolutionError({ + moduleUrl, + packageName: SERVER_PACKAGE_NAME, + }); + } + throw error; + } +} diff --git a/packages/server/src/server/dictation/dictation-stream-manager.test.ts b/packages/server/src/server/dictation/dictation-stream-manager.test.ts index e8ace8fab..5127fc4ab 100644 --- a/packages/server/src/server/dictation/dictation-stream-manager.test.ts +++ b/packages/server/src/server/dictation/dictation-stream-manager.test.ts @@ -233,4 +233,82 @@ describe("DictationStreamManager (provider-agnostic provider)", () => { expect(finishAccepted).toBeDefined(); expect(finishAccepted?.payload.timeoutMs).toBeGreaterThan(5000); }); + + it("adapts finish timeout when only uncommitted non-final transcripts are pending", async () => { + const session = new FakeRealtimeSession(); + const emitted: Array<{ type: string; payload: any }> = []; + const manager = new DictationStreamManager({ + logger: pino({ level: "silent" }), + emit: (msg) => emitted.push(msg), + sessionId: "s1", + stt: new FakeSttProvider(session), + finalTimeoutMs: 5000, + }); + + await manager.handleStart("d-uncommitted-timeout", "audio/pcm;rate=24000;bits=16"); + await manager.handleChunk({ + dictationId: "d-uncommitted-timeout", + seq: 0, + audioBase64: buildPcmBase64(2000, 2400), + format: "audio/pcm;rate=24000;bits=16", + }); + + session.emitCommitted("seg-1"); + session.emitTranscript("seg-1", "hello", true); + session.emitTranscript("seg-dangling", "hel", false); + + await manager.handleFinish("d-uncommitted-timeout", 0); + + const finishAccepted = emitted.find( + (msg) => msg.type === "dictation_stream_finish_accepted" + ); + expect(finishAccepted).toBeDefined(); + expect(finishAccepted?.payload.timeoutMs).toBeGreaterThan(5000); + }); + + it("drops dangling uncommitted non-final transcripts when finishing after silence tail clear", async () => { + vi.useFakeTimers(); + try { + const session = new FakeRealtimeSession(); + const emitted: Array<{ type: string; payload: any }> = []; + const manager = new DictationStreamManager({ + logger: pino({ level: "silent" }), + emit: (msg) => emitted.push(msg), + sessionId: "s1", + stt: new FakeSttProvider(session), + finalTimeoutMs: 5000, + }); + + await manager.handleStart("d-clear-tail", "audio/pcm;rate=24000;bits=16"); + await manager.handleChunk({ + dictationId: "d-clear-tail", + seq: 0, + audioBase64: buildPcmBase64(2000, 2400), + format: "audio/pcm;rate=24000;bits=16", + }); + + session.emitCommitted("seg-1"); + session.emitTranscript("seg-1", "hello", true); + + await manager.handleChunk({ + dictationId: "d-clear-tail", + seq: 1, + audioBase64: buildPcmBase64(0, 2400), + format: "audio/pcm;rate=24000;bits=16", + }); + session.emitTranscript("seg-dangling", "", false); + + await manager.handleFinish("d-clear-tail", 1); + await tick(); + await vi.advanceTimersByTimeAsync(5_100); + + const final = emitted.find((msg) => msg.type === "dictation_stream_final"); + const error = emitted.find((msg) => msg.type === "dictation_stream_error"); + expect(session.clearCalls).toBeGreaterThan(0); + expect(error).toBeUndefined(); + expect(final?.payload.text).toBe("hello"); + } finally { + vi.useRealTimers(); + } + }); }); diff --git a/packages/server/src/server/dictation/dictation-stream-manager.ts b/packages/server/src/server/dictation/dictation-stream-manager.ts index a9b0bd1c4..70cc8fb86 100644 --- a/packages/server/src/server/dictation/dictation-stream-manager.ts +++ b/packages/server/src/server/dictation/dictation-stream-manager.ts @@ -552,8 +552,19 @@ export class DictationStreamManager { const pendingCommittedSegments = state.committedSegmentIds.reduce((count, segmentId) => { return state.finalTranscriptSegmentIds.has(segmentId) ? count : count + 1; }, 0); + const committedSet = new Set(state.committedSegmentIds); + const pendingUncommittedTranscriptSegments = Array.from( + state.transcriptsBySegmentId.keys() + ).reduce((count, segmentId) => { + if (committedSet.has(segmentId)) { + return count; + } + return state.finalTranscriptSegmentIds.has(segmentId) ? count : count + 1; + }, 0); const pendingSegments = - pendingCommittedSegments + (state.awaitingFinalCommit ? 1 : 0); + pendingCommittedSegments + + pendingUncommittedTranscriptSegments + + (state.awaitingFinalCommit ? 1 : 0); const pendingAudioSeconds = Math.ceil( Math.max(0, state.bytesSinceCommit) / bytesPerSecond ); @@ -629,6 +640,16 @@ export class DictationStreamManager { state.bytesSinceCommit = 0; state.peakSinceCommit = 0; state.awaitingFinalCommit = false; + const droppedSegments = this.dropUncommittedNonFinalTranscripts(state); + if (droppedSegments > 0) { + this.logger.debug( + { + dictationId, + droppedSegments, + }, + "Dictation finish: dropped uncommitted non-final transcript segments after silence clear" + ); + } } else { state.awaitingFinalCommit = true; try { @@ -646,6 +667,22 @@ export class DictationStreamManager { state.finishSealed = true; } + private dropUncommittedNonFinalTranscripts(state: DictationStreamState): number { + const committedSet = new Set(state.committedSegmentIds); + let droppedCount = 0; + for (const segmentId of state.transcriptsBySegmentId.keys()) { + if (committedSet.has(segmentId)) { + continue; + } + if (state.finalTranscriptSegmentIds.has(segmentId)) { + continue; + } + state.transcriptsBySegmentId.delete(segmentId); + droppedCount += 1; + } + return droppedCount; + } + private maybeFinalizeDictationStream(dictationId: string): void { const state = this.streams.get(dictationId); if (!state) { diff --git a/packages/server/src/server/package-version.ts b/packages/server/src/server/package-version.ts new file mode 100644 index 000000000..f330df9d0 --- /dev/null +++ b/packages/server/src/server/package-version.ts @@ -0,0 +1,64 @@ +import { existsSync, readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +type ResolvePackageVersionParams = { + moduleUrl?: string; + packageName: string; +}; + +type PackageJson = { + name?: unknown; + version?: unknown; +}; + +export class PackageVersionResolutionError extends Error { + constructor(params: { moduleUrl: string; packageName: string }) { + super( + `Unable to resolve ${params.packageName} version from module URL ${params.moduleUrl}.` + ); + this.name = "PackageVersionResolutionError"; + } +} + +export function resolvePackageVersion(params: ResolvePackageVersionParams): string { + const moduleUrl = params.moduleUrl ?? import.meta.url; + let currentDir = path.dirname(fileURLToPath(moduleUrl)); + + while (true) { + const packageJsonPath = path.join(currentDir, "package.json"); + if (existsSync(packageJsonPath)) { + try { + const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")) as PackageJson; + if (packageJson.name === params.packageName) { + if ( + typeof packageJson.version === "string" && + packageJson.version.trim().length > 0 + ) { + return packageJson.version.trim(); + } + throw new PackageVersionResolutionError({ + moduleUrl, + packageName: params.packageName, + }); + } + } catch (error) { + if (error instanceof PackageVersionResolutionError) { + throw error; + } + // Continue searching parent directories. + } + } + + const parentDir = path.dirname(currentDir); + if (parentDir === currentDir) { + break; + } + currentDir = parentDir; + } + + throw new PackageVersionResolutionError({ + moduleUrl, + packageName: params.packageName, + }); +} diff --git a/packages/server/src/server/websocket-server.notifications.test.ts b/packages/server/src/server/websocket-server.notifications.test.ts index 4a8ffb00b..340ece2b2 100644 --- a/packages/server/src/server/websocket-server.notifications.test.ts +++ b/packages/server/src/server/websocket-server.notifications.test.ts @@ -75,7 +75,13 @@ function createServer(agentManagerOverrides?: Record) { {} as any, "/tmp/paseo-test", async () => ({} as any), - { allowedOrigins: new Set() } + { allowedOrigins: new Set() }, + undefined, + undefined, + undefined, + undefined, + undefined, + "1.2.3-test" ); return { server, agentManager }; diff --git a/packages/server/src/server/websocket-server.relay-reconnect.test.ts b/packages/server/src/server/websocket-server.relay-reconnect.test.ts index 15bec1360..201963872 100644 --- a/packages/server/src/server/websocket-server.relay-reconnect.test.ts +++ b/packages/server/src/server/websocket-server.relay-reconnect.test.ts @@ -79,6 +79,8 @@ import { import { parseServerInfoStatusPayload } from "./messages.js"; import type { SpeechReadinessSnapshot } from "./speech/speech-runtime.js"; +const TEST_DAEMON_VERSION = "1.2.3-test"; + class MockSocket { readyState = 1; sent: unknown[] = []; @@ -157,7 +159,9 @@ function createServer(options?: { speechReadiness?: SpeechReadinessSnapshot | nu ? { getSpeechReadiness: () => speechReadiness, } - : undefined + : undefined, + undefined, + TEST_DAEMON_VERSION ); } @@ -318,6 +322,7 @@ describe("relay external socket reconnect behavior", () => { const payload = parseServerInfoStatusPayload(envelope.message?.payload); expect(payload?.status).toBe("server_info"); + expect(payload?.version).toBe(TEST_DAEMON_VERSION); expect(payload?.capabilities?.voice?.dictation.enabled).toBe( speechReadiness.dictation.enabled ); diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index 15b056642..4c5c0a3be 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -158,6 +158,13 @@ type SessionConnection = { const EXTERNAL_SESSION_DISCONNECT_GRACE_MS = 90_000; +export class MissingDaemonVersionError extends Error { + constructor() { + super("VoiceAssistantWebSocketServer requires a non-empty daemonVersion."); + this.name = "MissingDaemonVersionError"; + } +} + /** * WebSocket server that only accepts sockets + parses/forwards messages to the session layer. */ @@ -168,6 +175,7 @@ export class VoiceAssistantWebSocketServer { private readonly externalSessionsByKey: Map = new Map(); private clientIdCounter = 0; private readonly serverId: string; + private readonly daemonVersion: string; private readonly agentManager: AgentManager; private readonly agentStorage: AgentStorage; private readonly downloadTokenStore: DownloadTokenStore; @@ -229,10 +237,15 @@ export class VoiceAssistantWebSocketServer { }; getSpeechReadiness?: () => SpeechReadinessSnapshot; }, - agentProviderRuntimeSettings?: AgentProviderRuntimeSettingsMap + agentProviderRuntimeSettings?: AgentProviderRuntimeSettingsMap, + daemonVersion?: string ) { this.logger = logger.child({ module: "websocket-server" }); this.serverId = serverId; + if (typeof daemonVersion !== "string" || daemonVersion.trim().length === 0) { + throw new MissingDaemonVersionError(); + } + this.daemonVersion = daemonVersion.trim(); this.agentManager = agentManager; this.agentStorage = agentStorage; this.downloadTokenStore = downloadTokenStore; @@ -509,6 +522,7 @@ export class VoiceAssistantWebSocketServer { status: "server_info", serverId: this.serverId, hostname: getHostname(), + version: this.daemonVersion, ...(this.serverCapabilities ? { capabilities: this.serverCapabilities } : {}), }; } diff --git a/packages/server/src/shared/messages.ts b/packages/server/src/shared/messages.ts index 03359b139..0ac572de0 100644 --- a/packages/server/src/shared/messages.ts +++ b/packages/server/src/shared/messages.ts @@ -1218,6 +1218,14 @@ const ServerInfoHostnameSchema = z.unknown().transform((value): string | null => return trimmed.length > 0 ? trimmed : null }) +const ServerInfoVersionSchema = z.unknown().transform((value): string | null => { + if (typeof value !== 'string') { + return null + } + const trimmed = value.trim() + return trimmed.length > 0 ? trimmed : null +}) + const ServerCapabilitiesFromUnknownSchema = z .unknown() .optional() @@ -1237,12 +1245,14 @@ export const ServerInfoStatusPayloadSchema = z status: z.literal('server_info'), serverId: z.string().trim().min(1), hostname: ServerInfoHostnameSchema.optional(), + version: ServerInfoVersionSchema.optional(), capabilities: ServerCapabilitiesFromUnknownSchema, }) .passthrough() .transform((payload) => ({ ...payload, hostname: payload.hostname ?? null, + version: payload.version ?? null, })) export const StatusMessageSchema = z.object({ diff --git a/packages/server/src/tasks/cli.ts b/packages/server/src/tasks/cli.ts index c1057fb23..843a0d4bc 100644 --- a/packages/server/src/tasks/cli.ts +++ b/packages/server/src/tasks/cli.ts @@ -8,10 +8,15 @@ import { computeExecutionOrder, buildSortedChildrenMap, } from "./execution-order.js"; +import { resolvePackageVersion } from "../server/package-version.js"; import type { AgentType, ModelName, Task } from "./types.js"; const TASKS_DIR = resolve(process.cwd(), ".tasks"); const store = new FileTaskStore(TASKS_DIR); +const TASK_CLI_VERSION = resolvePackageVersion({ + moduleUrl: import.meta.url, + packageName: "@getpaseo/server", +}); async function readStdin(): Promise { const chunks: Buffer[] = []; @@ -24,7 +29,7 @@ async function readStdin(): Promise { const program = new Command() .name("task") .description("Minimal task management with dependency tracking") - .version("0.1.0") + .version(TASK_CLI_VERSION) .addHelpText( "after", `