mirror of
https://github.com/getpaseo/paseo.git
synced 2026-08-14 20:32:46 +00:00
feat: add onboarding command and migrate git diff to checkout API
This commit is contained in:
22
package-lock.json
generated
22
package-lock.json
generated
@@ -1385,6 +1385,27 @@
|
|||||||
"react-native": "*"
|
"react-native": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@clack/core": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@clack/core/-/core-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-Orf9Ltr5NeiEuVJS8Rk2XTw3IxNC2Bic3ash7GgYeA8LJ/zmSNpSQ/m5UAhe03lA6KFgklzZ5KTHs4OAMA/SAQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"picocolors": "^1.0.0",
|
||||||
|
"sisteransi": "^1.0.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@clack/prompts": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@clack/prompts/-/prompts-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-rWPXg9UaCFqErJVQ+MecOaWsozjaxol4yjnmYcGNipAWzdaWa2x+VJmKfGq7L0APwBohQOYdHC+9RO4qRXej+A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@clack/core": "1.0.0",
|
||||||
|
"picocolors": "^1.0.0",
|
||||||
|
"sisteransi": "^1.0.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@cloudflare/kv-asset-handler": {
|
"node_modules/@cloudflare/kv-asset-handler": {
|
||||||
"version": "0.4.1",
|
"version": "0.4.1",
|
||||||
"license": "MIT OR Apache-2.0",
|
"license": "MIT OR Apache-2.0",
|
||||||
@@ -19899,6 +19920,7 @@
|
|||||||
"name": "@getpaseo/cli",
|
"name": "@getpaseo/cli",
|
||||||
"version": "0.1.2",
|
"version": "0.1.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@clack/prompts": "^1.0.0",
|
||||||
"@getpaseo/relay": "0.1.2",
|
"@getpaseo/relay": "0.1.2",
|
||||||
"@getpaseo/server": "0.1.2",
|
"@getpaseo/server": "0.1.2",
|
||||||
"chalk": "^5.3.0",
|
"chalk": "^5.3.0",
|
||||||
|
|||||||
@@ -506,6 +506,7 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
|
|||||||
});
|
});
|
||||||
const {
|
const {
|
||||||
status: prStatus,
|
status: prStatus,
|
||||||
|
githubFeaturesEnabled,
|
||||||
payloadError: prPayloadError,
|
payloadError: prPayloadError,
|
||||||
refresh: refreshPrStatus,
|
refresh: refreshPrStatus,
|
||||||
} = useCheckoutPrStatusQuery({
|
} = useCheckoutPrStatusQuery({
|
||||||
@@ -840,7 +841,7 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
|
|||||||
const diffErrorMessage =
|
const diffErrorMessage =
|
||||||
diffPayloadError?.message ??
|
diffPayloadError?.message ??
|
||||||
(isDiffError && diffError instanceof Error ? diffError.message : null);
|
(isDiffError && diffError instanceof Error ? diffError.message : null);
|
||||||
const prErrorMessage = prPayloadError?.message ?? null;
|
const prErrorMessage = githubFeaturesEnabled ? prPayloadError?.message ?? null : null;
|
||||||
const branchLabel =
|
const branchLabel =
|
||||||
gitStatus?.currentBranch && gitStatus.currentBranch !== "HEAD"
|
gitStatus?.currentBranch && gitStatus.currentBranch !== "HEAD"
|
||||||
? gitStatus.currentBranch
|
? gitStatus.currentBranch
|
||||||
@@ -993,7 +994,7 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// View PR - when PR exists
|
// View PR - when PR exists
|
||||||
if (hasPullRequest && prStatus?.url) {
|
if (githubFeaturesEnabled && hasPullRequest && prStatus?.url) {
|
||||||
const prUrl = prStatus.url;
|
const prUrl = prStatus.url;
|
||||||
allActions.set("view-pr", {
|
allActions.set("view-pr", {
|
||||||
id: "view-pr",
|
id: "view-pr",
|
||||||
@@ -1008,7 +1009,7 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Create PR - when ahead of base and no PR
|
// Create PR - when ahead of base and no PR
|
||||||
if (aheadCount > 0 && !hasPullRequest) {
|
if (githubFeaturesEnabled && aheadCount > 0 && !hasPullRequest) {
|
||||||
allActions.set("create-pr", {
|
allActions.set("create-pr", {
|
||||||
id: "create-pr",
|
id: "create-pr",
|
||||||
label: "Create PR",
|
label: "Create PR",
|
||||||
@@ -1112,7 +1113,7 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
|
|||||||
|
|
||||||
return { primary, secondary, menu };
|
return { primary, secondary, menu };
|
||||||
}, [
|
}, [
|
||||||
isGit, hasRemote, hasPullRequest, prStatus?.url, aheadCount, isPaseoOwnedWorktree, isOnBaseBranch,
|
isGit, hasRemote, hasPullRequest, prStatus?.url, aheadCount, isPaseoOwnedWorktree, isOnBaseBranch, githubFeaturesEnabled,
|
||||||
hasUncommittedChanges, aheadOfOrigin, shipDefault, baseRefLabel,
|
hasUncommittedChanges, aheadOfOrigin, shipDefault, baseRefLabel,
|
||||||
commitDisabled, pushDisabled, prDisabled, mergeDisabled, mergeFromBaseDisabled, archiveDisabled,
|
commitDisabled, pushDisabled, prDisabled, mergeDisabled, mergeFromBaseDisabled, archiveDisabled,
|
||||||
commitStatus, pushStatus, prCreateStatus, mergeStatus, mergeFromBaseStatus, archiveStatus,
|
commitStatus, pushStatus, prCreateStatus, mergeStatus, mergeFromBaseStatus, archiveStatus,
|
||||||
|
|||||||
@@ -322,7 +322,6 @@ export function SessionProvider({
|
|||||||
const setPendingPermissions = useSessionStore(
|
const setPendingPermissions = useSessionStore(
|
||||||
(state) => state.setPendingPermissions
|
(state) => state.setPendingPermissions
|
||||||
);
|
);
|
||||||
const setGitDiffs = useSessionStore((state) => state.setGitDiffs);
|
|
||||||
const setFileExplorer = useSessionStore((state) => state.setFileExplorer);
|
const setFileExplorer = useSessionStore((state) => state.setFileExplorer);
|
||||||
const clearDraftInput = useDraftStore((state) => state.clearDraftInput);
|
const clearDraftInput = useDraftStore((state) => state.clearDraftInput);
|
||||||
const setQueuedMessages = useSessionStore((state) => state.setQueuedMessages);
|
const setQueuedMessages = useSessionStore((state) => state.setQueuedMessages);
|
||||||
@@ -666,22 +665,6 @@ export function SessionProvider({
|
|||||||
[serverId, setFileExplorer]
|
[serverId, setFileExplorer]
|
||||||
);
|
);
|
||||||
|
|
||||||
const gitDiffMutation = useMutation({
|
|
||||||
mutationFn: async ({ agentId }: { agentId: string }) => {
|
|
||||||
if (!agentId) {
|
|
||||||
throw new Error("Agent id is required");
|
|
||||||
}
|
|
||||||
if (!client) {
|
|
||||||
throw new Error("Daemon client unavailable");
|
|
||||||
}
|
|
||||||
const payload = await client.getGitDiff(agentId);
|
|
||||||
if (payload.error) {
|
|
||||||
throw new Error(payload.error);
|
|
||||||
}
|
|
||||||
return { agentId: payload.agentId, diff: payload.diff ?? "" };
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const refreshAgentMutation = useMutation({
|
const refreshAgentMutation = useMutation({
|
||||||
mutationFn: async ({ agentId }: { agentId: string }) => {
|
mutationFn: async ({ agentId }: { agentId: string }) => {
|
||||||
if (!agentId) {
|
if (!agentId) {
|
||||||
@@ -1275,15 +1258,6 @@ export function SessionProvider({
|
|||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
|
|
||||||
setGitDiffs(serverId, (prev) => {
|
|
||||||
if (!prev.has(agentId)) {
|
|
||||||
return prev;
|
|
||||||
}
|
|
||||||
const next = new Map(prev);
|
|
||||||
next.delete(agentId);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
|
|
||||||
setFileExplorer(serverId, (prev) => {
|
setFileExplorer(serverId, (prev) => {
|
||||||
if (!prev.has(agentId)) {
|
if (!prev.has(agentId)) {
|
||||||
return prev;
|
return prev;
|
||||||
@@ -1343,7 +1317,6 @@ export function SessionProvider({
|
|||||||
setAgents,
|
setAgents,
|
||||||
setAgentLastActivity,
|
setAgentLastActivity,
|
||||||
setPendingPermissions,
|
setPendingPermissions,
|
||||||
setGitDiffs,
|
|
||||||
setFileExplorer,
|
setFileExplorer,
|
||||||
setHasHydratedAgents,
|
setHasHydratedAgents,
|
||||||
updateConnectionStatus,
|
updateConnectionStatus,
|
||||||
@@ -1635,24 +1608,6 @@ export function SessionProvider({
|
|||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
const requestGitDiff = useCallback(
|
|
||||||
(agentId: string) => {
|
|
||||||
gitDiffMutation
|
|
||||||
.mutateAsync({ agentId })
|
|
||||||
.then((result) => {
|
|
||||||
setGitDiffs(serverId, (prev) =>
|
|
||||||
new Map(prev).set(result.agentId, result.diff)
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
setGitDiffs(serverId, (prev) =>
|
|
||||||
new Map(prev).set(agentId, `Error: ${error.message}`)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[serverId, gitDiffMutation, setGitDiffs]
|
|
||||||
);
|
|
||||||
|
|
||||||
const requestDirectoryListing = useCallback(
|
const requestDirectoryListing = useCallback(
|
||||||
(agentId: string, path: string, options?: { recordHistory?: boolean }) => {
|
(agentId: string, path: string, options?: { recordHistory?: boolean }) => {
|
||||||
const normalizedPath = path && path.length > 0 ? path : ".";
|
const normalizedPath = path && path.length > 0 ? path : ".";
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ export function useCheckoutPrStatusQuery({
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
status: query.data?.status ?? null,
|
status: query.data?.status ?? null,
|
||||||
|
githubFeaturesEnabled: query.data?.githubFeaturesEnabled ?? true,
|
||||||
payloadError: query.data?.error ?? null,
|
payloadError: query.data?.error ?? null,
|
||||||
isLoading: query.isLoading,
|
isLoading: query.isLoading,
|
||||||
isFetching: query.isFetching,
|
isFetching: query.isFetching,
|
||||||
|
|||||||
@@ -1,70 +0,0 @@
|
|||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
||||||
import { useCallback, useEffect } from "react";
|
|
||||||
import { UnistylesRuntime } from "react-native-unistyles";
|
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
|
||||||
import { usePanelStore } from "@/stores/panel-store";
|
|
||||||
|
|
||||||
const GIT_DIFF_STALE_TIME = 30_000;
|
|
||||||
|
|
||||||
function gitDiffQueryKey(serverId: string, agentId: string) {
|
|
||||||
return ["gitDiff", serverId, agentId] as const;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface UseGitDiffQueryOptions {
|
|
||||||
serverId: string;
|
|
||||||
agentId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useGitDiffQuery({ serverId, agentId }: UseGitDiffQueryOptions) {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const client = useSessionStore(
|
|
||||||
(state) => state.sessions[serverId]?.client ?? null
|
|
||||||
);
|
|
||||||
const isConnected = useSessionStore(
|
|
||||||
(state) => state.sessions[serverId]?.connection.isConnected ?? false
|
|
||||||
);
|
|
||||||
const isMobile =
|
|
||||||
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
|
||||||
const mobileView = usePanelStore((state) => state.mobileView);
|
|
||||||
const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen);
|
|
||||||
const explorerTab = usePanelStore((state) => state.explorerTab);
|
|
||||||
const isOpen = isMobile ? mobileView === "file-explorer" : desktopFileExplorerOpen;
|
|
||||||
|
|
||||||
const query = useQuery({
|
|
||||||
queryKey: gitDiffQueryKey(serverId, agentId),
|
|
||||||
queryFn: async () => {
|
|
||||||
if (!client) {
|
|
||||||
throw new Error("Daemon client not available");
|
|
||||||
}
|
|
||||||
const response = await client.getGitDiff(agentId);
|
|
||||||
return response.diff;
|
|
||||||
},
|
|
||||||
enabled: !!client && isConnected && !!agentId,
|
|
||||||
staleTime: GIT_DIFF_STALE_TIME,
|
|
||||||
refetchInterval: 10_000,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Revalidate when sidebar opens with "changes" tab active
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isOpen || explorerTab !== "changes" || !agentId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Invalidate to trigger background refetch (shows stale data while fetching)
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: gitDiffQueryKey(serverId, agentId),
|
|
||||||
});
|
|
||||||
}, [isOpen, explorerTab, serverId, agentId, queryClient]);
|
|
||||||
|
|
||||||
const refresh = useCallback(() => {
|
|
||||||
return query.refetch();
|
|
||||||
}, [query]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
diff: query.data ?? null,
|
|
||||||
isLoading: query.isLoading,
|
|
||||||
isFetching: query.isFetching,
|
|
||||||
isError: query.isError,
|
|
||||||
error: query.error,
|
|
||||||
refresh,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -201,9 +201,6 @@ export interface SessionState {
|
|||||||
// Permissions
|
// Permissions
|
||||||
pendingPermissions: Map<string, PendingPermission>;
|
pendingPermissions: Map<string, PendingPermission>;
|
||||||
|
|
||||||
// Git diffs
|
|
||||||
gitDiffs: Map<string, string>;
|
|
||||||
|
|
||||||
// File explorer
|
// File explorer
|
||||||
fileExplorer: Map<string, AgentFileExplorerState>;
|
fileExplorer: Map<string, AgentFileExplorerState>;
|
||||||
|
|
||||||
@@ -258,9 +255,6 @@ interface SessionStoreActions {
|
|||||||
// Permissions
|
// Permissions
|
||||||
setPendingPermissions: (serverId: string, perms: Map<string, PendingPermission> | ((prev: Map<string, PendingPermission>) => Map<string, PendingPermission>)) => void;
|
setPendingPermissions: (serverId: string, perms: Map<string, PendingPermission> | ((prev: Map<string, PendingPermission>) => Map<string, PendingPermission>)) => void;
|
||||||
|
|
||||||
// Git diffs
|
|
||||||
setGitDiffs: (serverId: string, diffs: Map<string, string> | ((prev: Map<string, string>) => Map<string, string>)) => void;
|
|
||||||
|
|
||||||
// File explorer
|
// File explorer
|
||||||
setFileExplorer: (serverId: string, state: Map<string, AgentFileExplorerState> | ((prev: Map<string, AgentFileExplorerState>) => Map<string, AgentFileExplorerState>)) => void;
|
setFileExplorer: (serverId: string, state: Map<string, AgentFileExplorerState> | ((prev: Map<string, AgentFileExplorerState>) => Map<string, AgentFileExplorerState>)) => void;
|
||||||
|
|
||||||
@@ -332,7 +326,6 @@ function createInitialSessionState(serverId: string, client: DaemonClient, audio
|
|||||||
initializingAgents: new Map(),
|
initializingAgents: new Map(),
|
||||||
agents: new Map(),
|
agents: new Map(),
|
||||||
pendingPermissions: new Map(),
|
pendingPermissions: new Map(),
|
||||||
gitDiffs: new Map(),
|
|
||||||
fileExplorer: new Map(),
|
fileExplorer: new Map(),
|
||||||
queuedMessages: new Map(),
|
queuedMessages: new Map(),
|
||||||
};
|
};
|
||||||
@@ -745,28 +738,6 @@ export const useSessionStore = create<SessionStore>()(
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
// Git diffs
|
|
||||||
setGitDiffs: (serverId, diffs) => {
|
|
||||||
set((prev) => {
|
|
||||||
const session = prev.sessions[serverId];
|
|
||||||
if (!session) {
|
|
||||||
return prev;
|
|
||||||
}
|
|
||||||
const nextDiffs = typeof diffs === "function" ? diffs(session.gitDiffs) : diffs;
|
|
||||||
if (session.gitDiffs === nextDiffs) {
|
|
||||||
return prev;
|
|
||||||
}
|
|
||||||
logSessionStoreUpdate("setGitDiffs", serverId, { count: nextDiffs.size });
|
|
||||||
return {
|
|
||||||
...prev,
|
|
||||||
sessions: {
|
|
||||||
...prev.sessions,
|
|
||||||
[serverId]: { ...session, gitDiffs: nextDiffs },
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
// File explorer
|
// File explorer
|
||||||
setFileExplorer: (serverId, state) => {
|
setFileExplorer: (serverId, state) => {
|
||||||
set((prev) => {
|
set((prev) => {
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
"test:e2e:lifecycle": "npx tsx tests/e2e/agent-lifecycle.test.ts"
|
"test:e2e:lifecycle": "npx tsx tests/e2e/agent-lifecycle.test.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@clack/prompts": "^1.0.0",
|
||||||
"@getpaseo/relay": "0.1.2",
|
"@getpaseo/relay": "0.1.2",
|
||||||
"@getpaseo/server": "0.1.2",
|
"@getpaseo/server": "0.1.2",
|
||||||
"chalk": "^5.3.0",
|
"chalk": "^5.3.0",
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { runWaitCommand } from './commands/agent/wait.js'
|
|||||||
import { runAttachCommand } from './commands/agent/attach.js'
|
import { runAttachCommand } from './commands/agent/attach.js'
|
||||||
import { runUpdateCommand } from './commands/agent/update.js'
|
import { runUpdateCommand } from './commands/agent/update.js'
|
||||||
import { withOutput } from './output/index.js'
|
import { withOutput } from './output/index.js'
|
||||||
|
import { onboardCommand } from './commands/onboard.js'
|
||||||
|
|
||||||
const VERSION = '0.1.0'
|
const VERSION = '0.1.0'
|
||||||
|
|
||||||
@@ -142,6 +143,7 @@ export function createCli(): Command {
|
|||||||
.action(withOutput(runUpdateCommand))
|
.action(withOutput(runUpdateCommand))
|
||||||
|
|
||||||
// Top-level local daemon shortcuts
|
// Top-level local daemon shortcuts
|
||||||
|
program.addCommand(onboardCommand())
|
||||||
program.addCommand(daemonStartCommand())
|
program.addCommand(daemonStartCommand())
|
||||||
|
|
||||||
program
|
program
|
||||||
|
|||||||
693
packages/cli/src/commands/onboard.ts
Normal file
693
packages/cli/src/commands/onboard.ts
Normal file
@@ -0,0 +1,693 @@
|
|||||||
|
import { cancel, confirm, intro, isCancel, log, note, outro, spinner } from '@clack/prompts'
|
||||||
|
import { Command } from 'commander'
|
||||||
|
import { writeFileSync } from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
import {
|
||||||
|
ensureLocalSpeechModels,
|
||||||
|
generateLocalPairingOffer,
|
||||||
|
loadConfig,
|
||||||
|
loadPersistedConfig,
|
||||||
|
type LocalSpeechModelId,
|
||||||
|
type CliConfigOverrides,
|
||||||
|
type PersistedConfig,
|
||||||
|
} from '@getpaseo/server'
|
||||||
|
import {
|
||||||
|
resolveLocalPaseoHome,
|
||||||
|
resolveLocalDaemonState,
|
||||||
|
resolveTcpHostFromListen,
|
||||||
|
startLocalDaemonDetached,
|
||||||
|
tailDaemonLog,
|
||||||
|
type DaemonStartOptions,
|
||||||
|
} from './daemon/local-daemon.js'
|
||||||
|
import { tryConnectToDaemon } from '../utils/client.js'
|
||||||
|
|
||||||
|
interface OnboardOptions extends DaemonStartOptions {
|
||||||
|
timeout?: string
|
||||||
|
voice?: 'ask' | 'enable' | 'disable'
|
||||||
|
}
|
||||||
|
|
||||||
|
type OnboardPersistedConfig = PersistedConfig & {
|
||||||
|
providers?: PersistedConfig['providers'] & {
|
||||||
|
local?: PersistedConfig['providers'] extends { local?: infer T } ? T : { autoDownload?: boolean }
|
||||||
|
}
|
||||||
|
features?: PersistedConfig['features'] & {
|
||||||
|
dictation?: PersistedConfig['features'] extends { dictation?: infer T }
|
||||||
|
? T & { enabled?: boolean }
|
||||||
|
: { enabled?: boolean }
|
||||||
|
voiceMode?: PersistedConfig['features'] extends { voiceMode?: infer T }
|
||||||
|
? T & { enabled?: boolean }
|
||||||
|
: { enabled?: boolean }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_READY_TIMEOUT_MS = 10 * 60 * 1000
|
||||||
|
|
||||||
|
class OnboardCancelledError extends Error {}
|
||||||
|
|
||||||
|
const plainNoteFormat = (line: string): string => line
|
||||||
|
|
||||||
|
function renderNote(message: string, title: string): void {
|
||||||
|
note(message, title, { format: plainNoteFormat })
|
||||||
|
}
|
||||||
|
|
||||||
|
function sleep(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
setTimeout(resolve, ms)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTimeoutMs(raw: string | undefined): number {
|
||||||
|
if (!raw || raw.trim().length === 0) {
|
||||||
|
return DEFAULT_READY_TIMEOUT_MS
|
||||||
|
}
|
||||||
|
|
||||||
|
const seconds = Number(raw)
|
||||||
|
if (!Number.isFinite(seconds) || seconds <= 0) {
|
||||||
|
throw new Error(`Invalid timeout value: ${raw}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.ceil(seconds * 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
function toCliOverrides(options: DaemonStartOptions): CliConfigOverrides {
|
||||||
|
const cliOverrides: CliConfigOverrides = {}
|
||||||
|
|
||||||
|
if (options.listen) {
|
||||||
|
cliOverrides.listen = options.listen
|
||||||
|
} else if (options.port) {
|
||||||
|
cliOverrides.listen = `127.0.0.1:${options.port}`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.relay === false) {
|
||||||
|
cliOverrides.relayEnabled = false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.allowedHosts) {
|
||||||
|
const raw = options.allowedHosts.trim()
|
||||||
|
cliOverrides.allowedHosts =
|
||||||
|
raw.toLowerCase() === 'true'
|
||||||
|
? true
|
||||||
|
: raw.split(',').map(host => host.trim()).filter(Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.mcp === false) {
|
||||||
|
cliOverrides.mcpEnabled = false
|
||||||
|
}
|
||||||
|
|
||||||
|
return cliOverrides
|
||||||
|
}
|
||||||
|
|
||||||
|
function savePersistedConfig(paseoHome: string, config: OnboardPersistedConfig): void {
|
||||||
|
const configPath = path.join(paseoHome, 'config.json')
|
||||||
|
writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyVoiceSelection(config: OnboardPersistedConfig, enabled: boolean): OnboardPersistedConfig {
|
||||||
|
return {
|
||||||
|
...config,
|
||||||
|
providers: {
|
||||||
|
...config.providers,
|
||||||
|
local: {
|
||||||
|
...config.providers?.local,
|
||||||
|
autoDownload: enabled,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
features: {
|
||||||
|
...config.features,
|
||||||
|
dictation: {
|
||||||
|
...config.features?.dictation,
|
||||||
|
enabled,
|
||||||
|
},
|
||||||
|
voiceMode: {
|
||||||
|
...config.features?.voiceMode,
|
||||||
|
enabled,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolvePersistedVoiceSelection(config: OnboardPersistedConfig): boolean | null {
|
||||||
|
const voiceModeEnabled = config.features?.voiceMode?.enabled
|
||||||
|
if (typeof voiceModeEnabled === 'boolean') {
|
||||||
|
return voiceModeEnabled
|
||||||
|
}
|
||||||
|
|
||||||
|
const dictationEnabled = config.features?.dictation?.enabled
|
||||||
|
if (typeof dictationEnabled === 'boolean') {
|
||||||
|
return dictationEnabled
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveVoiceSelection(mode: OnboardOptions['voice']): Promise<boolean> {
|
||||||
|
if (mode === 'enable') {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (mode === 'disable') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
||||||
|
log.message('Non-interactive terminal detected; voice setup defaults to disabled.')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const answer = await confirm({
|
||||||
|
message: 'Enable voice features? (downloads local STT/TTS models now)',
|
||||||
|
active: 'Yes',
|
||||||
|
inactive: 'No',
|
||||||
|
initialValue: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (isCancel(answer)) {
|
||||||
|
throw new OnboardCancelledError('Onboarding cancelled by user.')
|
||||||
|
}
|
||||||
|
|
||||||
|
return answer
|
||||||
|
}
|
||||||
|
|
||||||
|
type DownloadProgress = {
|
||||||
|
modelId: string | null
|
||||||
|
pct: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
type LocalModelDownloadProgress = {
|
||||||
|
modelId: string | null
|
||||||
|
pct: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
type LocalSpeechDownloadLogger = {
|
||||||
|
child: (_bindings: Record<string, unknown>) => LocalSpeechDownloadLogger
|
||||||
|
info: (obj?: unknown, msg?: string) => void
|
||||||
|
error: (_obj?: unknown, _msg?: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
type LocalSpeechDownloadEvent =
|
||||||
|
| {
|
||||||
|
type: 'progress'
|
||||||
|
progress: LocalModelDownloadProgress
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'phase'
|
||||||
|
phase: 'extracting' | 'verifying' | 'finalizing' | 'completed'
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveRequiredLocalModelIds(config: ReturnType<typeof loadConfig>): LocalSpeechModelId[] {
|
||||||
|
const providers = config.speech?.providers
|
||||||
|
const local = config.speech?.local
|
||||||
|
|
||||||
|
if (!providers || !local) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const ids = new Set<LocalSpeechModelId>()
|
||||||
|
|
||||||
|
if (providers.dictationStt.enabled !== false && providers.dictationStt.provider === 'local') {
|
||||||
|
ids.add(local.models.dictationStt)
|
||||||
|
}
|
||||||
|
if (providers.voiceStt.enabled !== false && providers.voiceStt.provider === 'local') {
|
||||||
|
ids.add(local.models.voiceStt)
|
||||||
|
}
|
||||||
|
if (providers.voiceTts.enabled !== false && providers.voiceTts.provider === 'local') {
|
||||||
|
ids.add(local.models.voiceTts)
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(ids)
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseLocalModelDownloadProgress(payload: unknown): LocalModelDownloadProgress | null {
|
||||||
|
if (!payload || typeof payload !== 'object') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = payload as Record<string, unknown>
|
||||||
|
const modelId = typeof value.modelId === 'string' ? value.modelId : null
|
||||||
|
const pctRaw = value.pct
|
||||||
|
const pct = typeof pctRaw === 'number' && Number.isFinite(pctRaw) ? Math.max(0, Math.min(100, Math.floor(pctRaw))) : null
|
||||||
|
|
||||||
|
return {
|
||||||
|
modelId,
|
||||||
|
pct,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderLocalModelProgress(params: {
|
||||||
|
modelId: LocalSpeechModelId
|
||||||
|
modelIndex: number
|
||||||
|
modelCount: number
|
||||||
|
pct: number | null
|
||||||
|
}): string {
|
||||||
|
const prefix = `Downloading speech model ${params.modelIndex}/${params.modelCount}: ${params.modelId}`
|
||||||
|
if (params.pct === null) {
|
||||||
|
return `${prefix}...`
|
||||||
|
}
|
||||||
|
return `${prefix} (${params.pct}%)`
|
||||||
|
}
|
||||||
|
|
||||||
|
function createLocalSpeechDownloadLogger(
|
||||||
|
onEvent: (event: LocalSpeechDownloadEvent) => void
|
||||||
|
): LocalSpeechDownloadLogger {
|
||||||
|
const logger: LocalSpeechDownloadLogger = {
|
||||||
|
child: () => logger,
|
||||||
|
info: (obj?: unknown, msg?: string) => {
|
||||||
|
if (msg === 'Downloading model artifact') {
|
||||||
|
const progress = parseLocalModelDownloadProgress(obj)
|
||||||
|
if (!progress) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
onEvent({
|
||||||
|
type: 'progress',
|
||||||
|
progress,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (msg === 'Extracting model archive') {
|
||||||
|
onEvent({ type: 'phase', phase: 'extracting' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (msg === 'Verifying downloaded model files') {
|
||||||
|
onEvent({ type: 'phase', phase: 'verifying' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (msg === 'Finalizing model artifacts') {
|
||||||
|
onEvent({ type: 'phase', phase: 'finalizing' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (msg === 'Model download completed') {
|
||||||
|
onEvent({ type: 'phase', phase: 'completed' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
// no-op: onboarding handles surfaced errors from ensureLocalSpeechModels.
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return logger
|
||||||
|
}
|
||||||
|
|
||||||
|
async function prepareLocalSpeechModelsBeforeStart(args: {
|
||||||
|
config: ReturnType<typeof loadConfig>
|
||||||
|
richUi: boolean
|
||||||
|
}): Promise<void> {
|
||||||
|
const local = args.config.speech?.local
|
||||||
|
const modelIds = resolveRequiredLocalModelIds(args.config)
|
||||||
|
|
||||||
|
if (!local || modelIds.length === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (local.autoDownload === false) {
|
||||||
|
log.warn('Local speech model auto-download is disabled. Voice may be unavailable until models are installed.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const modelList = modelIds.join(', ')
|
||||||
|
const modelCount = modelIds.length
|
||||||
|
const downloadSpinner = args.richUi ? spinner() : null
|
||||||
|
let lastPlainStatus = ''
|
||||||
|
|
||||||
|
const emitStatus = (status: string): void => {
|
||||||
|
if (downloadSpinner) {
|
||||||
|
downloadSpinner.message(status)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (status === lastPlainStatus) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
console.log(status)
|
||||||
|
lastPlainStatus = status
|
||||||
|
}
|
||||||
|
|
||||||
|
if (downloadSpinner) {
|
||||||
|
downloadSpinner.start(`Preparing local speech models (${modelCount})...`)
|
||||||
|
} else {
|
||||||
|
log.message(`Preparing local speech models (${modelCount}): ${modelList}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (const [index, modelId] of modelIds.entries()) {
|
||||||
|
const modelIndex = index + 1
|
||||||
|
emitStatus(`Checking speech model ${modelIndex}/${modelCount}: ${modelId}`)
|
||||||
|
|
||||||
|
const perModelLogger = createLocalSpeechDownloadLogger((event) => {
|
||||||
|
if (event.type === 'progress') {
|
||||||
|
const progress = event.progress
|
||||||
|
if (progress.modelId && progress.modelId !== modelId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
emitStatus(
|
||||||
|
renderLocalModelProgress({
|
||||||
|
modelId,
|
||||||
|
modelIndex,
|
||||||
|
modelCount,
|
||||||
|
pct: progress.pct,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.phase === 'extracting') {
|
||||||
|
emitStatus(`Extracting speech model ${modelIndex}/${modelCount}: ${modelId}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (event.phase === 'verifying') {
|
||||||
|
emitStatus(`Verifying speech model ${modelIndex}/${modelCount}: ${modelId}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (event.phase === 'finalizing') {
|
||||||
|
emitStatus(`Finalizing speech model ${modelIndex}/${modelCount}: ${modelId}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
await ensureLocalSpeechModels({
|
||||||
|
modelsDir: local.modelsDir,
|
||||||
|
modelIds: [modelId],
|
||||||
|
autoDownload: true,
|
||||||
|
logger: perModelLogger as any,
|
||||||
|
})
|
||||||
|
|
||||||
|
emitStatus(`Speech model ready ${modelIndex}/${modelCount}: ${modelId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (downloadSpinner) {
|
||||||
|
downloadSpinner.stop(`Local speech models ready (${modelCount})`)
|
||||||
|
} else {
|
||||||
|
log.message(`Local speech models ready (${modelCount}): ${modelList}`)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error)
|
||||||
|
if (downloadSpinner) {
|
||||||
|
downloadSpinner.error(`Failed to prepare local speech models: ${message}`)
|
||||||
|
} else {
|
||||||
|
log.error(`Failed to prepare local speech models: ${message}`)
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDownloadProgress(logTail: string): DownloadProgress | null {
|
||||||
|
const lines = logTail.split('\n').filter(Boolean)
|
||||||
|
|
||||||
|
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
||||||
|
const line = lines[index]
|
||||||
|
if (!line || !line.includes('Downloading model artifact')) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const pctMatch = line.match(/"pct"\s*:\s*(\d{1,3})|\bpct[=:]\s*(\d{1,3})/)
|
||||||
|
const modelMatch = line.match(
|
||||||
|
/"modelId"\s*:\s*"([^"]+)"|\bmodelId[=:]\s*"?([^\s",}]+)/
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
modelId: modelMatch?.[1] ?? modelMatch?.[2] ?? null,
|
||||||
|
pct: pctMatch ? Number(pctMatch[1] ?? pctMatch[2]) : null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderProgressLine(progress: DownloadProgress): string {
|
||||||
|
const modelSuffix = progress.modelId ? ` (${progress.modelId})` : ''
|
||||||
|
if (progress.pct === null) {
|
||||||
|
return `Downloading speech model${modelSuffix}...`
|
||||||
|
}
|
||||||
|
return `Downloading speech model${modelSuffix}: ${progress.pct}%`
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForDaemonReady(args: {
|
||||||
|
home: string
|
||||||
|
timeoutMs: number
|
||||||
|
onStatus?: (message: string) => void
|
||||||
|
}): Promise<{ listen: string; host: string | null }> {
|
||||||
|
const deadline = Date.now() + args.timeoutMs
|
||||||
|
let lastStatus = ''
|
||||||
|
let lastPrintedAt = 0
|
||||||
|
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
const state = resolveLocalDaemonState({ home: args.home })
|
||||||
|
const host = resolveTcpHostFromListen(state.listen)
|
||||||
|
|
||||||
|
if (state.running && host) {
|
||||||
|
const client = await tryConnectToDaemon({ host, timeout: 1200 })
|
||||||
|
if (client) {
|
||||||
|
try {
|
||||||
|
await client.fetchAgents()
|
||||||
|
return { listen: state.listen, host }
|
||||||
|
} catch {
|
||||||
|
// Daemon process is alive but not API-ready yet.
|
||||||
|
} finally {
|
||||||
|
await client.close().catch(() => {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (state.running && !host) {
|
||||||
|
return { listen: state.listen, host: null }
|
||||||
|
}
|
||||||
|
|
||||||
|
const progress = parseDownloadProgress(tailDaemonLog(args.home, 120) ?? '')
|
||||||
|
const progressLine = progress ? renderProgressLine(progress) : null
|
||||||
|
const statusMessage = progressLine ?? 'Waiting for daemon to become ready...'
|
||||||
|
|
||||||
|
if (statusMessage !== lastStatus) {
|
||||||
|
args.onStatus?.(statusMessage)
|
||||||
|
lastStatus = statusMessage
|
||||||
|
lastPrintedAt = Date.now()
|
||||||
|
} else if (!args.onStatus && Date.now() - lastPrintedAt >= 3000) {
|
||||||
|
console.log(statusMessage)
|
||||||
|
lastPrintedAt = Date.now()
|
||||||
|
}
|
||||||
|
|
||||||
|
await sleep(200)
|
||||||
|
}
|
||||||
|
|
||||||
|
const recentLogs = tailDaemonLog(args.home, 60)
|
||||||
|
throw new Error(
|
||||||
|
[
|
||||||
|
`Timed out after ${Math.ceil(args.timeoutMs / 1000)}s waiting for daemon readiness.`,
|
||||||
|
recentLogs ? `Recent daemon logs:\n${recentLogs}` : null,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('\n\n')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function printNextSteps(pairingUrl: string | null, paseoHome: string, richUi: boolean): void {
|
||||||
|
const daemonLogPath = path.join(paseoHome, 'daemon.log')
|
||||||
|
const nextStepsLines = [
|
||||||
|
pairingUrl
|
||||||
|
? '1. Open Paseo and scan the QR code above, or paste the pairing link.'
|
||||||
|
: '1. Open Paseo and connect to your daemon.',
|
||||||
|
'2. Web app: https://app.paseo.sh',
|
||||||
|
'3. Desktop app: https://github.com/getpaseo/paseo/releases/latest',
|
||||||
|
'4. Docs: https://paseo.sh/docs',
|
||||||
|
]
|
||||||
|
const quickReferenceLines = [
|
||||||
|
'1. paseo --help',
|
||||||
|
'2. paseo ls',
|
||||||
|
'3. paseo run "your prompt"',
|
||||||
|
'4. paseo status',
|
||||||
|
`5. Daemon logs: ${daemonLogPath}`,
|
||||||
|
]
|
||||||
|
|
||||||
|
if (!richUi) {
|
||||||
|
console.log('')
|
||||||
|
console.log('Next steps:')
|
||||||
|
for (const line of nextStepsLines) {
|
||||||
|
console.log(line)
|
||||||
|
}
|
||||||
|
console.log('')
|
||||||
|
console.log('CLI quick reference:')
|
||||||
|
for (const line of quickReferenceLines) {
|
||||||
|
console.log(line)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
renderNote(nextStepsLines.join('\n'), 'Next steps')
|
||||||
|
renderNote(quickReferenceLines.join('\n'), 'CLI quick reference')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function onboardCommand(): Command {
|
||||||
|
return new Command('onboard')
|
||||||
|
.description('Run first-time setup, start daemon, and print pairing instructions')
|
||||||
|
.option('--listen <listen>', 'Listen target (host:port, port, or unix socket path)')
|
||||||
|
.option('--port <port>', 'Port to listen on (default: 6767)')
|
||||||
|
.option('--home <path>', 'Paseo home directory (default: ~/.paseo)')
|
||||||
|
.option('--no-relay', 'Disable relay connection')
|
||||||
|
.option('--no-mcp', 'Disable the Agent MCP HTTP endpoint')
|
||||||
|
.option(
|
||||||
|
'--allowed-hosts <hosts>',
|
||||||
|
'Comma-separated Host allowlist values (example: "localhost,.example.com" or "true")'
|
||||||
|
)
|
||||||
|
.option('--timeout <seconds>', 'Max time to wait for daemon readiness (default: 600)')
|
||||||
|
.option('--voice <mode>', 'Voice setup mode: ask, enable, disable', 'ask')
|
||||||
|
.action(async (options: OnboardOptions) => {
|
||||||
|
await runOnboard(options)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runOnboard(options: OnboardOptions): Promise<void> {
|
||||||
|
const richUi = process.stdin.isTTY && process.stdout.isTTY
|
||||||
|
if (richUi) {
|
||||||
|
intro('Welcome to Paseo')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.listen && options.port) {
|
||||||
|
cancel('Cannot use --listen and --port together')
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
let timeoutMs = DEFAULT_READY_TIMEOUT_MS
|
||||||
|
try {
|
||||||
|
timeoutMs = parseTimeoutMs(options.timeout)
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error)
|
||||||
|
cancel(message)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const paseoHome = resolveLocalPaseoHome(options.home)
|
||||||
|
if (richUi) {
|
||||||
|
renderNote(paseoHome, 'Paseo home')
|
||||||
|
}
|
||||||
|
|
||||||
|
let persisted = loadPersistedConfig(paseoHome) as OnboardPersistedConfig
|
||||||
|
const persistedVoiceSelection = resolvePersistedVoiceSelection(persisted)
|
||||||
|
const shouldPrompt = options.voice === 'ask' || options.voice === undefined
|
||||||
|
let voiceEnabled: boolean
|
||||||
|
try {
|
||||||
|
voiceEnabled =
|
||||||
|
shouldPrompt && persistedVoiceSelection !== null
|
||||||
|
? persistedVoiceSelection
|
||||||
|
: await resolveVoiceSelection(options.voice)
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof OnboardCancelledError) {
|
||||||
|
cancel('Onboarding cancelled.')
|
||||||
|
process.exit(0)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldPrompt && persistedVoiceSelection !== null) {
|
||||||
|
log.message(`Using saved voice setup from config (${voiceEnabled ? 'enabled' : 'disabled'}).`)
|
||||||
|
}
|
||||||
|
|
||||||
|
persisted = applyVoiceSelection(persisted, voiceEnabled)
|
||||||
|
savePersistedConfig(paseoHome, persisted)
|
||||||
|
|
||||||
|
const config = loadConfig(paseoHome, { cli: toCliOverrides(options) })
|
||||||
|
|
||||||
|
const voiceStatus = voiceEnabled
|
||||||
|
? 'Voice features enabled. Local speech models will be downloaded if missing.'
|
||||||
|
: 'Voice features disabled. Local speech models will not be downloaded now.'
|
||||||
|
log.message(voiceStatus)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await prepareLocalSpeechModelsBeforeStart({
|
||||||
|
config,
|
||||||
|
richUi,
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const stateBeforeStart = resolveLocalDaemonState({ home: options.home })
|
||||||
|
const startSpinner = richUi ? spinner() : null
|
||||||
|
|
||||||
|
if (!stateBeforeStart.running) {
|
||||||
|
try {
|
||||||
|
if (startSpinner) {
|
||||||
|
startSpinner.start('Starting daemon...')
|
||||||
|
} else {
|
||||||
|
log.message('Starting daemon...')
|
||||||
|
}
|
||||||
|
const startup = await startLocalDaemonDetached(options)
|
||||||
|
if (startSpinner) {
|
||||||
|
startSpinner.stop(`Daemon started (PID ${startup.pid ?? 'unknown'})`)
|
||||||
|
} else {
|
||||||
|
log.message(`Daemon started (PID ${startup.pid ?? 'unknown'})`)
|
||||||
|
}
|
||||||
|
log.message(`Logs: ${startup.logPath}`)
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error)
|
||||||
|
if (startSpinner) {
|
||||||
|
startSpinner.error(message)
|
||||||
|
} else {
|
||||||
|
log.error(message)
|
||||||
|
}
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log.message(`Daemon already running (PID ${stateBeforeStart.pidInfo?.pid ?? 'unknown'}).`)
|
||||||
|
}
|
||||||
|
|
||||||
|
let readyState: { listen: string; host: string | null }
|
||||||
|
const readySpinner = richUi ? spinner() : null
|
||||||
|
try {
|
||||||
|
if (readySpinner) {
|
||||||
|
readySpinner.start('Waiting for daemon to become ready...')
|
||||||
|
} else {
|
||||||
|
log.message('Waiting for daemon to become ready...')
|
||||||
|
}
|
||||||
|
readyState = await waitForDaemonReady({
|
||||||
|
home: options.home ?? paseoHome,
|
||||||
|
timeoutMs,
|
||||||
|
onStatus: readySpinner ? (message) => readySpinner.message(message) : undefined,
|
||||||
|
})
|
||||||
|
if (readySpinner) {
|
||||||
|
readySpinner.stop(`Daemon ready on ${readyState.listen}`)
|
||||||
|
} else {
|
||||||
|
log.message(`Daemon ready on ${readyState.listen}`)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error)
|
||||||
|
if (readySpinner) {
|
||||||
|
readySpinner.error(message)
|
||||||
|
} else {
|
||||||
|
log.error(message)
|
||||||
|
}
|
||||||
|
process.exit(1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.relayEnabled === false) {
|
||||||
|
log.warn('Relay is disabled; pairing offer is unavailable for this daemon.')
|
||||||
|
printNextSteps(null, paseoHome, richUi)
|
||||||
|
if (richUi) {
|
||||||
|
outro('Paseo daemon is running.')
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const pairing = await generateLocalPairingOffer({
|
||||||
|
paseoHome,
|
||||||
|
relayEnabled: config.relayEnabled,
|
||||||
|
relayEndpoint: config.relayEndpoint,
|
||||||
|
relayPublicEndpoint: config.relayPublicEndpoint,
|
||||||
|
appBaseUrl: config.appBaseUrl,
|
||||||
|
includeQr: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!pairing.url) {
|
||||||
|
log.warn('Relay pairing URL is unavailable for this daemon configuration.')
|
||||||
|
printNextSteps(null, paseoHome, richUi)
|
||||||
|
if (richUi) {
|
||||||
|
outro('Paseo daemon is running.')
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
renderNote(
|
||||||
|
pairing.qr ?? 'QR is unavailable in this terminal. Use the pairing link below.',
|
||||||
|
'Scan to pair'
|
||||||
|
)
|
||||||
|
renderNote(pairing.url, 'Pairing link')
|
||||||
|
printNextSteps(pairing.url, paseoHome, richUi)
|
||||||
|
if (richUi) {
|
||||||
|
outro('Paseo is ready!')
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,6 @@ import { createCli } from './cli.js'
|
|||||||
|
|
||||||
const program = createCli()
|
const program = createCli()
|
||||||
if (process.argv.length <= 2) {
|
if (process.argv.length <= 2) {
|
||||||
process.argv.push('start')
|
process.argv.push('onboard')
|
||||||
}
|
}
|
||||||
program.parse()
|
program.parse()
|
||||||
|
|||||||
68
packages/cli/tests/17-onboard.test.ts
Normal file
68
packages/cli/tests/17-onboard.test.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
#!/usr/bin/env npx tsx
|
||||||
|
|
||||||
|
import assert from 'node:assert'
|
||||||
|
import { readFile, mkdtemp, rm } from 'node:fs/promises'
|
||||||
|
import { tmpdir } from 'node:os'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
import { $ } from 'zx'
|
||||||
|
|
||||||
|
$.verbose = false
|
||||||
|
|
||||||
|
function randomPort(): number {
|
||||||
|
return 10000 + Math.floor(Math.random() * 50000)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('=== Onboarding Command ===\n')
|
||||||
|
|
||||||
|
const paseoHome = await mkdtemp(join(tmpdir(), 'paseo-onboard-home-'))
|
||||||
|
const port = randomPort()
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log('Test 1: `paseo` runs blocking onboarding and prints pairing info')
|
||||||
|
const onboard =
|
||||||
|
await $`PASEO_HOME=${paseoHome} PASEO_LISTEN=127.0.0.1:${port} PASEO_PAIRING_QR=0 npm run -s cli --`.nothrow()
|
||||||
|
|
||||||
|
assert.strictEqual(onboard.exitCode, 0, `onboard should succeed: ${onboard.stderr}`)
|
||||||
|
assert(onboard.stdout.includes('Scan to pair'), 'onboard output should include scan header')
|
||||||
|
assert(onboard.stdout.includes('Pairing link'), 'onboard output should include pairing link header')
|
||||||
|
assert(onboard.stdout.includes('#offer='), 'onboard output should include pairing offer URL')
|
||||||
|
assert(onboard.stdout.includes('CLI quick reference'), 'onboard output should include CLI quick reference')
|
||||||
|
assert(onboard.stdout.includes('paseo --help'), 'onboard output should include --help shortcut')
|
||||||
|
assert(onboard.stdout.includes('paseo ls'), 'onboard output should include ls shortcut')
|
||||||
|
assert(onboard.stdout.includes('paseo run "your prompt"'), 'onboard output should include run shortcut')
|
||||||
|
assert(onboard.stdout.includes('paseo status'), 'onboard output should include status shortcut')
|
||||||
|
assert(onboard.stdout.includes(join(paseoHome, 'daemon.log')), 'onboard output should include daemon log path')
|
||||||
|
|
||||||
|
const status =
|
||||||
|
await $`PASEO_HOME=${paseoHome} npm run -s cli -- daemon status --home ${paseoHome}`.nothrow()
|
||||||
|
assert.strictEqual(status.exitCode, 0, `daemon status should succeed: ${status.stderr}`)
|
||||||
|
assert(status.stdout.includes('running'), 'daemon should be running when onboarding exits')
|
||||||
|
console.log('✓ onboarding prints pairing info and waits for daemon readiness\n')
|
||||||
|
|
||||||
|
console.log('Test 2: non-interactive onboarding persists voice disabled config')
|
||||||
|
const configRaw = await readFile(join(paseoHome, 'config.json'), 'utf-8')
|
||||||
|
const config = JSON.parse(configRaw) as {
|
||||||
|
features?: {
|
||||||
|
dictation?: { enabled?: boolean }
|
||||||
|
voiceMode?: { enabled?: boolean }
|
||||||
|
}
|
||||||
|
providers?: {
|
||||||
|
local?: { autoDownload?: boolean }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.strictEqual(config.features?.dictation?.enabled, false, 'dictation.enabled should be false')
|
||||||
|
assert.strictEqual(config.features?.voiceMode?.enabled, false, 'voiceMode.enabled should be false')
|
||||||
|
assert.strictEqual(config.providers?.local?.autoDownload, false, 'local.autoDownload should be false')
|
||||||
|
const daemonLog = await readFile(join(paseoHome, 'daemon.log'), 'utf-8')
|
||||||
|
assert(
|
||||||
|
!daemonLog.includes('Ensuring local speech models'),
|
||||||
|
'daemon should not attempt local speech model setup when voice is disabled'
|
||||||
|
)
|
||||||
|
console.log('✓ non-interactive run persisted voice disabled choices\n')
|
||||||
|
} finally {
|
||||||
|
await $`PASEO_HOME=${paseoHome} npm run -s cli -- daemon stop --home ${paseoHome} --force`.nothrow()
|
||||||
|
await rm(paseoHome, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('=== Onboarding tests passed ===')
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
import { readFileSync } from "node:fs";
|
import { readFileSync } from "node:fs";
|
||||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
import { afterEach, describe, expect, expectTypeOf, test, vi } from "vitest";
|
||||||
import { DaemonClient, type DaemonTransport } from "./daemon-client";
|
import { DaemonClient, type DaemonTransport } from "./daemon-client";
|
||||||
|
|
||||||
|
expectTypeOf<"getGitDiff" extends keyof DaemonClient ? true : false>().toEqualTypeOf<false>();
|
||||||
|
expectTypeOf<"getHighlightedDiff" extends keyof DaemonClient ? true : false>().toEqualTypeOf<false>();
|
||||||
|
|
||||||
function createMockLogger() {
|
function createMockLogger() {
|
||||||
return {
|
return {
|
||||||
debug: vi.fn(),
|
debug: vi.fn(),
|
||||||
|
|||||||
@@ -15,9 +15,7 @@ import type {
|
|||||||
CreateAgentRequestMessage,
|
CreateAgentRequestMessage,
|
||||||
FileDownloadTokenResponse,
|
FileDownloadTokenResponse,
|
||||||
FileExplorerResponse,
|
FileExplorerResponse,
|
||||||
GitDiffResponse,
|
|
||||||
GitSetupOptions,
|
GitSetupOptions,
|
||||||
HighlightedDiffResponse,
|
|
||||||
CheckoutStatusResponse,
|
CheckoutStatusResponse,
|
||||||
CheckoutCommitResponse,
|
CheckoutCommitResponse,
|
||||||
CheckoutMergeResponse,
|
CheckoutMergeResponse,
|
||||||
@@ -178,8 +176,6 @@ export type CreateAgentRequestOptions = {
|
|||||||
labels?: Record<string, string>;
|
labels?: Record<string, string>;
|
||||||
} & AgentConfigOverrides;
|
} & AgentConfigOverrides;
|
||||||
|
|
||||||
type GitDiffPayload = GitDiffResponse["payload"];
|
|
||||||
type HighlightedDiffPayload = HighlightedDiffResponse["payload"];
|
|
||||||
type CheckoutStatusPayload = CheckoutStatusResponse["payload"];
|
type CheckoutStatusPayload = CheckoutStatusResponse["payload"];
|
||||||
type SubscribeCheckoutDiffPayload = Extract<
|
type SubscribeCheckoutDiffPayload = Extract<
|
||||||
SessionOutboundMessage,
|
SessionOutboundMessage,
|
||||||
@@ -1907,60 +1903,6 @@ export class DaemonClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async getGitDiff(
|
|
||||||
agentId: string,
|
|
||||||
requestId?: string
|
|
||||||
): Promise<GitDiffPayload> {
|
|
||||||
const resolvedRequestId = this.createRequestId(requestId);
|
|
||||||
const message = SessionInboundMessageSchema.parse({
|
|
||||||
type: "git_diff_request",
|
|
||||||
agentId,
|
|
||||||
requestId: resolvedRequestId,
|
|
||||||
});
|
|
||||||
return this.sendRequest({
|
|
||||||
requestId: resolvedRequestId,
|
|
||||||
message,
|
|
||||||
timeout: 10000,
|
|
||||||
options: { skipQueue: true },
|
|
||||||
select: (msg) => {
|
|
||||||
if (msg.type !== "git_diff_response") {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (msg.payload.requestId !== resolvedRequestId) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return msg.payload;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async getHighlightedDiff(
|
|
||||||
agentId: string,
|
|
||||||
requestId?: string
|
|
||||||
): Promise<HighlightedDiffPayload> {
|
|
||||||
const resolvedRequestId = this.createRequestId(requestId);
|
|
||||||
const message = SessionInboundMessageSchema.parse({
|
|
||||||
type: "highlighted_diff_request",
|
|
||||||
agentId,
|
|
||||||
requestId: resolvedRequestId,
|
|
||||||
});
|
|
||||||
return this.sendRequest({
|
|
||||||
requestId: resolvedRequestId,
|
|
||||||
message,
|
|
||||||
timeout: 10000,
|
|
||||||
options: { skipQueue: true },
|
|
||||||
select: (msg) => {
|
|
||||||
if (msg.type !== "highlighted_diff_response") {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (msg.payload.requestId !== resolvedRequestId) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return msg.payload;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async validateBranch(
|
async validateBranch(
|
||||||
options: { cwd: string; branchName: string },
|
options: { cwd: string; branchName: string },
|
||||||
requestId?: string
|
requestId?: string
|
||||||
|
|||||||
@@ -229,17 +229,6 @@ export class TTSManager {
|
|||||||
isVoiceMode,
|
isVoiceMode,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
this.logger.info(
|
|
||||||
{
|
|
||||||
audioId,
|
|
||||||
chunkId,
|
|
||||||
chunkIndex,
|
|
||||||
isLastChunk: next.done,
|
|
||||||
bytes: chunkBuffer.length,
|
|
||||||
isVoiceMode,
|
|
||||||
},
|
|
||||||
"Emitted audio_output chunk to client"
|
|
||||||
);
|
|
||||||
|
|
||||||
chunkIndex += 1;
|
chunkIndex += 1;
|
||||||
|
|
||||||
@@ -298,15 +287,6 @@ export class TTSManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pending.pendingChunks = Math.max(0, pending.pendingChunks - 1);
|
pending.pendingChunks = Math.max(0, pending.pendingChunks - 1);
|
||||||
this.logger.info(
|
|
||||||
{
|
|
||||||
chunkId,
|
|
||||||
audioId,
|
|
||||||
remainingPendingChunks: pending.pendingChunks,
|
|
||||||
streamEnded: pending.streamEnded,
|
|
||||||
},
|
|
||||||
"Received audio playback confirmation from client"
|
|
||||||
);
|
|
||||||
|
|
||||||
if (pending.pendingChunks === 0 && pending.streamEnded) {
|
if (pending.pendingChunks === 0 && pending.streamEnded) {
|
||||||
pending.resolve();
|
pending.resolve();
|
||||||
|
|||||||
@@ -1012,63 +1012,11 @@ describe("daemon client E2E", () => {
|
|||||||
expect(checkoutStatus.isGit).toBe(true);
|
expect(checkoutStatus.isGit).toBe(true);
|
||||||
expect(checkoutStatus.repoRoot).toContain(cwd);
|
expect(checkoutStatus.repoRoot).toContain(cwd);
|
||||||
|
|
||||||
const diffRequestId = `diff-${Date.now()}`;
|
const diffResult = await ctx.client.getCheckoutDiff(cwd, { mode: "uncommitted" });
|
||||||
const diffMessagePromise = waitForSignal(15000, (resolve) => {
|
|
||||||
const unsubscribeDiff = ctx.client.on("git_diff_response", (message) => {
|
|
||||||
if (message.type !== "git_diff_response") {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (message.payload.agentId !== agent.id) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (message.payload.requestId !== diffRequestId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
resolve(message);
|
|
||||||
});
|
|
||||||
return unsubscribeDiff;
|
|
||||||
});
|
|
||||||
|
|
||||||
const diffResult = await ctx.client.getGitDiff(agent.id, diffRequestId);
|
|
||||||
const diffMessage = await diffMessagePromise;
|
|
||||||
expect(diffResult.error).toBeNull();
|
expect(diffResult.error).toBeNull();
|
||||||
expect(diffResult.diff).toContain("test.txt");
|
expect(Array.isArray(diffResult.files)).toBe(true);
|
||||||
expect(diffResult.diff).toContain("-original content");
|
expect(diffResult.files.length).toBeGreaterThan(0);
|
||||||
expect(diffResult.diff).toContain("+modified content");
|
expect(diffResult.files.some((file) => file.path === "test.txt")).toBe(true);
|
||||||
expect(diffResult.requestId).toBe(diffRequestId);
|
|
||||||
expect(diffMessage.payload.agentId).toBe(agent.id);
|
|
||||||
expect(diffMessage.payload.requestId).toBe(diffRequestId);
|
|
||||||
|
|
||||||
const highlightRequestId = `highlight-${Date.now()}`;
|
|
||||||
const highlightMessagePromise = waitForSignal(15000, (resolve) => {
|
|
||||||
const unsubscribeHighlight = ctx.client.on(
|
|
||||||
"highlighted_diff_response",
|
|
||||||
(message) => {
|
|
||||||
if (message.type !== "highlighted_diff_response") {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (message.payload.agentId !== agent.id) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (message.payload.requestId !== highlightRequestId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
resolve(message);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
return unsubscribeHighlight;
|
|
||||||
});
|
|
||||||
|
|
||||||
const highlightResult = await ctx.client.getHighlightedDiff(
|
|
||||||
agent.id,
|
|
||||||
highlightRequestId
|
|
||||||
);
|
|
||||||
const highlightMessage = await highlightMessagePromise;
|
|
||||||
expect(highlightResult.error).toBeNull();
|
|
||||||
expect(Array.isArray(highlightResult.files)).toBe(true);
|
|
||||||
expect(highlightResult.requestId).toBe(highlightRequestId);
|
|
||||||
expect(highlightMessage.payload.agentId).toBe(agent.id);
|
|
||||||
expect(highlightMessage.payload.requestId).toBe(highlightRequestId);
|
|
||||||
|
|
||||||
const listRequestId = `list-${Date.now()}`;
|
const listRequestId = `list-${Date.now()}`;
|
||||||
const listMessagePromise = waitForSignal(15000, (resolve) => {
|
const listMessagePromise = waitForSignal(15000, (resolve) => {
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import { describe, test, expect } from "vitest";
|
||||||
|
import { execSync } from "node:child_process";
|
||||||
|
import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { performance } from "node:perf_hooks";
|
||||||
|
import { createDaemonTestContext } from "../test-utils/index.js";
|
||||||
|
|
||||||
|
const RUN = process.env.PASEO_GIT_DIFF_BOTTLENECK_E2E === "1";
|
||||||
|
const LARGE_CHANGESET_SIZE = Number.parseInt(
|
||||||
|
process.env.PASEO_GIT_DIFF_BOTTLENECK_FILE_COUNT ?? "1200",
|
||||||
|
10
|
||||||
|
);
|
||||||
|
|
||||||
|
function tmpRepo(): string {
|
||||||
|
return mkdtempSync(path.join(tmpdir(), "paseo-git-diff-bottleneck-"));
|
||||||
|
}
|
||||||
|
|
||||||
|
function initGitRepo(cwd: string): void {
|
||||||
|
execSync("git init -b main", { cwd, stdio: "pipe" });
|
||||||
|
execSync("git config user.email 'test@test.com'", { cwd, stdio: "pipe" });
|
||||||
|
execSync("git config user.name 'Test'", { cwd, stdio: "pipe" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function seedLargeDirtyRepo(cwd: string, fileCount: number): void {
|
||||||
|
mkdirSync(path.join(cwd, "files"), { recursive: true });
|
||||||
|
for (let i = 0; i < fileCount; i += 1) {
|
||||||
|
writeFileSync(path.join(cwd, "files", `f-${i}.txt`), `line ${i}\n`);
|
||||||
|
}
|
||||||
|
execSync("git add .", { cwd, stdio: "pipe" });
|
||||||
|
execSync("git -c commit.gpgsign=false commit -m 'init'", {
|
||||||
|
cwd,
|
||||||
|
stdio: "pipe",
|
||||||
|
});
|
||||||
|
for (let i = 0; i < fileCount; i += 1) {
|
||||||
|
writeFileSync(path.join(cwd, "files", `f-${i}.txt`), `line ${i} changed\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Explicit binary artifact to verify we do not diff binary contents.
|
||||||
|
writeFileSync(path.join(cwd, "blob.bin"), Buffer.from([0x00, 0xff, 0x10, 0x80, 0x00, 0x7f]));
|
||||||
|
}
|
||||||
|
|
||||||
|
const runDescribe = RUN ? describe : describe.skip;
|
||||||
|
|
||||||
|
runDescribe("daemon E2E git diff bottleneck profiling", () => {
|
||||||
|
test(
|
||||||
|
"shows per-file git diff subprocess fanout and timeout pressure",
|
||||||
|
async () => {
|
||||||
|
const cwd = tmpRepo();
|
||||||
|
|
||||||
|
try {
|
||||||
|
initGitRepo(cwd);
|
||||||
|
seedLargeDirtyRepo(cwd, LARGE_CHANGESET_SIZE);
|
||||||
|
|
||||||
|
const cliStart = performance.now();
|
||||||
|
const cliDiff = execSync("git diff HEAD", { cwd, stdio: "pipe" }).toString();
|
||||||
|
const cliMs = performance.now() - cliStart;
|
||||||
|
|
||||||
|
const ctx = await createDaemonTestContext();
|
||||||
|
try {
|
||||||
|
const checkoutStart = performance.now();
|
||||||
|
const checkoutPayload = await ctx.client.getCheckoutDiff(cwd, {
|
||||||
|
mode: "uncommitted",
|
||||||
|
});
|
||||||
|
const checkoutMs = performance.now() - checkoutStart;
|
||||||
|
|
||||||
|
expect(checkoutPayload.error).toBeNull();
|
||||||
|
expect(checkoutPayload.files.length).toBeGreaterThanOrEqual(LARGE_CHANGESET_SIZE);
|
||||||
|
|
||||||
|
const binaryEntry = checkoutPayload.files.find((file) => file.path === "blob.bin");
|
||||||
|
expect(binaryEntry).toBeTruthy();
|
||||||
|
expect(binaryEntry?.status).toBe("binary");
|
||||||
|
|
||||||
|
// Keep this visible in test output for local bottleneck analysis.
|
||||||
|
console.info(
|
||||||
|
"[git-diff-bottleneck]",
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
fileCount: LARGE_CHANGESET_SIZE,
|
||||||
|
cliMs: Math.round(cliMs),
|
||||||
|
cliDiffBytes: cliDiff.length,
|
||||||
|
checkoutMs: Math.round(checkoutMs),
|
||||||
|
checkoutFiles: checkoutPayload.files.length,
|
||||||
|
speedRatio: Number((checkoutMs / Math.max(cliMs, 1)).toFixed(2)),
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(checkoutMs).toBeLessThan(cliMs * 10);
|
||||||
|
} finally {
|
||||||
|
await ctx.cleanup();
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
rmSync(cwd, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
240000
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -110,7 +110,7 @@ describe("daemon E2E", () => {
|
|||||||
await ctx.cleanup();
|
await ctx.cleanup();
|
||||||
}, 60000);
|
}, 60000);
|
||||||
|
|
||||||
describe("getGitDiff", () => {
|
describe("getCheckoutDiff", () => {
|
||||||
test(
|
test(
|
||||||
"returns diff for modified file in git repo",
|
"returns diff for modified file in git repo",
|
||||||
async () => {
|
async () => {
|
||||||
@@ -134,28 +134,12 @@ describe("daemon E2E", () => {
|
|||||||
// Modify the file (creates unstaged changes)
|
// Modify the file (creates unstaged changes)
|
||||||
writeFileSync(testFile, "modified content\n");
|
writeFileSync(testFile, "modified content\n");
|
||||||
|
|
||||||
// Create agent in the git repo
|
const result = await ctx.client.getCheckoutDiff(cwd, { mode: "uncommitted" });
|
||||||
const agent = await ctx.client.createAgent({
|
|
||||||
provider: "codex", model: CODEX_TEST_MODEL, thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
|
|
||||||
cwd,
|
|
||||||
title: "Git Diff Test",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(agent.id).toBeTruthy();
|
|
||||||
expect(agent.status).toBe("idle");
|
|
||||||
|
|
||||||
// Get git diff
|
|
||||||
const result = await ctx.client.getGitDiff(agent.id);
|
|
||||||
|
|
||||||
// Verify diff returned without error
|
|
||||||
expect(result.error).toBeNull();
|
expect(result.error).toBeNull();
|
||||||
expect(result.diff).toBeTruthy();
|
expect(result.files.length).toBeGreaterThan(0);
|
||||||
expect(result.diff).toContain("test.txt");
|
const file = result.files.find((entry) => entry.path === "test.txt");
|
||||||
expect(result.diff).toContain("-original content");
|
expect(file).toBeTruthy();
|
||||||
expect(result.diff).toContain("+modified content");
|
expect(file?.hunks.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
// Cleanup
|
|
||||||
await ctx.client.deleteAgent(agent.id);
|
|
||||||
rmSync(cwd, { recursive: true, force: true });
|
rmSync(cwd, { recursive: true, force: true });
|
||||||
},
|
},
|
||||||
60000 // 1 minute timeout
|
60000 // 1 minute timeout
|
||||||
@@ -181,23 +165,11 @@ describe("daemon E2E", () => {
|
|||||||
stdio: "pipe",
|
stdio: "pipe",
|
||||||
});
|
});
|
||||||
|
|
||||||
// Create agent in the git repo (no modifications)
|
const result = await ctx.client.getCheckoutDiff(cwd, { mode: "uncommitted" });
|
||||||
const agent = await ctx.client.createAgent({
|
|
||||||
provider: "codex", model: CODEX_TEST_MODEL, thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
|
|
||||||
cwd,
|
|
||||||
title: "Git Diff Clean Test",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(agent.id).toBeTruthy();
|
|
||||||
|
|
||||||
// Get git diff - should be empty
|
|
||||||
const result = await ctx.client.getGitDiff(agent.id);
|
|
||||||
|
|
||||||
expect(result.error).toBeNull();
|
expect(result.error).toBeNull();
|
||||||
expect(result.diff).toBe("");
|
expect(result.files).toEqual([]);
|
||||||
|
|
||||||
// Cleanup
|
|
||||||
await ctx.client.deleteAgent(agent.id);
|
|
||||||
rmSync(cwd, { recursive: true, force: true });
|
rmSync(cwd, { recursive: true, force: true });
|
||||||
},
|
},
|
||||||
60000 // 1 minute timeout
|
60000 // 1 minute timeout
|
||||||
@@ -209,24 +181,12 @@ describe("daemon E2E", () => {
|
|||||||
const cwd = tmpCwd();
|
const cwd = tmpCwd();
|
||||||
// Don't initialize git - just a regular directory
|
// Don't initialize git - just a regular directory
|
||||||
|
|
||||||
// Create agent in a non-git directory
|
const result = await ctx.client.getCheckoutDiff(cwd, { mode: "uncommitted" });
|
||||||
const agent = await ctx.client.createAgent({
|
|
||||||
provider: "codex", model: CODEX_TEST_MODEL, thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
|
|
||||||
cwd,
|
|
||||||
title: "Git Diff Non-Git Test",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(agent.id).toBeTruthy();
|
expect(result.files).toEqual([]);
|
||||||
|
|
||||||
// Get git diff - should return error
|
|
||||||
const result = await ctx.client.getGitDiff(agent.id);
|
|
||||||
|
|
||||||
expect(result.diff).toBe("");
|
|
||||||
expect(result.error).toBeTruthy();
|
expect(result.error).toBeTruthy();
|
||||||
expect(result.error).toContain("git");
|
expect(result.error?.code).toBe("NOT_GIT_REPO");
|
||||||
|
|
||||||
// Cleanup
|
|
||||||
await ctx.client.deleteAgent(agent.id);
|
|
||||||
rmSync(cwd, { recursive: true, force: true });
|
rmSync(cwd, { recursive: true, force: true });
|
||||||
},
|
},
|
||||||
60000 // 1 minute timeout
|
60000 // 1 minute timeout
|
||||||
|
|||||||
@@ -315,7 +315,7 @@ export class DictationStreamManager {
|
|||||||
state.bytesSinceCommit += resampled.length;
|
state.bytesSinceCommit += resampled.length;
|
||||||
state.peakSinceCommit = Math.max(state.peakSinceCommit, pcm16lePeakAbs(resampled));
|
state.peakSinceCommit = Math.max(state.peakSinceCommit, pcm16lePeakAbs(resampled));
|
||||||
try {
|
try {
|
||||||
this.maybeAutoCommitDictationSegment(params.dictationId, state);
|
this.maybeAutoCommitDictationSegment(state);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
void this.failAndCleanupDictationStream(params.dictationId, message, true);
|
void this.failAndCleanupDictationStream(params.dictationId, message, true);
|
||||||
@@ -482,7 +482,7 @@ export class DictationStreamManager {
|
|||||||
this.streams.delete(dictationId);
|
this.streams.delete(dictationId);
|
||||||
}
|
}
|
||||||
|
|
||||||
private maybeAutoCommitDictationSegment(dictationId: string, state: DictationStreamState): void {
|
private maybeAutoCommitDictationSegment(state: DictationStreamState): void {
|
||||||
if (state.finishRequested) {
|
if (state.finishRequested) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -490,29 +490,12 @@ export class DictationStreamManager {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (state.peakSinceCommit < DICTATION_SILENCE_PEAK_THRESHOLD) {
|
if (state.peakSinceCommit < DICTATION_SILENCE_PEAK_THRESHOLD) {
|
||||||
this.logger.debug(
|
|
||||||
{
|
|
||||||
dictationId,
|
|
||||||
autoCommitBytes: state.autoCommitBytes,
|
|
||||||
bytesSinceCommit: state.bytesSinceCommit,
|
|
||||||
peakSinceCommit: state.peakSinceCommit,
|
|
||||||
},
|
|
||||||
"Dictation auto-segment: clearing silence-only segment"
|
|
||||||
);
|
|
||||||
state.stt.clear();
|
state.stt.clear();
|
||||||
state.bytesSinceCommit = 0;
|
state.bytesSinceCommit = 0;
|
||||||
state.peakSinceCommit = 0;
|
state.peakSinceCommit = 0;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.debug(
|
|
||||||
{
|
|
||||||
dictationId,
|
|
||||||
autoCommitBytes: state.autoCommitBytes,
|
|
||||||
bytesSinceCommit: state.bytesSinceCommit,
|
|
||||||
},
|
|
||||||
"Dictation auto-segment: committing buffered audio"
|
|
||||||
);
|
|
||||||
state.bytesSinceCommit = 0;
|
state.bytesSinceCommit = 0;
|
||||||
state.peakSinceCommit = 0;
|
state.peakSinceCommit = 0;
|
||||||
state.stt.commit();
|
state.stt.commit();
|
||||||
|
|||||||
@@ -6,6 +6,13 @@ export { createRootLogger, type LogLevel, type LogFormat } from "./logger.js";
|
|||||||
export { loadPersistedConfig, type PersistedConfig } from "./persisted-config.js";
|
export { loadPersistedConfig, type PersistedConfig } from "./persisted-config.js";
|
||||||
export { generateLocalPairingOffer, type LocalPairingOffer } from "./pairing-offer.js";
|
export { generateLocalPairingOffer, type LocalPairingOffer } from "./pairing-offer.js";
|
||||||
export { DaemonClient, type DaemonClientConfig, type ConnectionState, type DaemonEvent } from "../client/daemon-client.js";
|
export { DaemonClient, type DaemonClientConfig, type ConnectionState, type DaemonEvent } from "../client/daemon-client.js";
|
||||||
|
export {
|
||||||
|
ensureLocalSpeechModels,
|
||||||
|
listLocalSpeechModels,
|
||||||
|
type LocalSpeechModelId,
|
||||||
|
type LocalSttModelId,
|
||||||
|
type LocalTtsModelId,
|
||||||
|
} from "./speech/providers/local/models.js";
|
||||||
|
|
||||||
// Agent SDK types for CLI commands
|
// Agent SDK types for CLI commands
|
||||||
export type {
|
export type {
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ const SpeechProviderIdSchema = z
|
|||||||
|
|
||||||
const FeatureDictationSchema = z
|
const FeatureDictationSchema = z
|
||||||
.object({
|
.object({
|
||||||
|
enabled: z.boolean().optional(),
|
||||||
stt: z
|
stt: z
|
||||||
.object({
|
.object({
|
||||||
provider: SpeechProviderIdSchema.optional(),
|
provider: SpeechProviderIdSchema.optional(),
|
||||||
@@ -54,6 +55,7 @@ const FeatureDictationSchema = z
|
|||||||
|
|
||||||
const FeatureVoiceModeSchema = z
|
const FeatureVoiceModeSchema = z
|
||||||
.object({
|
.object({
|
||||||
|
enabled: z.boolean().optional(),
|
||||||
llm: z
|
llm: z
|
||||||
.object({
|
.object({
|
||||||
provider: z.enum(AGENT_PROVIDER_IDS as [string, ...string[]]).optional(),
|
provider: z.enum(AGENT_PROVIDER_IDS as [string, ...string[]]).optional(),
|
||||||
|
|||||||
191
packages/server/src/server/relay-transport.test.ts
Normal file
191
packages/server/src/server/relay-transport.test.ts
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||||
|
|
||||||
|
const wsMock = vi.hoisted(() => {
|
||||||
|
class MockWebSocket {
|
||||||
|
static readonly CONNECTING = 0;
|
||||||
|
static readonly OPEN = 1;
|
||||||
|
static readonly CLOSING = 2;
|
||||||
|
static readonly CLOSED = 3;
|
||||||
|
static instances: MockWebSocket[] = [];
|
||||||
|
|
||||||
|
readonly url: string;
|
||||||
|
readonly options: unknown;
|
||||||
|
readyState = MockWebSocket.CONNECTING;
|
||||||
|
sent: string[] = [];
|
||||||
|
terminateCalls = 0;
|
||||||
|
private listeners = new Map<string, Array<(...args: any[]) => void>>();
|
||||||
|
|
||||||
|
constructor(url: string, options?: unknown) {
|
||||||
|
this.url = url;
|
||||||
|
this.options = options;
|
||||||
|
MockWebSocket.instances.push(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
static reset() {
|
||||||
|
MockWebSocket.instances = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
on(event: string, listener: (...args: any[]) => void) {
|
||||||
|
const handlers = this.listeners.get(event) ?? [];
|
||||||
|
handlers.push(listener);
|
||||||
|
this.listeners.set(event, handlers);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
once(event: string, listener: (...args: any[]) => void) {
|
||||||
|
const wrapped = (...args: any[]) => {
|
||||||
|
this.off(event, wrapped);
|
||||||
|
listener(...args);
|
||||||
|
};
|
||||||
|
return this.on(event, wrapped);
|
||||||
|
}
|
||||||
|
|
||||||
|
close(code?: number, reason?: string) {
|
||||||
|
this.readyState = MockWebSocket.CLOSED;
|
||||||
|
this.emit("close", code ?? 1000, reason ?? "");
|
||||||
|
}
|
||||||
|
|
||||||
|
terminate() {
|
||||||
|
this.terminateCalls += 1;
|
||||||
|
this.readyState = MockWebSocket.CLOSED;
|
||||||
|
this.emit("close", 1006, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
send(data: string) {
|
||||||
|
if (this.readyState !== MockWebSocket.OPEN) {
|
||||||
|
throw new Error(`WebSocket not open (readyState=${this.readyState})`);
|
||||||
|
}
|
||||||
|
this.sent.push(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
open() {
|
||||||
|
this.readyState = MockWebSocket.OPEN;
|
||||||
|
this.emit("open");
|
||||||
|
}
|
||||||
|
|
||||||
|
message(data: unknown) {
|
||||||
|
this.emit("message", data);
|
||||||
|
}
|
||||||
|
|
||||||
|
error(err: unknown) {
|
||||||
|
this.emit("error", err);
|
||||||
|
}
|
||||||
|
|
||||||
|
private off(event: string, listener: (...args: any[]) => void) {
|
||||||
|
const handlers = this.listeners.get(event) ?? [];
|
||||||
|
this.listeners.set(
|
||||||
|
event,
|
||||||
|
handlers.filter((handler) => handler !== listener)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private emit(event: string, ...args: any[]) {
|
||||||
|
const handlers = this.listeners.get(event) ?? [];
|
||||||
|
for (const handler of [...handlers]) {
|
||||||
|
handler(...args);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { MockWebSocket };
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("ws", () => ({ default: wsMock.MockWebSocket }));
|
||||||
|
|
||||||
|
import { startRelayTransport } from "./relay-transport";
|
||||||
|
|
||||||
|
function createMockLogger() {
|
||||||
|
const logger = {
|
||||||
|
child: vi.fn(() => logger),
|
||||||
|
debug: vi.fn(),
|
||||||
|
info: vi.fn(),
|
||||||
|
warn: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
};
|
||||||
|
return logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasLogMessage(mockFn: ReturnType<typeof vi.fn>, message: string): boolean {
|
||||||
|
return mockFn.mock.calls.some((call) => call.some((arg) => arg === message));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("relay-transport control lifecycle", () => {
|
||||||
|
const controllers: Array<{ stop: () => Promise<void> }> = [];
|
||||||
|
const MockWebSocket = wsMock.MockWebSocket;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
MockWebSocket.reset();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
for (const controller of controllers) {
|
||||||
|
await controller.stop();
|
||||||
|
}
|
||||||
|
controllers.length = 0;
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("logs relay_control_connected only after first valid control message", () => {
|
||||||
|
const logger = createMockLogger();
|
||||||
|
const controller = startRelayTransport({
|
||||||
|
logger: logger as any,
|
||||||
|
attachSocket: async () => {},
|
||||||
|
relayEndpoint: "relay.paseo.sh:443",
|
||||||
|
serverId: "srv_test",
|
||||||
|
});
|
||||||
|
controllers.push(controller);
|
||||||
|
|
||||||
|
const control = MockWebSocket.instances[0];
|
||||||
|
expect(control).toBeDefined();
|
||||||
|
|
||||||
|
control.open();
|
||||||
|
expect(hasLogMessage(logger.info, "relay_control_connected")).toBe(false);
|
||||||
|
expect(control.sent.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
control.message(JSON.stringify({ type: "pong", ts: Date.now() }));
|
||||||
|
expect(hasLogMessage(logger.info, "relay_control_connected")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("terminates and reconnects when control socket opens but never becomes ready", () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const logger = createMockLogger();
|
||||||
|
const controller = startRelayTransport({
|
||||||
|
logger: logger as any,
|
||||||
|
attachSocket: async () => {},
|
||||||
|
relayEndpoint: "relay.paseo.sh:443",
|
||||||
|
serverId: "srv_test",
|
||||||
|
});
|
||||||
|
controllers.push(controller);
|
||||||
|
|
||||||
|
const firstControl = MockWebSocket.instances[0];
|
||||||
|
firstControl.open();
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(8_000);
|
||||||
|
expect(hasLogMessage(logger.warn, "relay_control_ready_timeout_terminating")).toBe(true);
|
||||||
|
expect(firstControl.terminateCalls).toBe(1);
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(1_000);
|
||||||
|
expect(MockWebSocket.instances.length).toBeGreaterThanOrEqual(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("terminates stale control sockets in under one minute", () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const logger = createMockLogger();
|
||||||
|
const controller = startRelayTransport({
|
||||||
|
logger: logger as any,
|
||||||
|
attachSocket: async () => {},
|
||||||
|
relayEndpoint: "relay.paseo.sh:443",
|
||||||
|
serverId: "srv_test",
|
||||||
|
});
|
||||||
|
controllers.push(controller);
|
||||||
|
|
||||||
|
const control = MockWebSocket.instances[0];
|
||||||
|
control.open();
|
||||||
|
control.message(JSON.stringify({ type: "pong", ts: Date.now() }));
|
||||||
|
logger.warn.mockClear();
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(40_000);
|
||||||
|
expect(hasLogMessage(logger.warn, "relay_control_stale_terminating")).toBe(true);
|
||||||
|
expect(control.terminateCalls).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -37,6 +37,10 @@ type ControlMessage =
|
|||||||
| { type: "ping" }
|
| { type: "ping" }
|
||||||
| { type: "pong" };
|
| { type: "pong" };
|
||||||
|
|
||||||
|
const CONTROL_PING_INTERVAL_MS = 10_000;
|
||||||
|
const CONTROL_STALE_TIMEOUT_MS = 30_000;
|
||||||
|
const CONTROL_READY_TIMEOUT_MS = 8_000;
|
||||||
|
|
||||||
function tryParseControlMessage(raw: unknown): ControlMessage | null {
|
function tryParseControlMessage(raw: unknown): ControlMessage | null {
|
||||||
try {
|
try {
|
||||||
const text =
|
const text =
|
||||||
@@ -76,7 +80,9 @@ export function startRelayTransport({
|
|||||||
let reconnectAttempt = 0;
|
let reconnectAttempt = 0;
|
||||||
const dataSockets = new Map<string, WebSocket>(); // clientId -> ws
|
const dataSockets = new Map<string, WebSocket>(); // clientId -> ws
|
||||||
let controlKeepaliveInterval: ReturnType<typeof setInterval> | null = null;
|
let controlKeepaliveInterval: ReturnType<typeof setInterval> | null = null;
|
||||||
|
let controlReadyTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
let controlLastSeenAt = 0;
|
let controlLastSeenAt = 0;
|
||||||
|
let controlConnectionSeq = 0;
|
||||||
|
|
||||||
const stop = async (): Promise<void> => {
|
const stop = async (): Promise<void> => {
|
||||||
stopped = true;
|
stopped = true;
|
||||||
@@ -88,6 +94,10 @@ export function startRelayTransport({
|
|||||||
clearInterval(controlKeepaliveInterval);
|
clearInterval(controlKeepaliveInterval);
|
||||||
controlKeepaliveInterval = null;
|
controlKeepaliveInterval = null;
|
||||||
}
|
}
|
||||||
|
if (controlReadyTimeout) {
|
||||||
|
clearTimeout(controlReadyTimeout);
|
||||||
|
controlReadyTimeout = null;
|
||||||
|
}
|
||||||
if (controlWs) {
|
if (controlWs) {
|
||||||
try {
|
try {
|
||||||
controlWs.close();
|
controlWs.close();
|
||||||
@@ -109,6 +119,7 @@ export function startRelayTransport({
|
|||||||
const connectControl = (): void => {
|
const connectControl = (): void => {
|
||||||
if (stopped) return;
|
if (stopped) return;
|
||||||
|
|
||||||
|
const connectionId = ++controlConnectionSeq;
|
||||||
const url = buildRelayWebSocketUrl({
|
const url = buildRelayWebSocketUrl({
|
||||||
endpoint: relayEndpoint,
|
endpoint: relayEndpoint,
|
||||||
serverId,
|
serverId,
|
||||||
@@ -116,14 +127,46 @@ export function startRelayTransport({
|
|||||||
});
|
});
|
||||||
const socket = new WebSocket(url, { handshakeTimeout: 10_000, perMessageDeflate: false });
|
const socket = new WebSocket(url, { handshakeTimeout: 10_000, perMessageDeflate: false });
|
||||||
controlWs = socket;
|
controlWs = socket;
|
||||||
|
let controlConnected = false;
|
||||||
|
|
||||||
|
const markControlReady = () => {
|
||||||
|
if (controlWs !== socket) return;
|
||||||
|
if (controlConnected) return;
|
||||||
|
controlConnected = true;
|
||||||
|
reconnectAttempt = 0;
|
||||||
|
if (controlReadyTimeout) {
|
||||||
|
clearTimeout(controlReadyTimeout);
|
||||||
|
controlReadyTimeout = null;
|
||||||
|
}
|
||||||
|
relayLogger.info({ url, connectionId }, "relay_control_connected");
|
||||||
|
};
|
||||||
|
|
||||||
socket.on("open", () => {
|
socket.on("open", () => {
|
||||||
reconnectAttempt = 0;
|
if (controlWs !== socket) return;
|
||||||
|
|
||||||
controlLastSeenAt = Date.now();
|
controlLastSeenAt = Date.now();
|
||||||
if (controlKeepaliveInterval) {
|
if (controlKeepaliveInterval) {
|
||||||
clearInterval(controlKeepaliveInterval);
|
clearInterval(controlKeepaliveInterval);
|
||||||
controlKeepaliveInterval = null;
|
controlKeepaliveInterval = null;
|
||||||
}
|
}
|
||||||
|
if (controlReadyTimeout) {
|
||||||
|
clearTimeout(controlReadyTimeout);
|
||||||
|
controlReadyTimeout = null;
|
||||||
|
}
|
||||||
|
controlReadyTimeout = setTimeout(() => {
|
||||||
|
if (stopped) return;
|
||||||
|
if (controlWs !== socket) return;
|
||||||
|
if (controlConnected) return;
|
||||||
|
relayLogger.warn(
|
||||||
|
{ url, connectionId, waitedMs: CONTROL_READY_TIMEOUT_MS },
|
||||||
|
"relay_control_ready_timeout_terminating"
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
socket.terminate();
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}, CONTROL_READY_TIMEOUT_MS);
|
||||||
controlKeepaliveInterval = setInterval(() => {
|
controlKeepaliveInterval = setInterval(() => {
|
||||||
if (stopped) return;
|
if (stopped) return;
|
||||||
if (controlWs !== socket) return;
|
if (controlWs !== socket) return;
|
||||||
@@ -133,8 +176,11 @@ export function startRelayTransport({
|
|||||||
const staleForMs = now - controlLastSeenAt;
|
const staleForMs = now - controlLastSeenAt;
|
||||||
// If the control socket is half-open or silently dropped, ws may never emit "close".
|
// If the control socket is half-open or silently dropped, ws may never emit "close".
|
||||||
// Use app-level ping/pong to detect staleness and force a reconnect.
|
// Use app-level ping/pong to detect staleness and force a reconnect.
|
||||||
if (staleForMs > 90_000) {
|
if (staleForMs > CONTROL_STALE_TIMEOUT_MS) {
|
||||||
relayLogger.warn({ url, staleForMs }, "relay_control_stale_terminating");
|
relayLogger.warn(
|
||||||
|
{ url, staleForMs, connectionId, staleTimeoutMs: CONTROL_STALE_TIMEOUT_MS },
|
||||||
|
"relay_control_stale_terminating"
|
||||||
|
);
|
||||||
try {
|
try {
|
||||||
socket.terminate();
|
socket.terminate();
|
||||||
} catch {
|
} catch {
|
||||||
@@ -146,40 +192,58 @@ export function startRelayTransport({
|
|||||||
try {
|
try {
|
||||||
socket.send(JSON.stringify({ type: "ping", ts: now }));
|
socket.send(JSON.stringify({ type: "ping", ts: now }));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
relayLogger.warn({ err: error, url }, "relay_control_ping_send_failed");
|
relayLogger.warn({ err: error, url, connectionId }, "relay_control_ping_send_failed");
|
||||||
try {
|
try {
|
||||||
socket.terminate();
|
socket.terminate();
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, 20_000);
|
}, CONTROL_PING_INTERVAL_MS);
|
||||||
relayLogger.info({ url }, "relay_control_connected");
|
try {
|
||||||
|
socket.send(JSON.stringify({ type: "ping", ts: Date.now() }));
|
||||||
|
} catch (error) {
|
||||||
|
relayLogger.warn({ err: error, url, connectionId }, "relay_control_ping_send_failed");
|
||||||
|
try {
|
||||||
|
socket.terminate();
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
relayLogger.debug({ url, connectionId }, "relay_control_open_waiting_for_ready");
|
||||||
});
|
});
|
||||||
|
|
||||||
socket.on("close", (code, reason) => {
|
socket.on("close", (code, reason) => {
|
||||||
|
if (controlWs !== socket) return;
|
||||||
relayLogger.warn(
|
relayLogger.warn(
|
||||||
{ code, reason: reason?.toString?.(), url },
|
{ code, reason: reason?.toString?.(), url, connectionId },
|
||||||
"relay_control_disconnected"
|
"relay_control_disconnected"
|
||||||
);
|
);
|
||||||
if (controlWs === socket) {
|
controlWs = null;
|
||||||
controlWs = null;
|
|
||||||
}
|
|
||||||
if (controlKeepaliveInterval) {
|
if (controlKeepaliveInterval) {
|
||||||
clearInterval(controlKeepaliveInterval);
|
clearInterval(controlKeepaliveInterval);
|
||||||
controlKeepaliveInterval = null;
|
controlKeepaliveInterval = null;
|
||||||
}
|
}
|
||||||
|
if (controlReadyTimeout) {
|
||||||
|
clearTimeout(controlReadyTimeout);
|
||||||
|
controlReadyTimeout = null;
|
||||||
|
}
|
||||||
scheduleReconnect();
|
scheduleReconnect();
|
||||||
});
|
});
|
||||||
|
|
||||||
socket.on("error", (err) => {
|
socket.on("error", (err) => {
|
||||||
relayLogger.warn({ err, url }, "relay_error");
|
if (controlWs !== socket) return;
|
||||||
|
relayLogger.warn({ err, url, connectionId }, "relay_error");
|
||||||
// close event will schedule reconnect
|
// close event will schedule reconnect
|
||||||
});
|
});
|
||||||
|
|
||||||
socket.on("message", (data) => {
|
socket.on("message", (data) => {
|
||||||
|
if (controlWs !== socket) return;
|
||||||
controlLastSeenAt = Date.now();
|
controlLastSeenAt = Date.now();
|
||||||
const msg = tryParseControlMessage(data);
|
const msg = tryParseControlMessage(data);
|
||||||
|
if (msg) {
|
||||||
|
markControlReady();
|
||||||
|
}
|
||||||
if (!msg) return;
|
if (!msg) return;
|
||||||
if (msg.type === "ping") {
|
if (msg.type === "ping") {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ import {
|
|||||||
type ProjectPlacementPayload,
|
type ProjectPlacementPayload,
|
||||||
} from "./messages.js";
|
} from "./messages.js";
|
||||||
import type { TerminalManager } from "../terminal/terminal-manager.js";
|
import type { TerminalManager } from "../terminal/terminal-manager.js";
|
||||||
import { parseAndHighlightDiff, type ParsedDiffFile } from "./utils/diff-highlighter.js";
|
|
||||||
import { TTSManager } from "./agent/tts-manager.js";
|
import { TTSManager } from "./agent/tts-manager.js";
|
||||||
import { STTManager } from "./agent/stt-manager.js";
|
import { STTManager } from "./agent/stt-manager.js";
|
||||||
import type { SpeechToTextProvider, TextToSpeechProvider } from "./speech/speech-provider.js";
|
import type { SpeechToTextProvider, TextToSpeechProvider } from "./speech/speech-provider.js";
|
||||||
@@ -581,7 +580,7 @@ export class Session {
|
|||||||
void this.initializeAgentMcp();
|
void this.initializeAgentMcp();
|
||||||
this.subscribeToAgentEvents();
|
this.subscribeToAgentEvents();
|
||||||
|
|
||||||
this.sessionLogger.info("Session created");
|
this.sessionLogger.trace("Session created");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -750,7 +749,7 @@ export class Session {
|
|||||||
|
|
||||||
this.agentTools = (await this.agentMcpClient.tools()) as ToolSet;
|
this.agentTools = (await this.agentMcpClient.tools()) as ToolSet;
|
||||||
const agentToolCount = Object.keys(this.agentTools ?? {}).length;
|
const agentToolCount = Object.keys(this.agentTools ?? {}).length;
|
||||||
this.sessionLogger.info(
|
this.sessionLogger.trace(
|
||||||
{ agentToolCount },
|
{ agentToolCount },
|
||||||
`Agent MCP initialized with ${agentToolCount} tools`
|
`Agent MCP initialized with ${agentToolCount} tools`
|
||||||
);
|
);
|
||||||
@@ -941,9 +940,17 @@ export class Session {
|
|||||||
agentId,
|
agentId,
|
||||||
extractTimestamps(record)
|
extractTimestamps(record)
|
||||||
);
|
);
|
||||||
|
this.sessionLogger.info(
|
||||||
|
{ agentId, provider: record.provider },
|
||||||
|
"Agent resumed from persistence"
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
const config = buildSessionConfig(record);
|
const config = buildSessionConfig(record);
|
||||||
snapshot = await this.agentManager.createAgent(config, agentId, { labels: record.labels });
|
snapshot = await this.agentManager.createAgent(config, agentId, { labels: record.labels });
|
||||||
|
this.sessionLogger.info(
|
||||||
|
{ agentId, provider: record.provider },
|
||||||
|
"Agent created from stored config"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.agentManager.hydrateTimelineFromProvider(agentId);
|
await this.agentManager.hydrateTimelineFromProvider(agentId);
|
||||||
@@ -1215,10 +1222,6 @@ export class Session {
|
|||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "git_diff_request":
|
|
||||||
await this.handleGitDiffRequest(msg.agentId, msg.requestId);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "checkout_status_request":
|
case "checkout_status_request":
|
||||||
await this.handleCheckoutStatusRequest(msg);
|
await this.handleCheckoutStatusRequest(msg);
|
||||||
break;
|
break;
|
||||||
@@ -1267,10 +1270,6 @@ export class Session {
|
|||||||
await this.handlePaseoWorktreeArchiveRequest(msg);
|
await this.handlePaseoWorktreeArchiveRequest(msg);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "highlighted_diff_request":
|
|
||||||
await this.handleHighlightedDiffRequest(msg.agentId, msg.requestId);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "file_explorer_request":
|
case "file_explorer_request":
|
||||||
await this.handleFileExplorerRequest(msg);
|
await this.handleFileExplorerRequest(msg);
|
||||||
break;
|
break;
|
||||||
@@ -2087,11 +2086,6 @@ export class Session {
|
|||||||
agentId: string,
|
agentId: string,
|
||||||
requestId: string
|
requestId: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
this.sessionLogger.info(
|
|
||||||
{ agentId },
|
|
||||||
`Initializing agent ${agentId} on demand`
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const snapshot = await this.ensureAgentLoaded(agentId);
|
const snapshot = await this.ensureAgentLoaded(agentId);
|
||||||
await this.forwardAgentUpdate(snapshot);
|
await this.forwardAgentUpdate(snapshot);
|
||||||
@@ -2108,11 +2102,6 @@ export class Session {
|
|||||||
requestId,
|
requestId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
this.sessionLogger.info(
|
|
||||||
{ agentId, timelineSize, status: snapshot.lifecycle },
|
|
||||||
`Agent ${agentId} initialized with ${timelineSize} timeline item(s); status=${snapshot.lifecycle}`
|
|
||||||
);
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
this.sessionLogger.error(
|
this.sessionLogger.error(
|
||||||
{ err: error, agentId },
|
{ err: error, agentId },
|
||||||
@@ -3393,66 +3382,6 @@ export class Session {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle git diff request for an agent
|
|
||||||
*/
|
|
||||||
private async handleGitDiffRequest(agentId: string, requestId: string): Promise<void> {
|
|
||||||
this.sessionLogger.debug(
|
|
||||||
{ agentId },
|
|
||||||
`Handling git diff request for agent ${agentId}`
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const agents = this.agentManager.listAgents();
|
|
||||||
const agent = agents.find((a) => a.id === agentId);
|
|
||||||
|
|
||||||
if (!agent) {
|
|
||||||
this.emit({
|
|
||||||
type: "git_diff_response",
|
|
||||||
payload: {
|
|
||||||
agentId,
|
|
||||||
diff: "",
|
|
||||||
error: `Agent not found: ${agentId}`,
|
|
||||||
requestId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const diffResult = await getCheckoutDiff(agent.cwd, { mode: "uncommitted" }, { paseoHome: this.paseoHome });
|
|
||||||
const combinedDiff = diffResult.diff;
|
|
||||||
|
|
||||||
this.emit({
|
|
||||||
type: "git_diff_response",
|
|
||||||
payload: {
|
|
||||||
agentId,
|
|
||||||
diff: combinedDiff,
|
|
||||||
error: null,
|
|
||||||
requestId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
this.sessionLogger.debug(
|
|
||||||
{ agentId, diffBytes: combinedDiff.length },
|
|
||||||
`Git diff for agent ${agentId} completed (${combinedDiff.length} bytes)`
|
|
||||||
);
|
|
||||||
} catch (error: any) {
|
|
||||||
this.sessionLogger.error(
|
|
||||||
{ err: error, agentId },
|
|
||||||
`Failed to get git diff for agent ${agentId}`
|
|
||||||
);
|
|
||||||
this.emit({
|
|
||||||
type: "git_diff_response",
|
|
||||||
payload: {
|
|
||||||
agentId,
|
|
||||||
diff: "",
|
|
||||||
error: error.message,
|
|
||||||
requestId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleCheckoutStatusRequest(
|
private async handleCheckoutStatusRequest(
|
||||||
msg: Extract<SessionInboundMessage, { type: "checkout_status_request" }>
|
msg: Extract<SessionInboundMessage, { type: "checkout_status_request" }>
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
@@ -4190,12 +4119,13 @@ export class Session {
|
|||||||
const { cwd, requestId } = msg;
|
const { cwd, requestId } = msg;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const status = await getPullRequestStatus(cwd);
|
const prStatus = await getPullRequestStatus(cwd);
|
||||||
this.emit({
|
this.emit({
|
||||||
type: "checkout_pr_status_response",
|
type: "checkout_pr_status_response",
|
||||||
payload: {
|
payload: {
|
||||||
cwd,
|
cwd,
|
||||||
status,
|
status: prStatus.status,
|
||||||
|
githubFeaturesEnabled: prStatus.githubFeaturesEnabled,
|
||||||
error: null,
|
error: null,
|
||||||
requestId,
|
requestId,
|
||||||
},
|
},
|
||||||
@@ -4206,6 +4136,7 @@ export class Session {
|
|||||||
payload: {
|
payload: {
|
||||||
cwd,
|
cwd,
|
||||||
status: null,
|
status: null,
|
||||||
|
githubFeaturesEnabled: true,
|
||||||
error: this.toCheckoutError(error),
|
error: this.toCheckoutError(error),
|
||||||
requestId,
|
requestId,
|
||||||
},
|
},
|
||||||
@@ -4373,237 +4304,6 @@ export class Session {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle highlighted diff request - returns parsed and syntax-highlighted diff
|
|
||||||
*/
|
|
||||||
private async handleHighlightedDiffRequest(
|
|
||||||
agentId: string,
|
|
||||||
requestId: string
|
|
||||||
): Promise<void> {
|
|
||||||
this.sessionLogger.debug(
|
|
||||||
{ agentId },
|
|
||||||
`Handling highlighted diff request for agent ${agentId}`
|
|
||||||
);
|
|
||||||
|
|
||||||
// Maximum lines changed before we skip showing the diff content
|
|
||||||
const MAX_DIFF_LINES = 5000;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const agents = this.agentManager.listAgents();
|
|
||||||
const agent = agents.find((a) => a.id === agentId);
|
|
||||||
|
|
||||||
if (!agent) {
|
|
||||||
this.emit({
|
|
||||||
type: "highlighted_diff_response",
|
|
||||||
payload: {
|
|
||||||
agentId,
|
|
||||||
files: [],
|
|
||||||
error: `Agent not found: ${agentId}`,
|
|
||||||
requestId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 1: Get the list of changed files with their stats (numstat gives additions/deletions per file)
|
|
||||||
const { stdout: numstatOutput } = await execAsync(
|
|
||||||
"git diff --numstat HEAD",
|
|
||||||
{ cwd: agent.cwd }
|
|
||||||
);
|
|
||||||
|
|
||||||
// Get file statuses (A=added, D=deleted, M=modified) to detect deleted files
|
|
||||||
const { stdout: nameStatusOutput } = await execAsync(
|
|
||||||
"git diff --name-status HEAD",
|
|
||||||
{ cwd: agent.cwd }
|
|
||||||
);
|
|
||||||
const deletedFiles = new Set<string>();
|
|
||||||
const addedFiles = new Set<string>();
|
|
||||||
for (const line of nameStatusOutput.trim().split("\n").filter(Boolean)) {
|
|
||||||
const [status, ...pathParts] = line.split("\t");
|
|
||||||
const path = pathParts.join("\t");
|
|
||||||
if (status === "D") {
|
|
||||||
deletedFiles.add(path);
|
|
||||||
} else if (status === "A") {
|
|
||||||
addedFiles.add(path);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse numstat output: "additions\tdeletions\tfilepath" or "-\t-\tfilepath" for binary
|
|
||||||
interface FileStats {
|
|
||||||
path: string;
|
|
||||||
additions: number;
|
|
||||||
deletions: number;
|
|
||||||
isBinary: boolean;
|
|
||||||
isTracked: boolean;
|
|
||||||
isDeleted: boolean;
|
|
||||||
isNew: boolean;
|
|
||||||
}
|
|
||||||
const fileStats: FileStats[] = [];
|
|
||||||
|
|
||||||
for (const line of numstatOutput.trim().split("\n").filter(Boolean)) {
|
|
||||||
const parts = line.split("\t");
|
|
||||||
if (parts.length >= 3) {
|
|
||||||
const [addStr, delStr, ...pathParts] = parts;
|
|
||||||
const path = pathParts.join("\t"); // Handle paths with tabs
|
|
||||||
const isBinary = addStr === "-" && delStr === "-";
|
|
||||||
fileStats.push({
|
|
||||||
path,
|
|
||||||
additions: isBinary ? 0 : parseInt(addStr, 10),
|
|
||||||
deletions: isBinary ? 0 : parseInt(delStr, 10),
|
|
||||||
isBinary,
|
|
||||||
isTracked: true,
|
|
||||||
isDeleted: deletedFiles.has(path),
|
|
||||||
isNew: addedFiles.has(path),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 2: Get untracked files
|
|
||||||
try {
|
|
||||||
const { stdout: untrackedFiles } = await execAsync(
|
|
||||||
"git ls-files --others --exclude-standard",
|
|
||||||
{ cwd: agent.cwd }
|
|
||||||
);
|
|
||||||
for (const filePath of untrackedFiles.trim().split("\n").filter(Boolean)) {
|
|
||||||
// Use git's numstat with --no-index to detect binary files (cross-platform)
|
|
||||||
// Binary files show as "-\t-\tfilepath", text files show line counts
|
|
||||||
try {
|
|
||||||
const { stdout: numstatLine } = await execAsync(
|
|
||||||
`git diff --numstat --no-index /dev/null "${filePath}" || true`,
|
|
||||||
{ cwd: agent.cwd }
|
|
||||||
);
|
|
||||||
const parts = numstatLine.trim().split("\t");
|
|
||||||
const isBinary = parts[0] === "-" && parts[1] === "-";
|
|
||||||
const additions = isBinary ? 0 : (parseInt(parts[0], 10) || 0);
|
|
||||||
|
|
||||||
fileStats.push({
|
|
||||||
path: filePath,
|
|
||||||
additions,
|
|
||||||
deletions: 0,
|
|
||||||
isBinary,
|
|
||||||
isTracked: false,
|
|
||||||
isDeleted: false,
|
|
||||||
isNew: true,
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
// If we can't determine, assume text and try to get it
|
|
||||||
fileStats.push({
|
|
||||||
path: filePath,
|
|
||||||
additions: 0,
|
|
||||||
deletions: 0,
|
|
||||||
isBinary: false,
|
|
||||||
isTracked: false,
|
|
||||||
isDeleted: false,
|
|
||||||
isNew: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Ignore errors getting untracked files
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 3: Fetch diffs per-file, respecting limits
|
|
||||||
const allFiles: ParsedDiffFile[] = [];
|
|
||||||
|
|
||||||
for (const stats of fileStats) {
|
|
||||||
const totalLines = stats.additions + stats.deletions;
|
|
||||||
|
|
||||||
// Handle binary files
|
|
||||||
if (stats.isBinary) {
|
|
||||||
allFiles.push({
|
|
||||||
path: stats.path,
|
|
||||||
isNew: stats.isNew,
|
|
||||||
isDeleted: stats.isDeleted,
|
|
||||||
additions: 0,
|
|
||||||
deletions: 0,
|
|
||||||
hunks: [],
|
|
||||||
status: "binary",
|
|
||||||
});
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle files that are too large
|
|
||||||
if (totalLines > MAX_DIFF_LINES) {
|
|
||||||
allFiles.push({
|
|
||||||
path: stats.path,
|
|
||||||
isNew: stats.isNew,
|
|
||||||
isDeleted: stats.isDeleted,
|
|
||||||
additions: stats.additions,
|
|
||||||
deletions: stats.deletions,
|
|
||||||
hunks: [],
|
|
||||||
status: "too_large",
|
|
||||||
});
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch the actual diff for this file
|
|
||||||
try {
|
|
||||||
let fileDiff: string;
|
|
||||||
if (stats.isTracked) {
|
|
||||||
const { stdout } = await execAsync(
|
|
||||||
`git diff HEAD -- "${stats.path}"`,
|
|
||||||
{ cwd: agent.cwd }
|
|
||||||
);
|
|
||||||
fileDiff = stdout;
|
|
||||||
} else {
|
|
||||||
const { stdout } = await execAsync(
|
|
||||||
`git diff --no-index /dev/null "${stats.path}" || true`,
|
|
||||||
{ cwd: agent.cwd }
|
|
||||||
);
|
|
||||||
fileDiff = stdout;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (fileDiff) {
|
|
||||||
const parsedFiles = await parseAndHighlightDiff(fileDiff, agent.cwd);
|
|
||||||
for (const file of parsedFiles) {
|
|
||||||
allFiles.push({ ...file, status: "ok" });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// If diff fails for this file, add it with empty hunks
|
|
||||||
allFiles.push({
|
|
||||||
path: stats.path,
|
|
||||||
isNew: stats.isNew,
|
|
||||||
isDeleted: stats.isDeleted,
|
|
||||||
additions: stats.additions,
|
|
||||||
deletions: stats.deletions,
|
|
||||||
hunks: [],
|
|
||||||
status: "ok",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.emit({
|
|
||||||
type: "highlighted_diff_response",
|
|
||||||
payload: {
|
|
||||||
agentId,
|
|
||||||
files: allFiles,
|
|
||||||
error: null,
|
|
||||||
requestId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
this.sessionLogger.debug(
|
|
||||||
{ agentId, fileCount: allFiles.length },
|
|
||||||
`Highlighted diff for agent ${agentId} completed (${allFiles.length} files)`
|
|
||||||
);
|
|
||||||
} catch (error: any) {
|
|
||||||
this.sessionLogger.error(
|
|
||||||
{ err: error, agentId },
|
|
||||||
`Failed to get highlighted diff for agent ${agentId}`
|
|
||||||
);
|
|
||||||
this.emit({
|
|
||||||
type: "highlighted_diff_response",
|
|
||||||
payload: {
|
|
||||||
agentId,
|
|
||||||
files: [],
|
|
||||||
error: error.message,
|
|
||||||
requestId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle read-only file explorer requests scoped to an agent's cwd
|
* Handle read-only file explorer requests scoped to an agent's cwd
|
||||||
*/
|
*/
|
||||||
@@ -5871,7 +5571,7 @@ export class Session {
|
|||||||
* Clean up session resources
|
* Clean up session resources
|
||||||
*/
|
*/
|
||||||
public async cleanup(): Promise<void> {
|
public async cleanup(): Promise<void> {
|
||||||
this.sessionLogger.info("Cleaning up");
|
this.sessionLogger.trace("Cleaning up");
|
||||||
|
|
||||||
if (this.unsubscribeAgentEvents) {
|
if (this.unsubscribeAgentEvents) {
|
||||||
this.unsubscribeAgentEvents();
|
this.unsubscribeAgentEvents();
|
||||||
|
|||||||
@@ -73,9 +73,10 @@ const LocalSpeechResolutionSchema = z.object({
|
|||||||
|
|
||||||
function persistedLocalFeatureModel(
|
function persistedLocalFeatureModel(
|
||||||
provider: RequestedSpeechProviders[keyof RequestedSpeechProviders]["provider"],
|
provider: RequestedSpeechProviders[keyof RequestedSpeechProviders]["provider"],
|
||||||
|
enabled: boolean | undefined,
|
||||||
model: string | undefined
|
model: string | undefined
|
||||||
): string | undefined {
|
): string | undefined {
|
||||||
if (provider !== "local") {
|
if (provider !== "local" || enabled === false) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
return model;
|
return model;
|
||||||
@@ -87,9 +88,12 @@ function shouldIncludeLocalProviderConfig(params: {
|
|||||||
persisted: PersistedConfig;
|
persisted: PersistedConfig;
|
||||||
}): boolean {
|
}): boolean {
|
||||||
const localRequestedByFeature =
|
const localRequestedByFeature =
|
||||||
params.providers.dictationStt.provider === "local" ||
|
(params.providers.dictationStt.enabled !== false &&
|
||||||
params.providers.voiceStt.provider === "local" ||
|
params.providers.dictationStt.provider === "local") ||
|
||||||
params.providers.voiceTts.provider === "local";
|
(params.providers.voiceStt.enabled !== false &&
|
||||||
|
params.providers.voiceStt.provider === "local") ||
|
||||||
|
(params.providers.voiceTts.enabled !== false &&
|
||||||
|
params.providers.voiceTts.provider === "local");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
localRequestedByFeature ||
|
localRequestedByFeature ||
|
||||||
@@ -119,6 +123,7 @@ export function resolveLocalSpeechConfig(params: {
|
|||||||
params.env.PASEO_DICTATION_LOCAL_STT_MODEL ??
|
params.env.PASEO_DICTATION_LOCAL_STT_MODEL ??
|
||||||
persistedLocalFeatureModel(
|
persistedLocalFeatureModel(
|
||||||
params.providers.dictationStt.provider,
|
params.providers.dictationStt.provider,
|
||||||
|
params.providers.dictationStt.enabled,
|
||||||
params.persisted.features?.dictation?.stt?.model
|
params.persisted.features?.dictation?.stt?.model
|
||||||
) ??
|
) ??
|
||||||
DEFAULT_LOCAL_STT_MODEL,
|
DEFAULT_LOCAL_STT_MODEL,
|
||||||
@@ -126,6 +131,7 @@ export function resolveLocalSpeechConfig(params: {
|
|||||||
params.env.PASEO_VOICE_LOCAL_STT_MODEL ??
|
params.env.PASEO_VOICE_LOCAL_STT_MODEL ??
|
||||||
persistedLocalFeatureModel(
|
persistedLocalFeatureModel(
|
||||||
params.providers.voiceStt.provider,
|
params.providers.voiceStt.provider,
|
||||||
|
params.providers.voiceStt.enabled,
|
||||||
params.persisted.features?.voiceMode?.stt?.model
|
params.persisted.features?.voiceMode?.stt?.model
|
||||||
) ??
|
) ??
|
||||||
DEFAULT_LOCAL_STT_MODEL,
|
DEFAULT_LOCAL_STT_MODEL,
|
||||||
@@ -133,6 +139,7 @@ export function resolveLocalSpeechConfig(params: {
|
|||||||
params.env.PASEO_VOICE_LOCAL_TTS_MODEL ??
|
params.env.PASEO_VOICE_LOCAL_TTS_MODEL ??
|
||||||
persistedLocalFeatureModel(
|
persistedLocalFeatureModel(
|
||||||
params.providers.voiceTts.provider,
|
params.providers.voiceTts.provider,
|
||||||
|
params.providers.voiceTts.enabled,
|
||||||
params.persisted.features?.voiceMode?.tts?.model
|
params.persisted.features?.voiceMode?.tts?.model
|
||||||
) ??
|
) ??
|
||||||
DEFAULT_LOCAL_TTS_MODEL,
|
DEFAULT_LOCAL_TTS_MODEL,
|
||||||
|
|||||||
@@ -88,13 +88,22 @@ function computeRequiredLocalModelIds(params: {
|
|||||||
models: ResolvedLocalModels;
|
models: ResolvedLocalModels;
|
||||||
}): LocalSpeechModelId[] {
|
}): LocalSpeechModelId[] {
|
||||||
const ids = new Set<LocalSpeechModelId>();
|
const ids = new Set<LocalSpeechModelId>();
|
||||||
if (params.providers.dictationStt.provider === "local") {
|
if (
|
||||||
|
params.providers.dictationStt.enabled !== false &&
|
||||||
|
params.providers.dictationStt.provider === "local"
|
||||||
|
) {
|
||||||
ids.add(params.models.dictationLocalSttModel);
|
ids.add(params.models.dictationLocalSttModel);
|
||||||
}
|
}
|
||||||
if (params.providers.voiceStt.provider === "local") {
|
if (
|
||||||
|
params.providers.voiceStt.enabled !== false &&
|
||||||
|
params.providers.voiceStt.provider === "local"
|
||||||
|
) {
|
||||||
ids.add(params.models.voiceLocalSttModel);
|
ids.add(params.models.voiceLocalSttModel);
|
||||||
}
|
}
|
||||||
if (params.providers.voiceTts.provider === "local") {
|
if (
|
||||||
|
params.providers.voiceTts.enabled !== false &&
|
||||||
|
params.providers.voiceTts.provider === "local"
|
||||||
|
) {
|
||||||
ids.add(params.models.voiceLocalTtsModel);
|
ids.add(params.models.voiceLocalTtsModel);
|
||||||
}
|
}
|
||||||
return Array.from(ids);
|
return Array.from(ids);
|
||||||
@@ -258,7 +267,7 @@ export async function initializeLocalSpeechServices(params: {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (providers.voiceStt.provider === "local") {
|
if (providers.voiceStt.enabled !== false && providers.voiceStt.provider === "local") {
|
||||||
if (!localConfig) {
|
if (!localConfig) {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
{ configured: false },
|
{ configured: false },
|
||||||
@@ -274,7 +283,7 @@ export async function initializeLocalSpeechServices(params: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (providers.dictationStt.provider === "local") {
|
if (providers.dictationStt.enabled !== false && providers.dictationStt.provider === "local") {
|
||||||
if (!localConfig) {
|
if (!localConfig) {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
{ configured: false },
|
{ configured: false },
|
||||||
@@ -297,7 +306,7 @@ export async function initializeLocalSpeechServices(params: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (providers.voiceTts.provider === "local") {
|
if (providers.voiceTts.enabled !== false && providers.voiceTts.provider === "local") {
|
||||||
if (!localConfig) {
|
if (!localConfig) {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
{ configured: false },
|
{ configured: false },
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, expect, test } from "vitest";
|
import { describe, expect, test, vi } from "vitest";
|
||||||
import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs";
|
import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
@@ -53,4 +53,46 @@ describe("sherpa model downloader", () => {
|
|||||||
})
|
})
|
||||||
).rejects.toThrow(/auto-download/i);
|
).rejects.toThrow(/auto-download/i);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("ensureSherpaOnnxModel logs artifact download progress", async () => {
|
||||||
|
const modelsDir = makeTmpDir();
|
||||||
|
const progressLogs: Array<Record<string, unknown>> = [];
|
||||||
|
|
||||||
|
const loggerWithSpy = {
|
||||||
|
child: () => loggerWithSpy,
|
||||||
|
info: (obj?: unknown, msg?: string) => {
|
||||||
|
if (msg === "Downloading model artifact" && obj && typeof obj === "object") {
|
||||||
|
progressLogs.push(obj as Record<string, unknown>);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: () => undefined,
|
||||||
|
} as unknown as pino.Logger;
|
||||||
|
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
const payload = Buffer.alloc(128 * 1024, 7);
|
||||||
|
const fetchMock = vi.fn(async () => {
|
||||||
|
return new Response(payload, {
|
||||||
|
status: 200,
|
||||||
|
headers: { "content-length": String(payload.length) },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
globalThis.fetch = fetchMock as typeof fetch;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await ensureSherpaOnnxModel({
|
||||||
|
modelsDir,
|
||||||
|
modelId: "pocket-tts-onnx-int8",
|
||||||
|
autoDownload: true,
|
||||||
|
logger: loggerWithSpy,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalled();
|
||||||
|
expect(progressLogs.length).toBeGreaterThan(0);
|
||||||
|
const final = progressLogs.at(-1);
|
||||||
|
expect(final?.modelId).toBe("pocket-tts-onnx-int8");
|
||||||
|
expect(final?.pct).toBe(100);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { createWriteStream } from "node:fs";
|
import { createWriteStream } from "node:fs";
|
||||||
import { mkdir, rename, rm, stat } from "node:fs/promises";
|
import { mkdir, rename, rm, stat } from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { Readable } from "node:stream";
|
import { Readable, Transform } from "node:stream";
|
||||||
import { pipeline } from "node:stream/promises";
|
import { pipeline } from "node:stream/promises";
|
||||||
import { spawn } from "node:child_process";
|
import { spawn } from "node:child_process";
|
||||||
import type pino from "pino";
|
import type pino from "pino";
|
||||||
@@ -39,7 +39,31 @@ async function hasRequiredFiles(modelDir: string, requiredFiles: string[]): Prom
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function downloadToFile(url: string, outputPath: string, logger: pino.Logger): Promise<void> {
|
const UNKNOWN_SIZE_PROGRESS_BYTES_STEP = 5 * 1024 * 1024;
|
||||||
|
const UNKNOWN_SIZE_PROGRESS_MS_STEP = 1000;
|
||||||
|
|
||||||
|
type DownloadToFileOptions = {
|
||||||
|
url: string;
|
||||||
|
outputPath: string;
|
||||||
|
logger: pino.Logger;
|
||||||
|
modelId: SherpaOnnxModelId;
|
||||||
|
artifact: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function parseContentLength(res: Response): number | null {
|
||||||
|
const raw = res.headers.get("content-length");
|
||||||
|
if (!raw) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const parsed = Number.parseInt(raw, 10);
|
||||||
|
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadToFile(options: DownloadToFileOptions): Promise<void> {
|
||||||
|
const { url, outputPath, logger, modelId, artifact } = options;
|
||||||
const res = await fetch(url);
|
const res = await fetch(url);
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new Error(`Failed to download ${url}: ${res.status} ${res.statusText}`);
|
throw new Error(`Failed to download ${url}: ${res.status} ${res.statusText}`);
|
||||||
@@ -51,24 +75,68 @@ async function downloadToFile(url: string, outputPath: string, logger: pino.Logg
|
|||||||
const tmpPath = `${outputPath}.tmp-${Date.now()}`;
|
const tmpPath = `${outputPath}.tmp-${Date.now()}`;
|
||||||
await mkdir(path.dirname(outputPath), { recursive: true });
|
await mkdir(path.dirname(outputPath), { recursive: true });
|
||||||
|
|
||||||
const total = Number(res.headers.get("content-length") ?? "0");
|
const nodeStream = Readable.fromWeb(res.body as any);
|
||||||
let downloaded = 0;
|
const totalBytes = parseContentLength(res);
|
||||||
let lastLoggedBucket = -1;
|
|
||||||
|
|
||||||
const nodeStream = Readable.fromWeb(res.body as any).on("data", (chunk: Buffer) => {
|
let downloadedBytes = 0;
|
||||||
downloaded += chunk.length;
|
let lastLoggedPct = -1;
|
||||||
if (total > 0) {
|
let lastLoggedBytes = 0;
|
||||||
const pct = Math.floor((downloaded / total) * 100);
|
let lastLoggedAt = 0;
|
||||||
const bucket = Math.min(100, Math.floor(pct / 10) * 10);
|
|
||||||
if (bucket >= 0 && bucket <= 100 && bucket !== lastLoggedBucket) {
|
const logProgress = (force: boolean): void => {
|
||||||
lastLoggedBucket = bucket;
|
const pct =
|
||||||
logger.info({ pct: bucket, downloaded, total }, "Downloading model artifact");
|
totalBytes && totalBytes > 0
|
||||||
|
? Math.min(100, Math.floor((downloadedBytes * 100) / totalBytes))
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (!force) {
|
||||||
|
if (pct !== null) {
|
||||||
|
if (pct <= lastLoggedPct) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const now = Date.now();
|
||||||
|
const advancedBytes = downloadedBytes - lastLoggedBytes;
|
||||||
|
if (advancedBytes < UNKNOWN_SIZE_PROGRESS_BYTES_STEP && now - lastLoggedAt < UNKNOWN_SIZE_PROGRESS_MS_STEP) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
{
|
||||||
|
modelId,
|
||||||
|
artifact,
|
||||||
|
url,
|
||||||
|
downloadedBytes,
|
||||||
|
totalBytes,
|
||||||
|
pct,
|
||||||
|
},
|
||||||
|
"Downloading model artifact"
|
||||||
|
);
|
||||||
|
lastLoggedPct = pct ?? -1;
|
||||||
|
lastLoggedBytes = downloadedBytes;
|
||||||
|
lastLoggedAt = Date.now();
|
||||||
|
};
|
||||||
|
|
||||||
|
logProgress(false);
|
||||||
|
|
||||||
|
const progressStream = new Transform({
|
||||||
|
transform(chunk: Buffer, _encoding, callback) {
|
||||||
|
downloadedBytes += chunk.length;
|
||||||
|
logProgress(false);
|
||||||
|
callback(null, chunk);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await pipeline(nodeStream, createWriteStream(tmpPath));
|
try {
|
||||||
await rename(tmpPath, outputPath);
|
await pipeline(nodeStream, progressStream, createWriteStream(tmpPath));
|
||||||
|
logProgress(true);
|
||||||
|
await rename(tmpPath, outputPath);
|
||||||
|
} catch (error) {
|
||||||
|
await rm(tmpPath, { force: true }).catch(() => undefined);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function extractTarArchive(archivePath: string, destDir: string): Promise<void> {
|
async function extractTarArchive(archivePath: string, destDir: string): Promise<void> {
|
||||||
@@ -114,60 +182,103 @@ export async function ensureSherpaOnnxModel(options: EnsureSherpaOnnxModelOption
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (spec.archiveUrl) {
|
logger.info({ modelsDir: options.modelsDir }, "Starting model download");
|
||||||
logger.info({ modelsDir: options.modelsDir, url: spec.archiveUrl }, "Model files missing; downloading");
|
|
||||||
|
|
||||||
const downloadsDir = path.join(options.modelsDir, ".downloads");
|
try {
|
||||||
const archiveFilename = path.basename(new URL(spec.archiveUrl).pathname);
|
if (spec.archiveUrl) {
|
||||||
const archivePath = path.join(downloadsDir, archiveFilename);
|
const downloadsDir = path.join(options.modelsDir, ".downloads");
|
||||||
|
const archiveFilename = path.basename(new URL(spec.archiveUrl).pathname);
|
||||||
|
const archivePath = path.join(downloadsDir, archiveFilename);
|
||||||
|
|
||||||
if (!(await isNonEmptyFile(archivePath))) {
|
if (!(await isNonEmptyFile(archivePath))) {
|
||||||
await downloadToFile(spec.archiveUrl, archivePath, logger);
|
await downloadToFile({
|
||||||
} else {
|
url: spec.archiveUrl,
|
||||||
logger.info({ archivePath }, "Using cached archive");
|
outputPath: archivePath,
|
||||||
}
|
logger,
|
||||||
|
modelId: options.modelId,
|
||||||
await extractTarArchive(archivePath, options.modelsDir);
|
artifact: archiveFilename,
|
||||||
|
});
|
||||||
if (!(await hasRequiredFiles(modelDir, spec.requiredFiles))) {
|
|
||||||
throw new Error(
|
|
||||||
`Downloaded and extracted ${archiveFilename}, but required files are still missing in ${modelDir}.`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await rm(archivePath, { force: true });
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info({ modelDir }, "Model ready");
|
|
||||||
return modelDir;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (spec.downloadFiles && spec.downloadFiles.length > 0) {
|
|
||||||
logger.info({ modelsDir: options.modelsDir, fileCount: spec.downloadFiles.length }, "Model files missing; downloading");
|
|
||||||
await mkdir(modelDir, { recursive: true });
|
|
||||||
|
|
||||||
for (const file of spec.downloadFiles) {
|
|
||||||
const dst = path.join(modelDir, file.relPath);
|
|
||||||
if (await isNonEmptyFile(dst)) {
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
await downloadToFile(file.url, dst, logger);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!(await hasRequiredFiles(modelDir, spec.requiredFiles))) {
|
logger.info(
|
||||||
throw new Error(
|
{
|
||||||
`Downloaded files for ${options.modelId}, but required files are still missing in ${modelDir}.`
|
modelId: options.modelId,
|
||||||
|
archivePath,
|
||||||
|
modelDir,
|
||||||
|
},
|
||||||
|
"Extracting model archive"
|
||||||
);
|
);
|
||||||
|
await extractTarArchive(archivePath, options.modelsDir);
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
{
|
||||||
|
modelId: options.modelId,
|
||||||
|
modelDir,
|
||||||
|
},
|
||||||
|
"Verifying downloaded model files"
|
||||||
|
);
|
||||||
|
if (!(await hasRequiredFiles(modelDir, spec.requiredFiles))) {
|
||||||
|
throw new Error(
|
||||||
|
`Downloaded and extracted ${archiveFilename}, but required files are still missing in ${modelDir}.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
{
|
||||||
|
modelId: options.modelId,
|
||||||
|
archivePath,
|
||||||
|
},
|
||||||
|
"Finalizing model artifacts"
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
await rm(archivePath, { force: true });
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info({ modelDir }, "Model download completed");
|
||||||
|
return modelDir;
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info({ modelDir }, "Model ready");
|
if (spec.downloadFiles && spec.downloadFiles.length > 0) {
|
||||||
return modelDir;
|
await mkdir(modelDir, { recursive: true });
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error(`Model spec for ${options.modelId} has no archiveUrl or downloadFiles`);
|
for (const file of spec.downloadFiles) {
|
||||||
|
const dst = path.join(modelDir, file.relPath);
|
||||||
|
if (await isNonEmptyFile(dst)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
await downloadToFile({
|
||||||
|
url: file.url,
|
||||||
|
outputPath: dst,
|
||||||
|
logger,
|
||||||
|
modelId: options.modelId,
|
||||||
|
artifact: file.relPath,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
{
|
||||||
|
modelId: options.modelId,
|
||||||
|
modelDir,
|
||||||
|
},
|
||||||
|
"Verifying downloaded model files"
|
||||||
|
);
|
||||||
|
if (!(await hasRequiredFiles(modelDir, spec.requiredFiles))) {
|
||||||
|
throw new Error(
|
||||||
|
`Downloaded files for ${options.modelId}, but required files are still missing in ${modelDir}.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info({ modelDir }, "Model download completed");
|
||||||
|
return modelDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Model spec for ${options.modelId} has no archiveUrl or downloadFiles`);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error({ err: error }, "Model download failed");
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function ensureSherpaOnnxModels(options: {
|
export async function ensureSherpaOnnxModels(options: {
|
||||||
|
|||||||
@@ -74,27 +74,32 @@ export function resolveOpenAiSpeechConfig(params: {
|
|||||||
params.persisted.features?.dictation?.stt?.confidenceThreshold,
|
params.persisted.features?.dictation?.stt?.confidenceThreshold,
|
||||||
sttModel:
|
sttModel:
|
||||||
params.env.STT_MODEL ??
|
params.env.STT_MODEL ??
|
||||||
(params.providers.voiceStt.provider === "openai"
|
(params.providers.voiceStt.enabled !== false &&
|
||||||
|
params.providers.voiceStt.provider === "openai"
|
||||||
? params.persisted.features?.voiceMode?.stt?.model
|
? params.persisted.features?.voiceMode?.stt?.model
|
||||||
: undefined) ??
|
: undefined) ??
|
||||||
(params.providers.dictationStt.provider === "openai"
|
(params.providers.dictationStt.enabled !== false &&
|
||||||
|
params.providers.dictationStt.provider === "openai"
|
||||||
? params.persisted.features?.dictation?.stt?.model
|
? params.persisted.features?.dictation?.stt?.model
|
||||||
: undefined),
|
: undefined),
|
||||||
ttsVoice:
|
ttsVoice:
|
||||||
params.env.TTS_VOICE ??
|
params.env.TTS_VOICE ??
|
||||||
(params.providers.voiceTts.provider === "openai"
|
(params.providers.voiceTts.enabled !== false &&
|
||||||
|
params.providers.voiceTts.provider === "openai"
|
||||||
? params.persisted.features?.voiceMode?.tts?.voice
|
? params.persisted.features?.voiceMode?.tts?.voice
|
||||||
: undefined) ??
|
: undefined) ??
|
||||||
"alloy",
|
"alloy",
|
||||||
ttsModel:
|
ttsModel:
|
||||||
params.env.TTS_MODEL ??
|
params.env.TTS_MODEL ??
|
||||||
(params.providers.voiceTts.provider === "openai"
|
(params.providers.voiceTts.enabled !== false &&
|
||||||
|
params.providers.voiceTts.provider === "openai"
|
||||||
? params.persisted.features?.voiceMode?.tts?.model
|
? params.persisted.features?.voiceMode?.tts?.model
|
||||||
: undefined) ??
|
: undefined) ??
|
||||||
DEFAULT_OPENAI_TTS_MODEL,
|
DEFAULT_OPENAI_TTS_MODEL,
|
||||||
realtimeTranscriptionModel:
|
realtimeTranscriptionModel:
|
||||||
params.env.OPENAI_REALTIME_TRANSCRIPTION_MODEL ??
|
params.env.OPENAI_REALTIME_TRANSCRIPTION_MODEL ??
|
||||||
(params.providers.dictationStt.provider === "openai"
|
(params.providers.dictationStt.enabled !== false &&
|
||||||
|
params.providers.dictationStt.provider === "openai"
|
||||||
? params.persisted.features?.dictation?.stt?.model
|
? params.persisted.features?.dictation?.stt?.model
|
||||||
: undefined) ??
|
: undefined) ??
|
||||||
DEFAULT_OPENAI_REALTIME_TRANSCRIPTION_MODEL,
|
DEFAULT_OPENAI_REALTIME_TRANSCRIPTION_MODEL,
|
||||||
|
|||||||
@@ -60,13 +60,22 @@ export function validateOpenAiCredentialRequirements(params: {
|
|||||||
const openAiCredentials = resolveOpenAiCredentials(openaiConfig);
|
const openAiCredentials = resolveOpenAiCredentials(openaiConfig);
|
||||||
|
|
||||||
const missingOpenAiCredentialsFor: string[] = [];
|
const missingOpenAiCredentialsFor: string[] = [];
|
||||||
if (providers.voiceStt.provider === "openai" && !openAiCredentials.openaiSttApiKey) {
|
if (
|
||||||
|
providers.voiceStt.enabled !== false &&
|
||||||
|
providers.voiceStt.provider === "openai" &&
|
||||||
|
!openAiCredentials.openaiSttApiKey
|
||||||
|
) {
|
||||||
missingOpenAiCredentialsFor.push("voice.stt");
|
missingOpenAiCredentialsFor.push("voice.stt");
|
||||||
}
|
}
|
||||||
if (providers.voiceTts.provider === "openai" && !openAiCredentials.openaiTtsApiKey) {
|
if (
|
||||||
|
providers.voiceTts.enabled !== false &&
|
||||||
|
providers.voiceTts.provider === "openai" &&
|
||||||
|
!openAiCredentials.openaiTtsApiKey
|
||||||
|
) {
|
||||||
missingOpenAiCredentialsFor.push("voice.tts");
|
missingOpenAiCredentialsFor.push("voice.tts");
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
|
providers.dictationStt.enabled !== false &&
|
||||||
providers.dictationStt.provider === "openai" &&
|
providers.dictationStt.provider === "openai" &&
|
||||||
!openAiCredentials.openaiDictationApiKey
|
!openAiCredentials.openaiDictationApiKey
|
||||||
) {
|
) {
|
||||||
@@ -104,10 +113,18 @@ export function initializeOpenAiSpeechServices(params: {
|
|||||||
let ttsService = existing.ttsService;
|
let ttsService = existing.ttsService;
|
||||||
let dictationSttService = existing.dictationSttService;
|
let dictationSttService = existing.dictationSttService;
|
||||||
|
|
||||||
const needsOpenAiStt = !sttService && providers.voiceStt.provider === "openai";
|
const needsOpenAiStt =
|
||||||
const needsOpenAiTts = !ttsService && providers.voiceTts.provider === "openai";
|
!sttService &&
|
||||||
|
providers.voiceStt.enabled !== false &&
|
||||||
|
providers.voiceStt.provider === "openai";
|
||||||
|
const needsOpenAiTts =
|
||||||
|
!ttsService &&
|
||||||
|
providers.voiceTts.enabled !== false &&
|
||||||
|
providers.voiceTts.provider === "openai";
|
||||||
const needsOpenAiDictation =
|
const needsOpenAiDictation =
|
||||||
!dictationSttService && providers.dictationStt.provider === "openai";
|
!dictationSttService &&
|
||||||
|
providers.dictationStt.enabled !== false &&
|
||||||
|
providers.dictationStt.provider === "openai";
|
||||||
|
|
||||||
if (
|
if (
|
||||||
(needsOpenAiStt || needsOpenAiTts || needsOpenAiDictation) &&
|
(needsOpenAiStt || needsOpenAiTts || needsOpenAiDictation) &&
|
||||||
|
|||||||
@@ -21,14 +21,17 @@ describe("resolveSpeechConfig", () => {
|
|||||||
expect(result.speech.providers.dictationStt).toEqual({
|
expect(result.speech.providers.dictationStt).toEqual({
|
||||||
provider: "local",
|
provider: "local",
|
||||||
explicit: false,
|
explicit: false,
|
||||||
|
enabled: true,
|
||||||
});
|
});
|
||||||
expect(result.speech.providers.voiceStt).toEqual({
|
expect(result.speech.providers.voiceStt).toEqual({
|
||||||
provider: "local",
|
provider: "local",
|
||||||
explicit: false,
|
explicit: false,
|
||||||
|
enabled: true,
|
||||||
});
|
});
|
||||||
expect(result.speech.providers.voiceTts).toEqual({
|
expect(result.speech.providers.voiceTts).toEqual({
|
||||||
provider: "local",
|
provider: "local",
|
||||||
explicit: false,
|
explicit: false,
|
||||||
|
enabled: true,
|
||||||
});
|
});
|
||||||
expect(result.speech.local).toEqual({
|
expect(result.speech.local).toEqual({
|
||||||
modelsDir: path.join(paseoHome, "models", "local-speech"),
|
modelsDir: path.join(paseoHome, "models", "local-speech"),
|
||||||
@@ -91,14 +94,17 @@ describe("resolveSpeechConfig", () => {
|
|||||||
expect(result.speech.providers.dictationStt).toEqual({
|
expect(result.speech.providers.dictationStt).toEqual({
|
||||||
provider: "local",
|
provider: "local",
|
||||||
explicit: true,
|
explicit: true,
|
||||||
|
enabled: true,
|
||||||
});
|
});
|
||||||
expect(result.speech.providers.voiceStt).toEqual({
|
expect(result.speech.providers.voiceStt).toEqual({
|
||||||
provider: "openai",
|
provider: "openai",
|
||||||
explicit: true,
|
explicit: true,
|
||||||
|
enabled: true,
|
||||||
});
|
});
|
||||||
expect(result.speech.providers.voiceTts).toEqual({
|
expect(result.speech.providers.voiceTts).toEqual({
|
||||||
provider: "local",
|
provider: "local",
|
||||||
explicit: true,
|
explicit: true,
|
||||||
|
enabled: true,
|
||||||
});
|
});
|
||||||
expect(result.speech.local?.models.dictationStt).toBe("zipformer-bilingual-zh-en-2023-02-20");
|
expect(result.speech.local?.models.dictationStt).toBe("zipformer-bilingual-zh-en-2023-02-20");
|
||||||
expect(result.speech.local?.models.voiceStt).toBe("parakeet-tdt-0.6b-v3-int8");
|
expect(result.speech.local?.models.voiceStt).toBe("parakeet-tdt-0.6b-v3-int8");
|
||||||
@@ -127,4 +133,35 @@ describe("resolveSpeechConfig", () => {
|
|||||||
expect(result.speech.local?.models.voiceTts).toBe("kokoro-en-v0_19");
|
expect(result.speech.local?.models.voiceTts).toBe("kokoro-en-v0_19");
|
||||||
expect(result.speech.local?.models.voiceTtsSpeakerId).toBe(0);
|
expect(result.speech.local?.models.voiceTtsSpeakerId).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("respects disabled dictation and voice mode feature flags", () => {
|
||||||
|
const persisted = PersistedConfigSchema.parse({
|
||||||
|
features: {
|
||||||
|
dictation: { enabled: false },
|
||||||
|
voiceMode: { enabled: false },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = resolveSpeechConfig({
|
||||||
|
paseoHome: "/tmp/paseo-home",
|
||||||
|
env: {} as NodeJS.ProcessEnv,
|
||||||
|
persisted,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.speech.providers.dictationStt).toEqual({
|
||||||
|
provider: "local",
|
||||||
|
explicit: false,
|
||||||
|
enabled: false,
|
||||||
|
});
|
||||||
|
expect(result.speech.providers.voiceStt).toEqual({
|
||||||
|
provider: "local",
|
||||||
|
explicit: false,
|
||||||
|
enabled: false,
|
||||||
|
});
|
||||||
|
expect(result.speech.providers.voiceTts).toEqual({
|
||||||
|
provider: "local",
|
||||||
|
explicit: false,
|
||||||
|
enabled: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -17,6 +17,25 @@ const OptionalSpeechProviderSchema = z
|
|||||||
.pipe(SpeechProviderIdSchema)
|
.pipe(SpeechProviderIdSchema)
|
||||||
.optional();
|
.optional();
|
||||||
|
|
||||||
|
const OptionalBooleanFlagSchema = z
|
||||||
|
.union([z.boolean(), z.string().trim().toLowerCase()])
|
||||||
|
.optional()
|
||||||
|
.transform((value) => {
|
||||||
|
if (typeof value === "boolean") {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (value === undefined) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (["1", "true", "yes", "y", "on"].includes(value)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (["0", "false", "no", "n", "off"].includes(value)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
});
|
||||||
|
|
||||||
const RequestedSpeechProvidersSchema = z.object({
|
const RequestedSpeechProvidersSchema = z.object({
|
||||||
dictationStt: OptionalSpeechProviderSchema.default("local"),
|
dictationStt: OptionalSpeechProviderSchema.default("local"),
|
||||||
voiceStt: OptionalSpeechProviderSchema.default("local"),
|
voiceStt: OptionalSpeechProviderSchema.default("local"),
|
||||||
@@ -29,10 +48,12 @@ function resolveRequestedSpeechProviders(params: {
|
|||||||
}): RequestedSpeechProviders {
|
}): RequestedSpeechProviders {
|
||||||
const resolveFeatureProvider = (
|
const resolveFeatureProvider = (
|
||||||
configuredValue: string | undefined,
|
configuredValue: string | undefined,
|
||||||
parsedValue: z.infer<typeof SpeechProviderIdSchema>
|
parsedValue: z.infer<typeof SpeechProviderIdSchema>,
|
||||||
|
enabled: boolean
|
||||||
): RequestedSpeechProvider => ({
|
): RequestedSpeechProvider => ({
|
||||||
provider: parsedValue,
|
provider: parsedValue,
|
||||||
explicit: configuredValue !== undefined,
|
explicit: configuredValue !== undefined,
|
||||||
|
enabled,
|
||||||
});
|
});
|
||||||
|
|
||||||
const dictationSttProviderFromConfig =
|
const dictationSttProviderFromConfig =
|
||||||
@@ -44,6 +65,14 @@ function resolveRequestedSpeechProviders(params: {
|
|||||||
const voiceTtsProviderFromConfig =
|
const voiceTtsProviderFromConfig =
|
||||||
params.env.PASEO_VOICE_TTS_PROVIDER ??
|
params.env.PASEO_VOICE_TTS_PROVIDER ??
|
||||||
params.persisted.features?.voiceMode?.tts?.provider;
|
params.persisted.features?.voiceMode?.tts?.provider;
|
||||||
|
const dictationEnabled =
|
||||||
|
OptionalBooleanFlagSchema.parse(
|
||||||
|
params.env.PASEO_DICTATION_ENABLED ?? params.persisted.features?.dictation?.enabled
|
||||||
|
) ?? true;
|
||||||
|
const voiceModeEnabled =
|
||||||
|
OptionalBooleanFlagSchema.parse(
|
||||||
|
params.env.PASEO_VOICE_MODE_ENABLED ?? params.persisted.features?.voiceMode?.enabled
|
||||||
|
) ?? true;
|
||||||
|
|
||||||
const parsed = RequestedSpeechProvidersSchema.parse({
|
const parsed = RequestedSpeechProvidersSchema.parse({
|
||||||
dictationStt: dictationSttProviderFromConfig ?? "local",
|
dictationStt: dictationSttProviderFromConfig ?? "local",
|
||||||
@@ -54,15 +83,18 @@ function resolveRequestedSpeechProviders(params: {
|
|||||||
return {
|
return {
|
||||||
dictationStt: resolveFeatureProvider(
|
dictationStt: resolveFeatureProvider(
|
||||||
dictationSttProviderFromConfig,
|
dictationSttProviderFromConfig,
|
||||||
parsed.dictationStt
|
parsed.dictationStt,
|
||||||
|
dictationEnabled
|
||||||
),
|
),
|
||||||
voiceStt: resolveFeatureProvider(
|
voiceStt: resolveFeatureProvider(
|
||||||
voiceSttProviderFromConfig,
|
voiceSttProviderFromConfig,
|
||||||
parsed.voiceStt
|
parsed.voiceStt,
|
||||||
|
voiceModeEnabled
|
||||||
),
|
),
|
||||||
voiceTts: resolveFeatureProvider(
|
voiceTts: resolveFeatureProvider(
|
||||||
voiceTtsProviderFromConfig,
|
voiceTtsProviderFromConfig,
|
||||||
parsed.voiceTts
|
parsed.voiceTts,
|
||||||
|
voiceModeEnabled
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,9 +23,9 @@ function resolveRequestedSpeechProviders(
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
dictationStt: { provider: "local", explicit: false },
|
dictationStt: { provider: "local", explicit: false, enabled: true },
|
||||||
voiceStt: { provider: "local", explicit: false },
|
voiceStt: { provider: "local", explicit: false, enabled: true },
|
||||||
voiceTts: { provider: "local", explicit: false },
|
voiceTts: { provider: "local", explicit: false, enabled: true },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,9 +59,18 @@ export async function initializeSpeechRuntime(params: {
|
|||||||
logger.info(
|
logger.info(
|
||||||
{
|
{
|
||||||
requestedProviders: {
|
requestedProviders: {
|
||||||
dictationStt: providers.dictationStt.provider,
|
dictationStt: {
|
||||||
voiceStt: providers.voiceStt.provider,
|
provider: providers.dictationStt.provider,
|
||||||
voiceTts: providers.voiceTts.provider,
|
enabled: providers.dictationStt.enabled !== false,
|
||||||
|
},
|
||||||
|
voiceStt: {
|
||||||
|
provider: providers.voiceStt.provider,
|
||||||
|
enabled: providers.voiceStt.enabled !== false,
|
||||||
|
},
|
||||||
|
voiceTts: {
|
||||||
|
provider: providers.voiceTts.provider,
|
||||||
|
enabled: providers.voiceTts.enabled !== false,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
availability: {
|
availability: {
|
||||||
openai: getOpenAiSpeechAvailability(openaiConfig),
|
openai: getOpenAiSpeechAvailability(openaiConfig),
|
||||||
@@ -99,9 +108,11 @@ export async function initializeSpeechRuntime(params: {
|
|||||||
: "openai",
|
: "openai",
|
||||||
};
|
};
|
||||||
const unavailableFeatures = [
|
const unavailableFeatures = [
|
||||||
!openAiSpeech.dictationSttService ? "dictation.stt" : null,
|
providers.dictationStt.enabled !== false && !openAiSpeech.dictationSttService
|
||||||
!openAiSpeech.sttService ? "voice.stt" : null,
|
? "dictation.stt"
|
||||||
!openAiSpeech.ttsService ? "voice.tts" : null,
|
: null,
|
||||||
|
providers.voiceStt.enabled !== false && !openAiSpeech.sttService ? "voice.stt" : null,
|
||||||
|
providers.voiceTts.enabled !== false && !openAiSpeech.ttsService ? "voice.tts" : null,
|
||||||
].filter((feature): feature is string => feature !== null);
|
].filter((feature): feature is string => feature !== null);
|
||||||
const explicitlyConfiguredUnavailableFeatures = unavailableFeatures.filter((feature) => {
|
const explicitlyConfiguredUnavailableFeatures = unavailableFeatures.filter((feature) => {
|
||||||
if (feature === "dictation.stt") {
|
if (feature === "dictation.stt") {
|
||||||
@@ -117,9 +128,18 @@ export async function initializeSpeechRuntime(params: {
|
|||||||
logger.error(
|
logger.error(
|
||||||
{
|
{
|
||||||
requestedProviders: {
|
requestedProviders: {
|
||||||
dictationStt: providers.dictationStt.provider,
|
dictationStt: {
|
||||||
voiceStt: providers.voiceStt.provider,
|
provider: providers.dictationStt.provider,
|
||||||
voiceTts: providers.voiceTts.provider,
|
enabled: providers.dictationStt.enabled !== false,
|
||||||
|
},
|
||||||
|
voiceStt: {
|
||||||
|
provider: providers.voiceStt.provider,
|
||||||
|
enabled: providers.voiceStt.enabled !== false,
|
||||||
|
},
|
||||||
|
voiceTts: {
|
||||||
|
provider: providers.voiceTts.provider,
|
||||||
|
enabled: providers.voiceTts.enabled !== false,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
explicitProviders: {
|
explicitProviders: {
|
||||||
dictationStt: providers.dictationStt.explicit,
|
dictationStt: providers.dictationStt.explicit,
|
||||||
@@ -140,9 +160,18 @@ export async function initializeSpeechRuntime(params: {
|
|||||||
logger.warn(
|
logger.warn(
|
||||||
{
|
{
|
||||||
requestedProviders: {
|
requestedProviders: {
|
||||||
dictationStt: providers.dictationStt.provider,
|
dictationStt: {
|
||||||
voiceStt: providers.voiceStt.provider,
|
provider: providers.dictationStt.provider,
|
||||||
voiceTts: providers.voiceTts.provider,
|
enabled: providers.dictationStt.enabled !== false,
|
||||||
|
},
|
||||||
|
voiceStt: {
|
||||||
|
provider: providers.voiceStt.provider,
|
||||||
|
enabled: providers.voiceStt.enabled !== false,
|
||||||
|
},
|
||||||
|
voiceTts: {
|
||||||
|
provider: providers.voiceTts.provider,
|
||||||
|
enabled: providers.voiceTts.enabled !== false,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
explicitProviders: {
|
explicitProviders: {
|
||||||
dictationStt: providers.dictationStt.explicit,
|
dictationStt: providers.dictationStt.explicit,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export type SpeechProviderId = z.infer<typeof SpeechProviderIdSchema>;
|
|||||||
export const RequestedSpeechProviderSchema = z.object({
|
export const RequestedSpeechProviderSchema = z.object({
|
||||||
provider: SpeechProviderIdSchema,
|
provider: SpeechProviderIdSchema,
|
||||||
explicit: z.boolean(),
|
explicit: z.boolean(),
|
||||||
|
enabled: z.boolean().optional(),
|
||||||
});
|
});
|
||||||
export type RequestedSpeechProvider = z.infer<typeof RequestedSpeechProviderSchema>;
|
export type RequestedSpeechProvider = z.infer<typeof RequestedSpeechProviderSchema>;
|
||||||
|
|
||||||
|
|||||||
@@ -279,7 +279,7 @@ export class VoiceAssistantWebSocketServer {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
connectionLogger.info(
|
connectionLogger.trace(
|
||||||
{ clientId, totalSessions: this.sessions.size },
|
{ clientId, totalSessions: this.sessions.size },
|
||||||
"Client connected"
|
"Client connected"
|
||||||
);
|
);
|
||||||
@@ -319,7 +319,7 @@ export class VoiceAssistantWebSocketServer {
|
|||||||
const session = this.sessions.get(ws);
|
const session = this.sessions.get(ws);
|
||||||
if (!session) return;
|
if (!session) return;
|
||||||
|
|
||||||
connectionLogger.info(
|
connectionLogger.trace(
|
||||||
{ clientId, totalSessions: this.sessions.size - 1 },
|
{ clientId, totalSessions: this.sessions.size - 1 },
|
||||||
"Client disconnected"
|
"Client disconnected"
|
||||||
);
|
);
|
||||||
@@ -386,28 +386,12 @@ export class VoiceAssistantWebSocketServer {
|
|||||||
|
|
||||||
const message = parsedMessage.data;
|
const message = parsedMessage.data;
|
||||||
|
|
||||||
const messageSummary = {
|
|
||||||
type: message.type,
|
|
||||||
...(message.type === "session" && message.message
|
|
||||||
? { sessionMessageType: message.message.type }
|
|
||||||
: {}),
|
|
||||||
};
|
|
||||||
const isSessionNoise =
|
|
||||||
message.type === "session" &&
|
|
||||||
(message.message.type === "client_heartbeat" ||
|
|
||||||
message.message.type === "voice_audio_chunk" ||
|
|
||||||
message.message.type === "dictation_stream_chunk");
|
|
||||||
if (!isSessionNoise) {
|
|
||||||
this.logger.debug(messageSummary, "Received message");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (message.type === "ping") {
|
if (message.type === "ping") {
|
||||||
this.sendToClient(ws, { type: "pong" });
|
this.sendToClient(ws, { type: "pong" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (message.type === "recording_state") {
|
if (message.type === "recording_state") {
|
||||||
this.logger.debug({ isRecording: message.isRecording }, "Recording state");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -418,17 +402,6 @@ export class VoiceAssistantWebSocketServer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (message.type === "session") {
|
if (message.type === "session") {
|
||||||
if (message.message.type === "create_agent_request") {
|
|
||||||
this.logger.debug(
|
|
||||||
{
|
|
||||||
cwd: message.message.config.cwd,
|
|
||||||
initialMode: message.message.config.modeId,
|
|
||||||
worktreeName: message.message.worktreeName,
|
|
||||||
requestId: message.message.requestId,
|
|
||||||
},
|
|
||||||
"create_agent_request details"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
await session.handleMessage(message.message);
|
await session.handleMessage(message.message);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -502,23 +475,11 @@ export class VoiceAssistantWebSocketServer {
|
|||||||
} {
|
} {
|
||||||
const activity = session.getClientActivity();
|
const activity = session.getClientActivity();
|
||||||
if (!activity) {
|
if (!activity) {
|
||||||
this.logger.debug("getClientActivityState: no activity for session");
|
|
||||||
return { deviceType: null, focusedAgentId: null, isStale: true, appVisible: false };
|
return { deviceType: null, focusedAgentId: null, isStale: true, appVisible: false };
|
||||||
}
|
}
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const ageMs = now - activity.lastActivityAt.getTime();
|
const ageMs = now - activity.lastActivityAt.getTime();
|
||||||
const isStale = ageMs >= this.ACTIVITY_THRESHOLD_MS;
|
const isStale = ageMs >= this.ACTIVITY_THRESHOLD_MS;
|
||||||
this.logger.debug(
|
|
||||||
{
|
|
||||||
deviceType: activity.deviceType,
|
|
||||||
focusedAgentId: activity.focusedAgentId,
|
|
||||||
lastActivityAt: activity.lastActivityAt.toISOString(),
|
|
||||||
ageMs,
|
|
||||||
isStale,
|
|
||||||
appVisible: activity.appVisible,
|
|
||||||
},
|
|
||||||
"getClientActivityState"
|
|
||||||
);
|
|
||||||
return {
|
return {
|
||||||
deviceType: activity.deviceType,
|
deviceType: activity.deviceType,
|
||||||
focusedAgentId: activity.focusedAgentId,
|
focusedAgentId: activity.focusedAgentId,
|
||||||
@@ -603,16 +564,6 @@ export class VoiceAssistantWebSocketServer {
|
|||||||
|
|
||||||
const allStates = clientEntries.map((e) => e.state);
|
const allStates = clientEntries.map((e) => e.state);
|
||||||
|
|
||||||
this.logger.debug(
|
|
||||||
{
|
|
||||||
agentId: params.agentId,
|
|
||||||
reason: params.reason,
|
|
||||||
clientCount: clientEntries.length,
|
|
||||||
allStates,
|
|
||||||
},
|
|
||||||
"broadcastAgentAttention"
|
|
||||||
);
|
|
||||||
|
|
||||||
const hasActiveWebClient = allStates.some(
|
const hasActiveWebClient = allStates.some(
|
||||||
(state) => state.deviceType === "web" && !state.isStale
|
(state) => state.deviceType === "web" && !state.isStale
|
||||||
);
|
);
|
||||||
@@ -627,11 +578,6 @@ export class VoiceAssistantWebSocketServer {
|
|||||||
!hasActiveWebClient &&
|
!hasActiveWebClient &&
|
||||||
!hasActiveMobileForegroundClient;
|
!hasActiveMobileForegroundClient;
|
||||||
|
|
||||||
this.logger.debug(
|
|
||||||
{ hasActiveWebClient, hasActiveMobileForegroundClient, shouldSendPush },
|
|
||||||
"Push gating check"
|
|
||||||
);
|
|
||||||
|
|
||||||
if (shouldSendPush) {
|
if (shouldSendPush) {
|
||||||
const tokens = this.pushTokenStore.getAllTokens();
|
const tokens = this.pushTokenStore.getAllTokens();
|
||||||
this.logger.info({ tokenCount: tokens.length }, "Sending push notification");
|
this.logger.info({ tokenCount: tokens.length }, "Sending push notification");
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { describe, expect, it } from "vitest";
|
|||||||
import {
|
import {
|
||||||
AgentStreamMessageSchema,
|
AgentStreamMessageSchema,
|
||||||
AgentStreamSnapshotMessageSchema,
|
AgentStreamSnapshotMessageSchema,
|
||||||
|
SessionInboundMessageSchema,
|
||||||
|
SessionOutboundMessageSchema,
|
||||||
WSOutboundMessageSchema,
|
WSOutboundMessageSchema,
|
||||||
} from "./messages.js";
|
} from "./messages.js";
|
||||||
|
|
||||||
@@ -64,4 +66,44 @@ describe("shared messages stream parsing", () => {
|
|||||||
});
|
});
|
||||||
expect(wrapped.success).toBe(false);
|
expect(wrapped.success).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("rejects removed legacy git diff request messages", () => {
|
||||||
|
const gitDiffParsed = SessionInboundMessageSchema.safeParse({
|
||||||
|
type: "git_diff_request",
|
||||||
|
agentId: "agent-1",
|
||||||
|
requestId: "req-1",
|
||||||
|
});
|
||||||
|
expect(gitDiffParsed.success).toBe(false);
|
||||||
|
|
||||||
|
const highlightedParsed = SessionInboundMessageSchema.safeParse({
|
||||||
|
type: "highlighted_diff_request",
|
||||||
|
agentId: "agent-1",
|
||||||
|
requestId: "req-2",
|
||||||
|
});
|
||||||
|
expect(highlightedParsed.success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects removed legacy git diff response messages", () => {
|
||||||
|
const gitDiffParsed = SessionOutboundMessageSchema.safeParse({
|
||||||
|
type: "git_diff_response",
|
||||||
|
payload: {
|
||||||
|
agentId: "agent-1",
|
||||||
|
diff: "",
|
||||||
|
error: null,
|
||||||
|
requestId: "req-1",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(gitDiffParsed.success).toBe(false);
|
||||||
|
|
||||||
|
const highlightedParsed = SessionOutboundMessageSchema.safeParse({
|
||||||
|
type: "highlighted_diff_response",
|
||||||
|
payload: {
|
||||||
|
agentId: "agent-1",
|
||||||
|
files: [],
|
||||||
|
error: null,
|
||||||
|
requestId: "req-2",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(highlightedParsed.success).toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -696,12 +696,6 @@ export const AgentPermissionResponseMessageSchema = z.object({
|
|||||||
response: AgentPermissionResponseSchema,
|
response: AgentPermissionResponseSchema,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const GitDiffRequestSchema = z.object({
|
|
||||||
type: z.literal("git_diff_request"),
|
|
||||||
agentId: z.string(),
|
|
||||||
requestId: z.string(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const CheckoutErrorCodeSchema = z.enum([
|
const CheckoutErrorCodeSchema = z.enum([
|
||||||
"NOT_GIT_REPO",
|
"NOT_GIT_REPO",
|
||||||
"NOT_ALLOWED",
|
"NOT_ALLOWED",
|
||||||
@@ -837,12 +831,6 @@ const ParsedDiffFileSchema = z.object({
|
|||||||
status: z.enum(["ok", "too_large", "binary"]).optional(),
|
status: z.enum(["ok", "too_large", "binary"]).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const HighlightedDiffRequestSchema = z.object({
|
|
||||||
type: z.literal("highlighted_diff_request"),
|
|
||||||
agentId: z.string(),
|
|
||||||
requestId: z.string(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const FileExplorerEntrySchema = z.object({
|
const FileExplorerEntrySchema = z.object({
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
path: z.string(),
|
path: z.string(),
|
||||||
@@ -1010,7 +998,6 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
|
|||||||
SetAgentModelRequestMessageSchema,
|
SetAgentModelRequestMessageSchema,
|
||||||
SetAgentThinkingRequestMessageSchema,
|
SetAgentThinkingRequestMessageSchema,
|
||||||
AgentPermissionResponseMessageSchema,
|
AgentPermissionResponseMessageSchema,
|
||||||
GitDiffRequestSchema,
|
|
||||||
CheckoutStatusRequestSchema,
|
CheckoutStatusRequestSchema,
|
||||||
SubscribeCheckoutDiffRequestSchema,
|
SubscribeCheckoutDiffRequestSchema,
|
||||||
UnsubscribeCheckoutDiffRequestSchema,
|
UnsubscribeCheckoutDiffRequestSchema,
|
||||||
@@ -1023,7 +1010,6 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
|
|||||||
ValidateBranchRequestSchema,
|
ValidateBranchRequestSchema,
|
||||||
PaseoWorktreeListRequestSchema,
|
PaseoWorktreeListRequestSchema,
|
||||||
PaseoWorktreeArchiveRequestSchema,
|
PaseoWorktreeArchiveRequestSchema,
|
||||||
HighlightedDiffRequestSchema,
|
|
||||||
FileExplorerRequestSchema,
|
FileExplorerRequestSchema,
|
||||||
ProjectIconRequestSchema,
|
ProjectIconRequestSchema,
|
||||||
FileDownloadTokenRequestSchema,
|
FileDownloadTokenRequestSchema,
|
||||||
@@ -1411,16 +1397,6 @@ export const AgentArchivedMessageSchema = z.object({
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const GitDiffResponseSchema = z.object({
|
|
||||||
type: z.literal("git_diff_response"),
|
|
||||||
payload: z.object({
|
|
||||||
agentId: z.string(),
|
|
||||||
diff: z.string(),
|
|
||||||
error: z.string().nullable(),
|
|
||||||
requestId: z.string(),
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
const AheadBehindSchema = z.object({
|
const AheadBehindSchema = z.object({
|
||||||
ahead: z.number(),
|
ahead: z.number(),
|
||||||
behind: z.number(),
|
behind: z.number(),
|
||||||
@@ -1564,6 +1540,7 @@ export const CheckoutPrStatusResponseSchema = z.object({
|
|||||||
payload: z.object({
|
payload: z.object({
|
||||||
cwd: z.string(),
|
cwd: z.string(),
|
||||||
status: CheckoutPrStatusSchema.nullable(),
|
status: CheckoutPrStatusSchema.nullable(),
|
||||||
|
githubFeaturesEnabled: z.boolean(),
|
||||||
error: CheckoutErrorSchema.nullable(),
|
error: CheckoutErrorSchema.nullable(),
|
||||||
requestId: z.string(),
|
requestId: z.string(),
|
||||||
}),
|
}),
|
||||||
@@ -1605,16 +1582,6 @@ export const PaseoWorktreeArchiveResponseSchema = z.object({
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const HighlightedDiffResponseSchema = z.object({
|
|
||||||
type: z.literal("highlighted_diff_response"),
|
|
||||||
payload: z.object({
|
|
||||||
agentId: z.string(),
|
|
||||||
files: z.array(ParsedDiffFileSchema),
|
|
||||||
error: z.string().nullable(),
|
|
||||||
requestId: z.string(),
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const FileExplorerResponseSchema = z.object({
|
export const FileExplorerResponseSchema = z.object({
|
||||||
type: z.literal("file_explorer_response"),
|
type: z.literal("file_explorer_response"),
|
||||||
payload: z.object({
|
payload: z.object({
|
||||||
@@ -1834,7 +1801,6 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
|||||||
AgentPermissionResolvedMessageSchema,
|
AgentPermissionResolvedMessageSchema,
|
||||||
AgentDeletedMessageSchema,
|
AgentDeletedMessageSchema,
|
||||||
AgentArchivedMessageSchema,
|
AgentArchivedMessageSchema,
|
||||||
GitDiffResponseSchema,
|
|
||||||
CheckoutStatusResponseSchema,
|
CheckoutStatusResponseSchema,
|
||||||
SubscribeCheckoutDiffResponseSchema,
|
SubscribeCheckoutDiffResponseSchema,
|
||||||
CheckoutDiffUpdateSchema,
|
CheckoutDiffUpdateSchema,
|
||||||
@@ -1847,7 +1813,6 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
|||||||
ValidateBranchResponseSchema,
|
ValidateBranchResponseSchema,
|
||||||
PaseoWorktreeListResponseSchema,
|
PaseoWorktreeListResponseSchema,
|
||||||
PaseoWorktreeArchiveResponseSchema,
|
PaseoWorktreeArchiveResponseSchema,
|
||||||
HighlightedDiffResponseSchema,
|
|
||||||
FileExplorerResponseSchema,
|
FileExplorerResponseSchema,
|
||||||
ProjectIconResponseSchema,
|
ProjectIconResponseSchema,
|
||||||
FileDownloadTokenResponseSchema,
|
FileDownloadTokenResponseSchema,
|
||||||
@@ -1944,8 +1909,6 @@ export type SetAgentModeRequestMessage = z.infer<typeof SetAgentModeRequestMessa
|
|||||||
export type SetAgentModelRequestMessage = z.infer<typeof SetAgentModelRequestMessageSchema>;
|
export type SetAgentModelRequestMessage = z.infer<typeof SetAgentModelRequestMessageSchema>;
|
||||||
export type SetAgentThinkingRequestMessage = z.infer<typeof SetAgentThinkingRequestMessageSchema>;
|
export type SetAgentThinkingRequestMessage = z.infer<typeof SetAgentThinkingRequestMessageSchema>;
|
||||||
export type AgentPermissionResponseMessage = z.infer<typeof AgentPermissionResponseMessageSchema>;
|
export type AgentPermissionResponseMessage = z.infer<typeof AgentPermissionResponseMessageSchema>;
|
||||||
export type GitDiffRequest = z.infer<typeof GitDiffRequestSchema>;
|
|
||||||
export type GitDiffResponse = z.infer<typeof GitDiffResponseSchema>;
|
|
||||||
export type CheckoutStatusRequest = z.infer<typeof CheckoutStatusRequestSchema>;
|
export type CheckoutStatusRequest = z.infer<typeof CheckoutStatusRequestSchema>;
|
||||||
export type CheckoutStatusResponse = z.infer<typeof CheckoutStatusResponseSchema>;
|
export type CheckoutStatusResponse = z.infer<typeof CheckoutStatusResponseSchema>;
|
||||||
export type SubscribeCheckoutDiffRequest = z.infer<
|
export type SubscribeCheckoutDiffRequest = z.infer<
|
||||||
@@ -1976,8 +1939,6 @@ export type PaseoWorktreeListRequest = z.infer<typeof PaseoWorktreeListRequestSc
|
|||||||
export type PaseoWorktreeListResponse = z.infer<typeof PaseoWorktreeListResponseSchema>;
|
export type PaseoWorktreeListResponse = z.infer<typeof PaseoWorktreeListResponseSchema>;
|
||||||
export type PaseoWorktreeArchiveRequest = z.infer<typeof PaseoWorktreeArchiveRequestSchema>;
|
export type PaseoWorktreeArchiveRequest = z.infer<typeof PaseoWorktreeArchiveRequestSchema>;
|
||||||
export type PaseoWorktreeArchiveResponse = z.infer<typeof PaseoWorktreeArchiveResponseSchema>;
|
export type PaseoWorktreeArchiveResponse = z.infer<typeof PaseoWorktreeArchiveResponseSchema>;
|
||||||
export type HighlightedDiffRequest = z.infer<typeof HighlightedDiffRequestSchema>;
|
|
||||||
export type HighlightedDiffResponse = z.infer<typeof HighlightedDiffResponseSchema>;
|
|
||||||
export type FileExplorerRequest = z.infer<typeof FileExplorerRequestSchema>;
|
export type FileExplorerRequest = z.infer<typeof FileExplorerRequestSchema>;
|
||||||
export type FileExplorerResponse = z.infer<typeof FileExplorerResponseSchema>;
|
export type FileExplorerResponse = z.infer<typeof FileExplorerResponseSchema>;
|
||||||
export type ProjectIconRequest = z.infer<typeof ProjectIconRequestSchema>;
|
export type ProjectIconRequest = z.infer<typeof ProjectIconRequestSchema>;
|
||||||
|
|||||||
82
packages/server/src/utils/checkout-git-batching.test.ts
Normal file
82
packages/server/src/utils/checkout-git-batching.test.ts
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import { execSync } from "node:child_process";
|
||||||
|
import { mkdtempSync, rmSync, writeFileSync, realpathSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { beforeEach, afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const spawnCounters = vi.hoisted(() => ({
|
||||||
|
trackedTextDiffCalls: 0,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("child_process", async () => {
|
||||||
|
const actual = await vi.importActual<typeof import("child_process")>("child_process");
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
spawn: (...args: Parameters<typeof actual.spawn>) => {
|
||||||
|
const [command, commandArgs] = args;
|
||||||
|
if (command === "git" && Array.isArray(commandArgs)) {
|
||||||
|
const normalizedArgs = commandArgs.map((arg) => String(arg));
|
||||||
|
const isTrackedTextDiff =
|
||||||
|
normalizedArgs[0] === "diff" &&
|
||||||
|
normalizedArgs.includes("HEAD") &&
|
||||||
|
!normalizedArgs.includes("--numstat") &&
|
||||||
|
!normalizedArgs.includes("--no-index");
|
||||||
|
if (isTrackedTextDiff) {
|
||||||
|
spawnCounters.trackedTextDiffCalls += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return actual.spawn(...args);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
import { getCheckoutDiff } from "./checkout-git.js";
|
||||||
|
|
||||||
|
function initRepoWithTrackedChanges(fileCount: number): { tempDir: string; repoDir: string } {
|
||||||
|
const tempDir = realpathSync(mkdtempSync(join(tmpdir(), "checkout-git-batch-test-")));
|
||||||
|
const repoDir = join(tempDir, "repo");
|
||||||
|
|
||||||
|
execSync(`mkdir -p ${repoDir}`);
|
||||||
|
execSync("git init -b main", { cwd: repoDir });
|
||||||
|
execSync("git config user.email 'test@test.com'", { cwd: repoDir });
|
||||||
|
execSync("git config user.name 'Test'", { cwd: repoDir });
|
||||||
|
|
||||||
|
for (let i = 0; i < fileCount; i += 1) {
|
||||||
|
writeFileSync(join(repoDir, `file-${i}.txt`), `before-${i}\n`);
|
||||||
|
}
|
||||||
|
execSync("git add .", { cwd: repoDir });
|
||||||
|
execSync("git -c commit.gpgsign=false commit -m 'initial'", { cwd: repoDir });
|
||||||
|
|
||||||
|
for (let i = 0; i < fileCount; i += 1) {
|
||||||
|
writeFileSync(join(repoDir, `file-${i}.txt`), `after-${i}\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { tempDir, repoDir };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("checkout git diff batching", () => {
|
||||||
|
let tempDir: string;
|
||||||
|
let repoDir: string;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
const setup = initRepoWithTrackedChanges(20);
|
||||||
|
tempDir = setup.tempDir;
|
||||||
|
repoDir = setup.repoDir;
|
||||||
|
spawnCounters.trackedTextDiffCalls = 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
rmSync(tempDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses a single tracked git diff command for tracked file diffs", async () => {
|
||||||
|
const result = await getCheckoutDiff(repoDir, {
|
||||||
|
mode: "uncommitted",
|
||||||
|
includeStructured: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.diff).toContain("file-0.txt");
|
||||||
|
expect(result.diff).toContain("file-19.txt");
|
||||||
|
expect(spawnCounters.trackedTextDiffCalls).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||||
import { execSync } from "child_process";
|
import { execSync } from "child_process";
|
||||||
import { mkdtempSync, rmSync, writeFileSync, realpathSync } from "fs";
|
import { mkdtempSync, rmSync, writeFileSync, realpathSync, mkdirSync, symlinkSync } from "fs";
|
||||||
import { join } from "path";
|
import { join } from "path";
|
||||||
import { tmpdir } from "os";
|
import { tmpdir } from "os";
|
||||||
import {
|
import {
|
||||||
commitAll,
|
commitAll,
|
||||||
getCheckoutDiff,
|
getCheckoutDiff,
|
||||||
|
getPullRequestStatus,
|
||||||
getCheckoutStatus,
|
getCheckoutStatus,
|
||||||
getCheckoutStatusLite,
|
getCheckoutStatusLite,
|
||||||
mergeToBase,
|
mergeToBase,
|
||||||
@@ -141,6 +142,27 @@ describe("checkout git utilities", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("short-circuits tracked binary files", async () => {
|
||||||
|
const trackedBinaryPath = join(repoDir, "tracked-blob.bin");
|
||||||
|
writeFileSync(trackedBinaryPath, Buffer.from([0x00, 0xff, 0x10, 0x80, 0x00]));
|
||||||
|
execSync("git add tracked-blob.bin", { cwd: repoDir });
|
||||||
|
execSync("git -c commit.gpgsign=false commit -m 'add tracked binary'", {
|
||||||
|
cwd: repoDir,
|
||||||
|
});
|
||||||
|
|
||||||
|
writeFileSync(trackedBinaryPath, Buffer.from([0x00, 0xff, 0x11, 0x81, 0x00]));
|
||||||
|
|
||||||
|
const diff = await getCheckoutDiff(repoDir, {
|
||||||
|
mode: "uncommitted",
|
||||||
|
includeStructured: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const entry = diff.structured?.find((file) => file.path === "tracked-blob.bin");
|
||||||
|
expect(entry).toBeTruthy();
|
||||||
|
expect(entry?.status).toBe("binary");
|
||||||
|
expect(diff.diff).toContain("# tracked-blob.bin: binary diff omitted");
|
||||||
|
});
|
||||||
|
|
||||||
it("short-circuits untracked binary files", async () => {
|
it("short-circuits untracked binary files", async () => {
|
||||||
const binaryPath = join(repoDir, "blob.bin");
|
const binaryPath = join(repoDir, "blob.bin");
|
||||||
writeFileSync(binaryPath, Buffer.from([0x00, 0xff, 0x10, 0x80, 0x00, 0x7f, 0x00]));
|
writeFileSync(binaryPath, Buffer.from([0x00, 0xff, 0x10, 0x80, 0x00, 0x7f, 0x00]));
|
||||||
@@ -380,6 +402,29 @@ describe("checkout git utilities", () => {
|
|||||||
execSync(`git --git-dir ${remoteDir} show-ref --verify refs/heads/feature`);
|
execSync(`git --git-dir ${remoteDir} show-ref --verify refs/heads/feature`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("disables GitHub features when gh is unavailable", async () => {
|
||||||
|
execSync("git remote add origin https://github.com/getpaseo/paseo.git", { cwd: repoDir });
|
||||||
|
|
||||||
|
const fakeBinDir = join(tempDir, "fake-bin");
|
||||||
|
mkdirSync(fakeBinDir);
|
||||||
|
const gitPath = execSync("command -v git", { stdio: "pipe" }).toString().trim();
|
||||||
|
symlinkSync(gitPath, join(fakeBinDir, "git"));
|
||||||
|
|
||||||
|
const originalPath = process.env.PATH;
|
||||||
|
process.env.PATH = fakeBinDir;
|
||||||
|
try {
|
||||||
|
const status = await getPullRequestStatus(repoDir);
|
||||||
|
expect(status.githubFeaturesEnabled).toBe(false);
|
||||||
|
expect(status.status).toBeNull();
|
||||||
|
} finally {
|
||||||
|
if (originalPath === undefined) {
|
||||||
|
delete process.env.PATH;
|
||||||
|
} else {
|
||||||
|
process.env.PATH = originalPath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("returns typed MergeConflictError on merge conflicts", async () => {
|
it("returns typed MergeConflictError on merge conflicts", async () => {
|
||||||
const conflictFile = join(repoDir, "conflict.txt");
|
const conflictFile = join(repoDir, "conflict.txt");
|
||||||
writeFileSync(conflictFile, "base\n");
|
writeFileSync(conflictFile, "base\n");
|
||||||
|
|||||||
@@ -184,35 +184,81 @@ async function tryResolveMergeBase(cwd: string, baseRef: string): Promise<string
|
|||||||
|
|
||||||
type FileStat = { additions: number; deletions: number; isBinary: boolean } | null;
|
type FileStat = { additions: number; deletions: number; isBinary: boolean } | null;
|
||||||
|
|
||||||
async function tryGetNumstat(
|
function normalizeNumstatPath(pathField: string): string {
|
||||||
cwd: string,
|
const braceRenameMatch = pathField.match(/^(.*)\{(.*) => (.*)\}(.*)$/);
|
||||||
args: string[]
|
if (braceRenameMatch) {
|
||||||
): Promise<FileStat> {
|
const [, prefix, , renamed, suffix] = braceRenameMatch;
|
||||||
try {
|
return `${prefix}${renamed}${suffix}`;
|
||||||
const { text } = await spawnLimitedText({
|
|
||||||
cmd: "git",
|
|
||||||
args,
|
|
||||||
cwd,
|
|
||||||
env: READ_ONLY_GIT_ENV,
|
|
||||||
maxBytes: 64 * 1024,
|
|
||||||
acceptExitCodes: [0],
|
|
||||||
});
|
|
||||||
const line = text.trim().split("\n").map((l) => l.trim()).filter(Boolean)[0] ?? "";
|
|
||||||
if (!line) return null;
|
|
||||||
const [aRaw, dRaw] = line.split(/\s+/);
|
|
||||||
if (!aRaw || !dRaw) return null;
|
|
||||||
if (aRaw === "-" || dRaw === "-") {
|
|
||||||
return { additions: 0, deletions: 0, isBinary: true };
|
|
||||||
}
|
|
||||||
const additions = Number.parseInt(aRaw, 10);
|
|
||||||
const deletions = Number.parseInt(dRaw, 10);
|
|
||||||
if (Number.isNaN(additions) || Number.isNaN(deletions)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return { additions, deletions, isBinary: false };
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const inlineRenameMatch = pathField.match(/^(.*) => (.*)$/);
|
||||||
|
if (inlineRenameMatch) {
|
||||||
|
return inlineRenameMatch[2] ?? pathField;
|
||||||
|
}
|
||||||
|
|
||||||
|
return pathField;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TRACKED_DIFF_NUMSTAT_MAX_BYTES = 2 * 1024 * 1024; // 2MB
|
||||||
|
const TRACKED_MAX_CHANGED_LINES = 40_000;
|
||||||
|
|
||||||
|
async function getTrackedNumstatByPath(
|
||||||
|
cwd: string,
|
||||||
|
ref: string
|
||||||
|
): Promise<Map<string, FileStat>> {
|
||||||
|
const result = await spawnLimitedText({
|
||||||
|
cmd: "git",
|
||||||
|
args: ["diff", "--numstat", ref],
|
||||||
|
cwd,
|
||||||
|
env: READ_ONLY_GIT_ENV,
|
||||||
|
maxBytes: TRACKED_DIFF_NUMSTAT_MAX_BYTES,
|
||||||
|
acceptExitCodes: [0],
|
||||||
|
});
|
||||||
|
|
||||||
|
const stats = new Map<string, FileStat>();
|
||||||
|
const lines = result.text
|
||||||
|
.split("\n")
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
const parts = line.split("\t");
|
||||||
|
if (parts.length < 3) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const additionsField = parts[0] ?? "";
|
||||||
|
const deletionsField = parts[1] ?? "";
|
||||||
|
const rawPath = parts.slice(2).join("\t");
|
||||||
|
const path = normalizeNumstatPath(rawPath);
|
||||||
|
|
||||||
|
if (!path) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (additionsField === "-" || deletionsField === "-") {
|
||||||
|
stats.set(path, { additions: 0, deletions: 0, isBinary: true });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const additions = Number.parseInt(additionsField, 10);
|
||||||
|
const deletions = Number.parseInt(deletionsField, 10);
|
||||||
|
if (Number.isNaN(additions) || Number.isNaN(deletions)) {
|
||||||
|
stats.set(path, null);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
stats.set(path, { additions, deletions, isBinary: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
return stats;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTrackedDiffTooLarge(stat: FileStat): boolean {
|
||||||
|
if (!stat || stat.isBinary) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return stat.additions + stat.deletions > TRACKED_MAX_CHANGED_LINES;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class NotGitRepoError extends Error {
|
export class NotGitRepoError extends Error {
|
||||||
@@ -784,49 +830,32 @@ function buildPlaceholderParsedDiffFile(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getPerFileDiffText(
|
async function getUntrackedDiffText(
|
||||||
cwd: string,
|
cwd: string,
|
||||||
ref: string,
|
|
||||||
change: CheckoutFileChange
|
change: CheckoutFileChange
|
||||||
): Promise<{ text: string; truncated: boolean; stat: FileStat }> {
|
): Promise<{ text: string; truncated: boolean; stat: FileStat }> {
|
||||||
if (change.isUntracked) {
|
try {
|
||||||
try {
|
const inspected = await inspectUntrackedFile(cwd, change.path);
|
||||||
const inspected = await inspectUntrackedFile(cwd, change.path);
|
if (inspected.stat?.isBinary || inspected.truncated) {
|
||||||
if (inspected.stat?.isBinary || inspected.truncated) {
|
return { text: "", truncated: inspected.truncated, stat: inspected.stat };
|
||||||
return { text: "", truncated: inspected.truncated, stat: inspected.stat };
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Fall through to git diff path if metadata probing fails.
|
|
||||||
}
|
}
|
||||||
|
} catch {
|
||||||
const result = await spawnLimitedText({
|
// Fall through to git diff path if metadata probing fails.
|
||||||
cmd: "git",
|
|
||||||
args: ["diff", "--no-index", "/dev/null", "--", change.path],
|
|
||||||
cwd,
|
|
||||||
env: READ_ONLY_GIT_ENV,
|
|
||||||
maxBytes: PER_FILE_DIFF_MAX_BYTES,
|
|
||||||
acceptExitCodes: [0, 1],
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
text: result.text,
|
|
||||||
truncated: result.truncated,
|
|
||||||
stat: { additions: 0, deletions: 0, isBinary: false },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const stat = await tryGetNumstat(cwd, ["diff", "--numstat", ref, "--", change.path]);
|
|
||||||
if (stat?.isBinary) {
|
|
||||||
return { text: "", truncated: false, stat };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await spawnLimitedText({
|
const result = await spawnLimitedText({
|
||||||
cmd: "git",
|
cmd: "git",
|
||||||
args: ["diff", ref, "--", change.path],
|
args: ["diff", "--no-index", "/dev/null", "--", change.path],
|
||||||
cwd,
|
cwd,
|
||||||
env: READ_ONLY_GIT_ENV,
|
env: READ_ONLY_GIT_ENV,
|
||||||
maxBytes: PER_FILE_DIFF_MAX_BYTES,
|
maxBytes: PER_FILE_DIFF_MAX_BYTES,
|
||||||
|
acceptExitCodes: [0, 1],
|
||||||
});
|
});
|
||||||
return { text: result.text, truncated: result.truncated, stat };
|
return {
|
||||||
|
text: result.text,
|
||||||
|
truncated: result.truncated,
|
||||||
|
stat: { additions: 0, deletions: 0, isBinary: false },
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getCheckoutStatus(
|
export async function getCheckoutStatus(
|
||||||
@@ -966,8 +995,111 @@ export async function getCheckoutDiff(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
for (const change of changes) {
|
const trackedChanges = changes.filter((change) => !change.isUntracked);
|
||||||
const { text, truncated, stat } = await getPerFileDiffText(cwd, refForDiff, change);
|
const untrackedChanges = changes.filter((change) => change.isUntracked === true);
|
||||||
|
|
||||||
|
const trackedNumstatByPath =
|
||||||
|
trackedChanges.length > 0 ? await getTrackedNumstatByPath(cwd, refForDiff) : new Map<string, FileStat>();
|
||||||
|
const trackedDiffPaths: string[] = [];
|
||||||
|
const trackedPlaceholderByPath = new Map<
|
||||||
|
string,
|
||||||
|
{ status: "binary" | "too_large"; stat: FileStat }
|
||||||
|
>();
|
||||||
|
|
||||||
|
for (const change of trackedChanges) {
|
||||||
|
const stat = trackedNumstatByPath.get(change.path) ?? null;
|
||||||
|
if (stat?.isBinary) {
|
||||||
|
trackedPlaceholderByPath.set(change.path, { status: "binary", stat });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (isTrackedDiffTooLarge(stat)) {
|
||||||
|
trackedPlaceholderByPath.set(change.path, { status: "too_large", stat });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
trackedDiffPaths.push(change.path);
|
||||||
|
}
|
||||||
|
|
||||||
|
let trackedDiffText = "";
|
||||||
|
let trackedDiffTruncated = false;
|
||||||
|
if (trackedDiffPaths.length > 0) {
|
||||||
|
const trackedDiffResult = await spawnLimitedText({
|
||||||
|
cmd: "git",
|
||||||
|
args: ["diff", refForDiff, "--", ...trackedDiffPaths],
|
||||||
|
cwd,
|
||||||
|
env: READ_ONLY_GIT_ENV,
|
||||||
|
maxBytes: TOTAL_DIFF_MAX_BYTES,
|
||||||
|
});
|
||||||
|
trackedDiffText = trackedDiffResult.text;
|
||||||
|
trackedDiffTruncated = trackedDiffResult.truncated;
|
||||||
|
appendDiff(trackedDiffText);
|
||||||
|
if (trackedDiffTruncated) {
|
||||||
|
appendDiff("# tracked diff truncated\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const appendTrackedPlaceholderComment = (change: CheckoutFileChange, status: "binary" | "too_large") => {
|
||||||
|
if (status === "binary") {
|
||||||
|
appendDiff(`# ${change.path}: binary diff omitted\n`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
appendDiff(`# ${change.path}: diff too large omitted\n`);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (compare.includeStructured) {
|
||||||
|
const parsedTrackedFiles =
|
||||||
|
trackedDiffText.length > 0 ? await parseAndHighlightDiff(trackedDiffText, cwd) : [];
|
||||||
|
const parsedTrackedByPath = new Map(parsedTrackedFiles.map((file) => [file.path, file]));
|
||||||
|
|
||||||
|
for (const change of trackedChanges) {
|
||||||
|
const placeholder = trackedPlaceholderByPath.get(change.path);
|
||||||
|
if (placeholder) {
|
||||||
|
structured.push(
|
||||||
|
buildPlaceholderParsedDiffFile(change, {
|
||||||
|
status: placeholder.status,
|
||||||
|
stat: placeholder.stat,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
appendTrackedPlaceholderComment(change, placeholder.status);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stat = trackedNumstatByPath.get(change.path) ?? null;
|
||||||
|
const parsedFile = parsedTrackedByPath.get(change.path);
|
||||||
|
if (parsedFile) {
|
||||||
|
structured.push({
|
||||||
|
...parsedFile,
|
||||||
|
path: change.path,
|
||||||
|
isNew: change.isNew,
|
||||||
|
isDeleted: change.isDeleted,
|
||||||
|
status: "ok",
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
structured.push({
|
||||||
|
path: change.path,
|
||||||
|
isNew: change.isNew,
|
||||||
|
isDeleted: change.isDeleted,
|
||||||
|
additions: stat?.additions ?? 0,
|
||||||
|
deletions: stat?.deletions ?? 0,
|
||||||
|
hunks: [],
|
||||||
|
status: trackedDiffTruncated ? "too_large" : "ok",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (const change of trackedChanges) {
|
||||||
|
const placeholder = trackedPlaceholderByPath.get(change.path);
|
||||||
|
if (placeholder) {
|
||||||
|
appendTrackedPlaceholderComment(change, placeholder.status);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const change of untrackedChanges) {
|
||||||
|
if (diffBytes >= TOTAL_DIFF_MAX_BYTES) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const { text, truncated, stat } = await getUntrackedDiffText(cwd, change);
|
||||||
|
|
||||||
if (!compare.includeStructured) {
|
if (!compare.includeStructured) {
|
||||||
if (stat?.isBinary) {
|
if (stat?.isBinary) {
|
||||||
@@ -977,9 +1109,6 @@ export async function getCheckoutDiff(
|
|||||||
} else {
|
} else {
|
||||||
appendDiff(text);
|
appendDiff(text);
|
||||||
}
|
}
|
||||||
if (diffBytes >= TOTAL_DIFF_MAX_BYTES) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1268,6 +1397,11 @@ export interface PullRequestStatus {
|
|||||||
headRefName: string;
|
headRefName: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PullRequestStatusResult {
|
||||||
|
status: PullRequestStatus | null;
|
||||||
|
githubFeaturesEnabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
async function ensureGhAvailable(cwd: string): Promise<void> {
|
async function ensureGhAvailable(cwd: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await execAsync("gh --version", { cwd });
|
await execAsync("gh --version", { cwd });
|
||||||
@@ -1276,6 +1410,27 @@ async function ensureGhAvailable(cwd: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getCommandErrorText(error: unknown): string {
|
||||||
|
if (!(error instanceof Error)) {
|
||||||
|
return String(error);
|
||||||
|
}
|
||||||
|
const stderr = typeof (error as any)?.stderr === "string" ? (error as any).stderr : "";
|
||||||
|
const stdout = typeof (error as any)?.stdout === "string" ? (error as any).stdout : "";
|
||||||
|
return `${error.message}\n${stderr}\n${stdout}`.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function isGhAuthError(error: unknown): boolean {
|
||||||
|
const text = getCommandErrorText(error);
|
||||||
|
return (
|
||||||
|
text.includes("gh auth login") ||
|
||||||
|
text.includes("not logged into any github hosts") ||
|
||||||
|
text.includes("authentication failed") ||
|
||||||
|
text.includes("authentication required") ||
|
||||||
|
text.includes("bad credentials") ||
|
||||||
|
text.includes("http 401")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async function resolveGitHubRepo(cwd: string): Promise<string | null> {
|
async function resolveGitHubRepo(cwd: string): Promise<string | null> {
|
||||||
try {
|
try {
|
||||||
const { stdout } = await execAsync("git config --get remote.origin.url", {
|
const { stdout } = await execAsync("git config --get remote.origin.url", {
|
||||||
@@ -1356,39 +1511,66 @@ export async function createPullRequest(
|
|||||||
return { url: parsed.url, number: parsed.number };
|
return { url: parsed.url, number: parsed.number };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getPullRequestStatus(cwd: string): Promise<PullRequestStatus | null> {
|
export async function getPullRequestStatus(cwd: string): Promise<PullRequestStatusResult> {
|
||||||
await requireGitRepo(cwd);
|
await requireGitRepo(cwd);
|
||||||
await ensureGhAvailable(cwd);
|
|
||||||
const repo = await resolveGitHubRepo(cwd);
|
const repo = await resolveGitHubRepo(cwd);
|
||||||
const head = await getCurrentBranch(cwd);
|
const head = await getCurrentBranch(cwd);
|
||||||
if (!repo || !head) {
|
if (!repo || !head) {
|
||||||
return null;
|
return {
|
||||||
|
status: null,
|
||||||
|
githubFeaturesEnabled: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await ensureGhAvailable(cwd);
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
status: null,
|
||||||
|
githubFeaturesEnabled: false,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
const owner = repo.split("/")[0];
|
const owner = repo.split("/")[0];
|
||||||
const { stdout } = await execFileAsync(
|
let stdout: string;
|
||||||
"gh",
|
try {
|
||||||
[
|
({ stdout } = await execFileAsync(
|
||||||
"api",
|
"gh",
|
||||||
`repos/${repo}/pulls`,
|
[
|
||||||
"-X",
|
"api",
|
||||||
"GET",
|
`repos/${repo}/pulls`,
|
||||||
"-F",
|
"-X",
|
||||||
`head=${owner}:${head}`,
|
"GET",
|
||||||
"-F",
|
"-F",
|
||||||
"state=open",
|
`head=${owner}:${head}`,
|
||||||
],
|
"-F",
|
||||||
{ cwd }
|
"state=open",
|
||||||
);
|
],
|
||||||
|
{ cwd }
|
||||||
|
));
|
||||||
|
} catch (error) {
|
||||||
|
if (isGhAuthError(error)) {
|
||||||
|
return {
|
||||||
|
status: null,
|
||||||
|
githubFeaturesEnabled: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
const parsed = JSON.parse(stdout.trim());
|
const parsed = JSON.parse(stdout.trim());
|
||||||
const current = Array.isArray(parsed) && parsed.length > 0 ? parsed[0] : null;
|
const current = Array.isArray(parsed) && parsed.length > 0 ? parsed[0] : null;
|
||||||
if (!current) {
|
if (!current) {
|
||||||
return null;
|
return {
|
||||||
|
status: null,
|
||||||
|
githubFeaturesEnabled: true,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
url: current.html_url ?? current.url,
|
status: {
|
||||||
title: current.title,
|
url: current.html_url ?? current.url,
|
||||||
state: current.state,
|
title: current.title,
|
||||||
baseRefName: current.base?.ref ?? "",
|
state: current.state,
|
||||||
headRefName: current.head?.ref ?? head,
|
baseRefName: current.base?.ref ?? "",
|
||||||
|
headRefName: current.head?.ref ?? head,
|
||||||
|
},
|
||||||
|
githubFeaturesEnabled: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user