fix(dictation): avoid timeout from dangling non-final segments
This commit is contained in:
@@ -996,6 +996,7 @@ export function SessionProvider({
|
||||
updateSessionServerInfo(serverId, {
|
||||
serverId: serverInfo.serverId,
|
||||
hostname: serverInfo.hostname,
|
||||
version: serverInfo.version,
|
||||
...(serverInfo.capabilities
|
||||
? { capabilities: serverInfo.capabilities }
|
||||
: {}),
|
||||
|
||||
@@ -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<string | null>(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() {
|
||||
<DaemonCard
|
||||
key={daemon.serverId}
|
||||
daemon={daemon}
|
||||
appVersion={appVersion}
|
||||
connectionStatus={connectionStatus}
|
||||
activeConnection={activeConnection}
|
||||
lastError={lastConnectionError}
|
||||
@@ -829,6 +876,7 @@ export default function SettingsScreen() {
|
||||
<HostDetailModal
|
||||
visible={Boolean(editingDaemonLive)}
|
||||
host={editingDaemonLive}
|
||||
appVersion={appVersion}
|
||||
connectionStatus={editingServerId ? (connectionStates.get(editingServerId)?.status ?? "idle") : "idle"}
|
||||
activeConnection={editingServerId ? (connectionStates.get(editingServerId)?.activeConnection ?? null) : null}
|
||||
lastError={editingServerId ? (connectionStates.get(editingServerId)?.lastError ?? null) : null}
|
||||
@@ -934,7 +982,7 @@ export default function SettingsScreen() {
|
||||
<View style={styles.audioRowContent}>
|
||||
<Text style={styles.audioRowTitle}>Version</Text>
|
||||
</View>
|
||||
<Text style={styles.aboutValue}>{appVersion}</Text>
|
||||
<Text style={styles.aboutValue}>{appVersion ?? "Unavailable"}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
@@ -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({
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
<View style={styles.versionPill}>
|
||||
<Text style={styles.connectionText} numberOfLines={1}>
|
||||
{versionBadgeText}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
{connectionError ? (
|
||||
<Text style={{ color: theme.colors.palette.red[300], fontSize: theme.fontSize.xs }}>
|
||||
@@ -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 (
|
||||
<View
|
||||
@@ -1500,6 +1566,11 @@ function DaemonCard({
|
||||
) : null}
|
||||
</View>
|
||||
) : null}
|
||||
<View style={styles.versionPill}>
|
||||
<Text style={styles.connectionText} numberOfLines={1}>
|
||||
{versionBadgeText}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<Pressable
|
||||
style={({ pressed, hovered }) => [
|
||||
|
||||
@@ -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<SessionStore>()(
|
||||
|
||||
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<SessionStore>()(
|
||||
logSessionStoreUpdate("updateSessionServerInfo", serverId, {
|
||||
serverId: info.serverId,
|
||||
hostname: nextHostname,
|
||||
version: nextVersion,
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -480,6 +485,7 @@ export const useSessionStore = create<SessionStore>()(
|
||||
serverInfo: {
|
||||
serverId: info.serverId,
|
||||
hostname: nextHostname,
|
||||
version: nextVersion,
|
||||
...(nextCapabilities ? { capabilities: nextCapabilities } : {}),
|
||||
},
|
||||
},
|
||||
|
||||
@@ -11,6 +11,7 @@ function buildServerInfo(capabilities?: ServerCapabilities): DaemonServerInfo {
|
||||
return {
|
||||
serverId: "srv-1",
|
||||
hostname: "test-host",
|
||||
version: "0.1.0",
|
||||
...(capabilities ? { capabilities } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
+14
-2
@@ -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[] {
|
||||
|
||||
@@ -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<PaseoDaemon> {
|
||||
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);
|
||||
|
||||
@@ -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
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -75,7 +75,13 @@ function createServer(agentManagerOverrides?: Record<string, unknown>) {
|
||||
{} 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 };
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -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<string, SessionConnection> = 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 } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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<string> {
|
||||
const chunks: Buffer[] = [];
|
||||
@@ -24,7 +29,7 @@ async function readStdin(): Promise<string> {
|
||||
const program = new Command()
|
||||
.name("task")
|
||||
.description("Minimal task management with dependency tracking")
|
||||
.version("0.1.0")
|
||||
.version(TASK_CLI_VERSION)
|
||||
.addHelpText(
|
||||
"after",
|
||||
`
|
||||
|
||||
Reference in New Issue
Block a user