mirror of
https://github.com/getpaseo/paseo.git
synced 2026-08-14 12:23:16 +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": "*"
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"version": "0.4.1",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
@@ -19899,6 +19920,7 @@
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.1.2",
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/relay": "0.1.2",
|
||||
"@getpaseo/server": "0.1.2",
|
||||
"chalk": "^5.3.0",
|
||||
|
||||
@@ -506,6 +506,7 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
|
||||
});
|
||||
const {
|
||||
status: prStatus,
|
||||
githubFeaturesEnabled,
|
||||
payloadError: prPayloadError,
|
||||
refresh: refreshPrStatus,
|
||||
} = useCheckoutPrStatusQuery({
|
||||
@@ -840,7 +841,7 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
|
||||
const diffErrorMessage =
|
||||
diffPayloadError?.message ??
|
||||
(isDiffError && diffError instanceof Error ? diffError.message : null);
|
||||
const prErrorMessage = prPayloadError?.message ?? null;
|
||||
const prErrorMessage = githubFeaturesEnabled ? prPayloadError?.message ?? null : null;
|
||||
const branchLabel =
|
||||
gitStatus?.currentBranch && gitStatus.currentBranch !== "HEAD"
|
||||
? gitStatus.currentBranch
|
||||
@@ -993,7 +994,7 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
|
||||
}
|
||||
|
||||
// View PR - when PR exists
|
||||
if (hasPullRequest && prStatus?.url) {
|
||||
if (githubFeaturesEnabled && hasPullRequest && prStatus?.url) {
|
||||
const prUrl = prStatus.url;
|
||||
allActions.set("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
|
||||
if (aheadCount > 0 && !hasPullRequest) {
|
||||
if (githubFeaturesEnabled && aheadCount > 0 && !hasPullRequest) {
|
||||
allActions.set("create-pr", {
|
||||
id: "create-pr",
|
||||
label: "Create PR",
|
||||
@@ -1112,7 +1113,7 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
|
||||
|
||||
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,
|
||||
commitDisabled, pushDisabled, prDisabled, mergeDisabled, mergeFromBaseDisabled, archiveDisabled,
|
||||
commitStatus, pushStatus, prCreateStatus, mergeStatus, mergeFromBaseStatus, archiveStatus,
|
||||
|
||||
@@ -322,7 +322,6 @@ export function SessionProvider({
|
||||
const setPendingPermissions = useSessionStore(
|
||||
(state) => state.setPendingPermissions
|
||||
);
|
||||
const setGitDiffs = useSessionStore((state) => state.setGitDiffs);
|
||||
const setFileExplorer = useSessionStore((state) => state.setFileExplorer);
|
||||
const clearDraftInput = useDraftStore((state) => state.clearDraftInput);
|
||||
const setQueuedMessages = useSessionStore((state) => state.setQueuedMessages);
|
||||
@@ -666,22 +665,6 @@ export function SessionProvider({
|
||||
[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({
|
||||
mutationFn: async ({ agentId }: { agentId: string }) => {
|
||||
if (!agentId) {
|
||||
@@ -1275,15 +1258,6 @@ export function SessionProvider({
|
||||
return next;
|
||||
});
|
||||
|
||||
setGitDiffs(serverId, (prev) => {
|
||||
if (!prev.has(agentId)) {
|
||||
return prev;
|
||||
}
|
||||
const next = new Map(prev);
|
||||
next.delete(agentId);
|
||||
return next;
|
||||
});
|
||||
|
||||
setFileExplorer(serverId, (prev) => {
|
||||
if (!prev.has(agentId)) {
|
||||
return prev;
|
||||
@@ -1343,7 +1317,6 @@ export function SessionProvider({
|
||||
setAgents,
|
||||
setAgentLastActivity,
|
||||
setPendingPermissions,
|
||||
setGitDiffs,
|
||||
setFileExplorer,
|
||||
setHasHydratedAgents,
|
||||
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(
|
||||
(agentId: string, path: string, options?: { recordHistory?: boolean }) => {
|
||||
const normalizedPath = path && path.length > 0 ? path : ".";
|
||||
|
||||
@@ -43,6 +43,7 @@ export function useCheckoutPrStatusQuery({
|
||||
|
||||
return {
|
||||
status: query.data?.status ?? null,
|
||||
githubFeaturesEnabled: query.data?.githubFeaturesEnabled ?? true,
|
||||
payloadError: query.data?.error ?? null,
|
||||
isLoading: query.isLoading,
|
||||
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
|
||||
pendingPermissions: Map<string, PendingPermission>;
|
||||
|
||||
// Git diffs
|
||||
gitDiffs: Map<string, string>;
|
||||
|
||||
// File explorer
|
||||
fileExplorer: Map<string, AgentFileExplorerState>;
|
||||
|
||||
@@ -258,9 +255,6 @@ interface SessionStoreActions {
|
||||
// Permissions
|
||||
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
|
||||
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(),
|
||||
agents: new Map(),
|
||||
pendingPermissions: new Map(),
|
||||
gitDiffs: new Map(),
|
||||
fileExplorer: 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
|
||||
setFileExplorer: (serverId, state) => {
|
||||
set((prev) => {
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
"test:e2e:lifecycle": "npx tsx tests/e2e/agent-lifecycle.test.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/relay": "0.1.2",
|
||||
"@getpaseo/server": "0.1.2",
|
||||
"chalk": "^5.3.0",
|
||||
|
||||
@@ -18,6 +18,7 @@ import { runWaitCommand } from './commands/agent/wait.js'
|
||||
import { runAttachCommand } from './commands/agent/attach.js'
|
||||
import { runUpdateCommand } from './commands/agent/update.js'
|
||||
import { withOutput } from './output/index.js'
|
||||
import { onboardCommand } from './commands/onboard.js'
|
||||
|
||||
const VERSION = '0.1.0'
|
||||
|
||||
@@ -142,6 +143,7 @@ export function createCli(): Command {
|
||||
.action(withOutput(runUpdateCommand))
|
||||
|
||||
// Top-level local daemon shortcuts
|
||||
program.addCommand(onboardCommand())
|
||||
program.addCommand(daemonStartCommand())
|
||||
|
||||
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()
|
||||
if (process.argv.length <= 2) {
|
||||
process.argv.push('start')
|
||||
process.argv.push('onboard')
|
||||
}
|
||||
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 { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { afterEach, describe, expect, expectTypeOf, test, vi } from "vitest";
|
||||
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() {
|
||||
return {
|
||||
debug: vi.fn(),
|
||||
|
||||
@@ -15,9 +15,7 @@ import type {
|
||||
CreateAgentRequestMessage,
|
||||
FileDownloadTokenResponse,
|
||||
FileExplorerResponse,
|
||||
GitDiffResponse,
|
||||
GitSetupOptions,
|
||||
HighlightedDiffResponse,
|
||||
CheckoutStatusResponse,
|
||||
CheckoutCommitResponse,
|
||||
CheckoutMergeResponse,
|
||||
@@ -178,8 +176,6 @@ export type CreateAgentRequestOptions = {
|
||||
labels?: Record<string, string>;
|
||||
} & AgentConfigOverrides;
|
||||
|
||||
type GitDiffPayload = GitDiffResponse["payload"];
|
||||
type HighlightedDiffPayload = HighlightedDiffResponse["payload"];
|
||||
type CheckoutStatusPayload = CheckoutStatusResponse["payload"];
|
||||
type SubscribeCheckoutDiffPayload = Extract<
|
||||
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(
|
||||
options: { cwd: string; branchName: string },
|
||||
requestId?: string
|
||||
|
||||
@@ -229,17 +229,6 @@ export class TTSManager {
|
||||
isVoiceMode,
|
||||
},
|
||||
});
|
||||
this.logger.info(
|
||||
{
|
||||
audioId,
|
||||
chunkId,
|
||||
chunkIndex,
|
||||
isLastChunk: next.done,
|
||||
bytes: chunkBuffer.length,
|
||||
isVoiceMode,
|
||||
},
|
||||
"Emitted audio_output chunk to client"
|
||||
);
|
||||
|
||||
chunkIndex += 1;
|
||||
|
||||
@@ -298,15 +287,6 @@ export class TTSManager {
|
||||
}
|
||||
|
||||
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) {
|
||||
pending.resolve();
|
||||
|
||||
@@ -1012,63 +1012,11 @@ describe("daemon client E2E", () => {
|
||||
expect(checkoutStatus.isGit).toBe(true);
|
||||
expect(checkoutStatus.repoRoot).toContain(cwd);
|
||||
|
||||
const diffRequestId = `diff-${Date.now()}`;
|
||||
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;
|
||||
const diffResult = await ctx.client.getCheckoutDiff(cwd, { mode: "uncommitted" });
|
||||
expect(diffResult.error).toBeNull();
|
||||
expect(diffResult.diff).toContain("test.txt");
|
||||
expect(diffResult.diff).toContain("-original content");
|
||||
expect(diffResult.diff).toContain("+modified content");
|
||||
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);
|
||||
expect(Array.isArray(diffResult.files)).toBe(true);
|
||||
expect(diffResult.files.length).toBeGreaterThan(0);
|
||||
expect(diffResult.files.some((file) => file.path === "test.txt")).toBe(true);
|
||||
|
||||
const listRequestId = `list-${Date.now()}`;
|
||||
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();
|
||||
}, 60000);
|
||||
|
||||
describe("getGitDiff", () => {
|
||||
describe("getCheckoutDiff", () => {
|
||||
test(
|
||||
"returns diff for modified file in git repo",
|
||||
async () => {
|
||||
@@ -134,28 +134,12 @@ describe("daemon E2E", () => {
|
||||
// Modify the file (creates unstaged changes)
|
||||
writeFileSync(testFile, "modified content\n");
|
||||
|
||||
// Create agent in the git repo
|
||||
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
|
||||
const result = await ctx.client.getCheckoutDiff(cwd, { mode: "uncommitted" });
|
||||
expect(result.error).toBeNull();
|
||||
expect(result.diff).toBeTruthy();
|
||||
expect(result.diff).toContain("test.txt");
|
||||
expect(result.diff).toContain("-original content");
|
||||
expect(result.diff).toContain("+modified content");
|
||||
|
||||
// Cleanup
|
||||
await ctx.client.deleteAgent(agent.id);
|
||||
expect(result.files.length).toBeGreaterThan(0);
|
||||
const file = result.files.find((entry) => entry.path === "test.txt");
|
||||
expect(file).toBeTruthy();
|
||||
expect(file?.hunks.length).toBeGreaterThan(0);
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
},
|
||||
60000 // 1 minute timeout
|
||||
@@ -181,23 +165,11 @@ describe("daemon E2E", () => {
|
||||
stdio: "pipe",
|
||||
});
|
||||
|
||||
// Create agent in the git repo (no modifications)
|
||||
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);
|
||||
const result = await ctx.client.getCheckoutDiff(cwd, { mode: "uncommitted" });
|
||||
|
||||
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 });
|
||||
},
|
||||
60000 // 1 minute timeout
|
||||
@@ -209,24 +181,12 @@ describe("daemon E2E", () => {
|
||||
const cwd = tmpCwd();
|
||||
// Don't initialize git - just a regular directory
|
||||
|
||||
// Create agent in a non-git directory
|
||||
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",
|
||||
});
|
||||
const result = await ctx.client.getCheckoutDiff(cwd, { mode: "uncommitted" });
|
||||
|
||||
expect(agent.id).toBeTruthy();
|
||||
|
||||
// Get git diff - should return error
|
||||
const result = await ctx.client.getGitDiff(agent.id);
|
||||
|
||||
expect(result.diff).toBe("");
|
||||
expect(result.files).toEqual([]);
|
||||
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 });
|
||||
},
|
||||
60000 // 1 minute timeout
|
||||
|
||||
@@ -315,7 +315,7 @@ export class DictationStreamManager {
|
||||
state.bytesSinceCommit += resampled.length;
|
||||
state.peakSinceCommit = Math.max(state.peakSinceCommit, pcm16lePeakAbs(resampled));
|
||||
try {
|
||||
this.maybeAutoCommitDictationSegment(params.dictationId, state);
|
||||
this.maybeAutoCommitDictationSegment(state);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
void this.failAndCleanupDictationStream(params.dictationId, message, true);
|
||||
@@ -482,7 +482,7 @@ export class DictationStreamManager {
|
||||
this.streams.delete(dictationId);
|
||||
}
|
||||
|
||||
private maybeAutoCommitDictationSegment(dictationId: string, state: DictationStreamState): void {
|
||||
private maybeAutoCommitDictationSegment(state: DictationStreamState): void {
|
||||
if (state.finishRequested) {
|
||||
return;
|
||||
}
|
||||
@@ -490,29 +490,12 @@ export class DictationStreamManager {
|
||||
return;
|
||||
}
|
||||
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.bytesSinceCommit = 0;
|
||||
state.peakSinceCommit = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
this.logger.debug(
|
||||
{
|
||||
dictationId,
|
||||
autoCommitBytes: state.autoCommitBytes,
|
||||
bytesSinceCommit: state.bytesSinceCommit,
|
||||
},
|
||||
"Dictation auto-segment: committing buffered audio"
|
||||
);
|
||||
state.bytesSinceCommit = 0;
|
||||
state.peakSinceCommit = 0;
|
||||
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 { generateLocalPairingOffer, type LocalPairingOffer } from "./pairing-offer.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
|
||||
export type {
|
||||
|
||||
@@ -41,6 +41,7 @@ const SpeechProviderIdSchema = z
|
||||
|
||||
const FeatureDictationSchema = z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
stt: z
|
||||
.object({
|
||||
provider: SpeechProviderIdSchema.optional(),
|
||||
@@ -54,6 +55,7 @@ const FeatureDictationSchema = z
|
||||
|
||||
const FeatureVoiceModeSchema = z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
llm: z
|
||||
.object({
|
||||
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: "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 {
|
||||
try {
|
||||
const text =
|
||||
@@ -76,7 +80,9 @@ export function startRelayTransport({
|
||||
let reconnectAttempt = 0;
|
||||
const dataSockets = new Map<string, WebSocket>(); // clientId -> ws
|
||||
let controlKeepaliveInterval: ReturnType<typeof setInterval> | null = null;
|
||||
let controlReadyTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
let controlLastSeenAt = 0;
|
||||
let controlConnectionSeq = 0;
|
||||
|
||||
const stop = async (): Promise<void> => {
|
||||
stopped = true;
|
||||
@@ -88,6 +94,10 @@ export function startRelayTransport({
|
||||
clearInterval(controlKeepaliveInterval);
|
||||
controlKeepaliveInterval = null;
|
||||
}
|
||||
if (controlReadyTimeout) {
|
||||
clearTimeout(controlReadyTimeout);
|
||||
controlReadyTimeout = null;
|
||||
}
|
||||
if (controlWs) {
|
||||
try {
|
||||
controlWs.close();
|
||||
@@ -109,6 +119,7 @@ export function startRelayTransport({
|
||||
const connectControl = (): void => {
|
||||
if (stopped) return;
|
||||
|
||||
const connectionId = ++controlConnectionSeq;
|
||||
const url = buildRelayWebSocketUrl({
|
||||
endpoint: relayEndpoint,
|
||||
serverId,
|
||||
@@ -116,14 +127,46 @@ export function startRelayTransport({
|
||||
});
|
||||
const socket = new WebSocket(url, { handshakeTimeout: 10_000, perMessageDeflate: false });
|
||||
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", () => {
|
||||
reconnectAttempt = 0;
|
||||
if (controlWs !== socket) return;
|
||||
|
||||
controlLastSeenAt = Date.now();
|
||||
if (controlKeepaliveInterval) {
|
||||
clearInterval(controlKeepaliveInterval);
|
||||
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(() => {
|
||||
if (stopped) return;
|
||||
if (controlWs !== socket) return;
|
||||
@@ -133,8 +176,11 @@ export function startRelayTransport({
|
||||
const staleForMs = now - controlLastSeenAt;
|
||||
// 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.
|
||||
if (staleForMs > 90_000) {
|
||||
relayLogger.warn({ url, staleForMs }, "relay_control_stale_terminating");
|
||||
if (staleForMs > CONTROL_STALE_TIMEOUT_MS) {
|
||||
relayLogger.warn(
|
||||
{ url, staleForMs, connectionId, staleTimeoutMs: CONTROL_STALE_TIMEOUT_MS },
|
||||
"relay_control_stale_terminating"
|
||||
);
|
||||
try {
|
||||
socket.terminate();
|
||||
} catch {
|
||||
@@ -146,40 +192,58 @@ export function startRelayTransport({
|
||||
try {
|
||||
socket.send(JSON.stringify({ type: "ping", ts: now }));
|
||||
} catch (error) {
|
||||
relayLogger.warn({ err: error, url }, "relay_control_ping_send_failed");
|
||||
relayLogger.warn({ err: error, url, connectionId }, "relay_control_ping_send_failed");
|
||||
try {
|
||||
socket.terminate();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}, 20_000);
|
||||
relayLogger.info({ url }, "relay_control_connected");
|
||||
}, CONTROL_PING_INTERVAL_MS);
|
||||
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) => {
|
||||
if (controlWs !== socket) return;
|
||||
relayLogger.warn(
|
||||
{ code, reason: reason?.toString?.(), url },
|
||||
{ code, reason: reason?.toString?.(), url, connectionId },
|
||||
"relay_control_disconnected"
|
||||
);
|
||||
if (controlWs === socket) {
|
||||
controlWs = null;
|
||||
}
|
||||
controlWs = null;
|
||||
if (controlKeepaliveInterval) {
|
||||
clearInterval(controlKeepaliveInterval);
|
||||
controlKeepaliveInterval = null;
|
||||
}
|
||||
if (controlReadyTimeout) {
|
||||
clearTimeout(controlReadyTimeout);
|
||||
controlReadyTimeout = null;
|
||||
}
|
||||
scheduleReconnect();
|
||||
});
|
||||
|
||||
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
|
||||
});
|
||||
|
||||
socket.on("message", (data) => {
|
||||
if (controlWs !== socket) return;
|
||||
controlLastSeenAt = Date.now();
|
||||
const msg = tryParseControlMessage(data);
|
||||
if (msg) {
|
||||
markControlReady();
|
||||
}
|
||||
if (!msg) return;
|
||||
if (msg.type === "ping") {
|
||||
try {
|
||||
|
||||
@@ -26,7 +26,6 @@ import {
|
||||
type ProjectPlacementPayload,
|
||||
} from "./messages.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 { STTManager } from "./agent/stt-manager.js";
|
||||
import type { SpeechToTextProvider, TextToSpeechProvider } from "./speech/speech-provider.js";
|
||||
@@ -581,7 +580,7 @@ export class Session {
|
||||
void this.initializeAgentMcp();
|
||||
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;
|
||||
const agentToolCount = Object.keys(this.agentTools ?? {}).length;
|
||||
this.sessionLogger.info(
|
||||
this.sessionLogger.trace(
|
||||
{ agentToolCount },
|
||||
`Agent MCP initialized with ${agentToolCount} tools`
|
||||
);
|
||||
@@ -941,9 +940,17 @@ export class Session {
|
||||
agentId,
|
||||
extractTimestamps(record)
|
||||
);
|
||||
this.sessionLogger.info(
|
||||
{ agentId, provider: record.provider },
|
||||
"Agent resumed from persistence"
|
||||
);
|
||||
} else {
|
||||
const config = buildSessionConfig(record);
|
||||
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);
|
||||
@@ -1215,10 +1222,6 @@ export class Session {
|
||||
);
|
||||
break;
|
||||
|
||||
case "git_diff_request":
|
||||
await this.handleGitDiffRequest(msg.agentId, msg.requestId);
|
||||
break;
|
||||
|
||||
case "checkout_status_request":
|
||||
await this.handleCheckoutStatusRequest(msg);
|
||||
break;
|
||||
@@ -1267,10 +1270,6 @@ export class Session {
|
||||
await this.handlePaseoWorktreeArchiveRequest(msg);
|
||||
break;
|
||||
|
||||
case "highlighted_diff_request":
|
||||
await this.handleHighlightedDiffRequest(msg.agentId, msg.requestId);
|
||||
break;
|
||||
|
||||
case "file_explorer_request":
|
||||
await this.handleFileExplorerRequest(msg);
|
||||
break;
|
||||
@@ -2087,11 +2086,6 @@ export class Session {
|
||||
agentId: string,
|
||||
requestId: string
|
||||
): Promise<void> {
|
||||
this.sessionLogger.info(
|
||||
{ agentId },
|
||||
`Initializing agent ${agentId} on demand`
|
||||
);
|
||||
|
||||
try {
|
||||
const snapshot = await this.ensureAgentLoaded(agentId);
|
||||
await this.forwardAgentUpdate(snapshot);
|
||||
@@ -2108,11 +2102,6 @@ export class Session {
|
||||
requestId,
|
||||
},
|
||||
});
|
||||
|
||||
this.sessionLogger.info(
|
||||
{ agentId, timelineSize, status: snapshot.lifecycle },
|
||||
`Agent ${agentId} initialized with ${timelineSize} timeline item(s); status=${snapshot.lifecycle}`
|
||||
);
|
||||
} catch (error: any) {
|
||||
this.sessionLogger.error(
|
||||
{ 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(
|
||||
msg: Extract<SessionInboundMessage, { type: "checkout_status_request" }>
|
||||
): Promise<void> {
|
||||
@@ -4190,12 +4119,13 @@ export class Session {
|
||||
const { cwd, requestId } = msg;
|
||||
|
||||
try {
|
||||
const status = await getPullRequestStatus(cwd);
|
||||
const prStatus = await getPullRequestStatus(cwd);
|
||||
this.emit({
|
||||
type: "checkout_pr_status_response",
|
||||
payload: {
|
||||
cwd,
|
||||
status,
|
||||
status: prStatus.status,
|
||||
githubFeaturesEnabled: prStatus.githubFeaturesEnabled,
|
||||
error: null,
|
||||
requestId,
|
||||
},
|
||||
@@ -4206,6 +4136,7 @@ export class Session {
|
||||
payload: {
|
||||
cwd,
|
||||
status: null,
|
||||
githubFeaturesEnabled: true,
|
||||
error: this.toCheckoutError(error),
|
||||
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
|
||||
*/
|
||||
@@ -5871,7 +5571,7 @@ export class Session {
|
||||
* Clean up session resources
|
||||
*/
|
||||
public async cleanup(): Promise<void> {
|
||||
this.sessionLogger.info("Cleaning up");
|
||||
this.sessionLogger.trace("Cleaning up");
|
||||
|
||||
if (this.unsubscribeAgentEvents) {
|
||||
this.unsubscribeAgentEvents();
|
||||
|
||||
@@ -73,9 +73,10 @@ const LocalSpeechResolutionSchema = z.object({
|
||||
|
||||
function persistedLocalFeatureModel(
|
||||
provider: RequestedSpeechProviders[keyof RequestedSpeechProviders]["provider"],
|
||||
enabled: boolean | undefined,
|
||||
model: string | undefined
|
||||
): string | undefined {
|
||||
if (provider !== "local") {
|
||||
if (provider !== "local" || enabled === false) {
|
||||
return undefined;
|
||||
}
|
||||
return model;
|
||||
@@ -87,9 +88,12 @@ function shouldIncludeLocalProviderConfig(params: {
|
||||
persisted: PersistedConfig;
|
||||
}): boolean {
|
||||
const localRequestedByFeature =
|
||||
params.providers.dictationStt.provider === "local" ||
|
||||
params.providers.voiceStt.provider === "local" ||
|
||||
params.providers.voiceTts.provider === "local";
|
||||
(params.providers.dictationStt.enabled !== false &&
|
||||
params.providers.dictationStt.provider === "local") ||
|
||||
(params.providers.voiceStt.enabled !== false &&
|
||||
params.providers.voiceStt.provider === "local") ||
|
||||
(params.providers.voiceTts.enabled !== false &&
|
||||
params.providers.voiceTts.provider === "local");
|
||||
|
||||
return (
|
||||
localRequestedByFeature ||
|
||||
@@ -119,6 +123,7 @@ export function resolveLocalSpeechConfig(params: {
|
||||
params.env.PASEO_DICTATION_LOCAL_STT_MODEL ??
|
||||
persistedLocalFeatureModel(
|
||||
params.providers.dictationStt.provider,
|
||||
params.providers.dictationStt.enabled,
|
||||
params.persisted.features?.dictation?.stt?.model
|
||||
) ??
|
||||
DEFAULT_LOCAL_STT_MODEL,
|
||||
@@ -126,6 +131,7 @@ export function resolveLocalSpeechConfig(params: {
|
||||
params.env.PASEO_VOICE_LOCAL_STT_MODEL ??
|
||||
persistedLocalFeatureModel(
|
||||
params.providers.voiceStt.provider,
|
||||
params.providers.voiceStt.enabled,
|
||||
params.persisted.features?.voiceMode?.stt?.model
|
||||
) ??
|
||||
DEFAULT_LOCAL_STT_MODEL,
|
||||
@@ -133,6 +139,7 @@ export function resolveLocalSpeechConfig(params: {
|
||||
params.env.PASEO_VOICE_LOCAL_TTS_MODEL ??
|
||||
persistedLocalFeatureModel(
|
||||
params.providers.voiceTts.provider,
|
||||
params.providers.voiceTts.enabled,
|
||||
params.persisted.features?.voiceMode?.tts?.model
|
||||
) ??
|
||||
DEFAULT_LOCAL_TTS_MODEL,
|
||||
|
||||
@@ -88,13 +88,22 @@ function computeRequiredLocalModelIds(params: {
|
||||
models: ResolvedLocalModels;
|
||||
}): 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);
|
||||
}
|
||||
if (params.providers.voiceStt.provider === "local") {
|
||||
if (
|
||||
params.providers.voiceStt.enabled !== false &&
|
||||
params.providers.voiceStt.provider === "local"
|
||||
) {
|
||||
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);
|
||||
}
|
||||
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) {
|
||||
logger.warn(
|
||||
{ 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) {
|
||||
logger.warn(
|
||||
{ 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) {
|
||||
logger.warn(
|
||||
{ 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 { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
@@ -53,4 +53,46 @@ describe("sherpa model downloader", () => {
|
||||
})
|
||||
).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 { mkdir, rename, rm, stat } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { Readable } from "node:stream";
|
||||
import { Readable, Transform } from "node:stream";
|
||||
import { pipeline } from "node:stream/promises";
|
||||
import { spawn } from "node:child_process";
|
||||
import type pino from "pino";
|
||||
@@ -39,7 +39,31 @@ async function hasRequiredFiles(modelDir: string, requiredFiles: string[]): Prom
|
||||
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);
|
||||
if (!res.ok) {
|
||||
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()}`;
|
||||
await mkdir(path.dirname(outputPath), { recursive: true });
|
||||
|
||||
const total = Number(res.headers.get("content-length") ?? "0");
|
||||
let downloaded = 0;
|
||||
let lastLoggedBucket = -1;
|
||||
const nodeStream = Readable.fromWeb(res.body as any);
|
||||
const totalBytes = parseContentLength(res);
|
||||
|
||||
const nodeStream = Readable.fromWeb(res.body as any).on("data", (chunk: Buffer) => {
|
||||
downloaded += chunk.length;
|
||||
if (total > 0) {
|
||||
const pct = Math.floor((downloaded / total) * 100);
|
||||
const bucket = Math.min(100, Math.floor(pct / 10) * 10);
|
||||
if (bucket >= 0 && bucket <= 100 && bucket !== lastLoggedBucket) {
|
||||
lastLoggedBucket = bucket;
|
||||
logger.info({ pct: bucket, downloaded, total }, "Downloading model artifact");
|
||||
let downloadedBytes = 0;
|
||||
let lastLoggedPct = -1;
|
||||
let lastLoggedBytes = 0;
|
||||
let lastLoggedAt = 0;
|
||||
|
||||
const logProgress = (force: boolean): void => {
|
||||
const pct =
|
||||
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));
|
||||
await rename(tmpPath, outputPath);
|
||||
try {
|
||||
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> {
|
||||
@@ -114,60 +182,103 @@ export async function ensureSherpaOnnxModel(options: EnsureSherpaOnnxModelOption
|
||||
);
|
||||
}
|
||||
|
||||
if (spec.archiveUrl) {
|
||||
logger.info({ modelsDir: options.modelsDir, url: spec.archiveUrl }, "Model files missing; downloading");
|
||||
logger.info({ modelsDir: options.modelsDir }, "Starting model download");
|
||||
|
||||
const downloadsDir = path.join(options.modelsDir, ".downloads");
|
||||
const archiveFilename = path.basename(new URL(spec.archiveUrl).pathname);
|
||||
const archivePath = path.join(downloadsDir, archiveFilename);
|
||||
try {
|
||||
if (spec.archiveUrl) {
|
||||
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))) {
|
||||
await downloadToFile(spec.archiveUrl, archivePath, logger);
|
||||
} else {
|
||||
logger.info({ archivePath }, "Using cached archive");
|
||||
}
|
||||
|
||||
await extractTarArchive(archivePath, options.modelsDir);
|
||||
|
||||
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;
|
||||
if (!(await isNonEmptyFile(archivePath))) {
|
||||
await downloadToFile({
|
||||
url: spec.archiveUrl,
|
||||
outputPath: archivePath,
|
||||
logger,
|
||||
modelId: options.modelId,
|
||||
artifact: archiveFilename,
|
||||
});
|
||||
}
|
||||
await downloadToFile(file.url, dst, logger);
|
||||
}
|
||||
|
||||
if (!(await hasRequiredFiles(modelDir, spec.requiredFiles))) {
|
||||
throw new Error(
|
||||
`Downloaded files for ${options.modelId}, but required files are still missing in ${modelDir}.`
|
||||
logger.info(
|
||||
{
|
||||
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");
|
||||
return modelDir;
|
||||
}
|
||||
if (spec.downloadFiles && spec.downloadFiles.length > 0) {
|
||||
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: {
|
||||
|
||||
@@ -74,27 +74,32 @@ export function resolveOpenAiSpeechConfig(params: {
|
||||
params.persisted.features?.dictation?.stt?.confidenceThreshold,
|
||||
sttModel:
|
||||
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
|
||||
: undefined) ??
|
||||
(params.providers.dictationStt.provider === "openai"
|
||||
(params.providers.dictationStt.enabled !== false &&
|
||||
params.providers.dictationStt.provider === "openai"
|
||||
? params.persisted.features?.dictation?.stt?.model
|
||||
: undefined),
|
||||
ttsVoice:
|
||||
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
|
||||
: undefined) ??
|
||||
"alloy",
|
||||
ttsModel:
|
||||
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
|
||||
: undefined) ??
|
||||
DEFAULT_OPENAI_TTS_MODEL,
|
||||
realtimeTranscriptionModel:
|
||||
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
|
||||
: undefined) ??
|
||||
DEFAULT_OPENAI_REALTIME_TRANSCRIPTION_MODEL,
|
||||
|
||||
@@ -60,13 +60,22 @@ export function validateOpenAiCredentialRequirements(params: {
|
||||
const openAiCredentials = resolveOpenAiCredentials(openaiConfig);
|
||||
|
||||
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");
|
||||
}
|
||||
if (providers.voiceTts.provider === "openai" && !openAiCredentials.openaiTtsApiKey) {
|
||||
if (
|
||||
providers.voiceTts.enabled !== false &&
|
||||
providers.voiceTts.provider === "openai" &&
|
||||
!openAiCredentials.openaiTtsApiKey
|
||||
) {
|
||||
missingOpenAiCredentialsFor.push("voice.tts");
|
||||
}
|
||||
if (
|
||||
providers.dictationStt.enabled !== false &&
|
||||
providers.dictationStt.provider === "openai" &&
|
||||
!openAiCredentials.openaiDictationApiKey
|
||||
) {
|
||||
@@ -104,10 +113,18 @@ export function initializeOpenAiSpeechServices(params: {
|
||||
let ttsService = existing.ttsService;
|
||||
let dictationSttService = existing.dictationSttService;
|
||||
|
||||
const needsOpenAiStt = !sttService && providers.voiceStt.provider === "openai";
|
||||
const needsOpenAiTts = !ttsService && providers.voiceTts.provider === "openai";
|
||||
const needsOpenAiStt =
|
||||
!sttService &&
|
||||
providers.voiceStt.enabled !== false &&
|
||||
providers.voiceStt.provider === "openai";
|
||||
const needsOpenAiTts =
|
||||
!ttsService &&
|
||||
providers.voiceTts.enabled !== false &&
|
||||
providers.voiceTts.provider === "openai";
|
||||
const needsOpenAiDictation =
|
||||
!dictationSttService && providers.dictationStt.provider === "openai";
|
||||
!dictationSttService &&
|
||||
providers.dictationStt.enabled !== false &&
|
||||
providers.dictationStt.provider === "openai";
|
||||
|
||||
if (
|
||||
(needsOpenAiStt || needsOpenAiTts || needsOpenAiDictation) &&
|
||||
|
||||
@@ -21,14 +21,17 @@ describe("resolveSpeechConfig", () => {
|
||||
expect(result.speech.providers.dictationStt).toEqual({
|
||||
provider: "local",
|
||||
explicit: false,
|
||||
enabled: true,
|
||||
});
|
||||
expect(result.speech.providers.voiceStt).toEqual({
|
||||
provider: "local",
|
||||
explicit: false,
|
||||
enabled: true,
|
||||
});
|
||||
expect(result.speech.providers.voiceTts).toEqual({
|
||||
provider: "local",
|
||||
explicit: false,
|
||||
enabled: true,
|
||||
});
|
||||
expect(result.speech.local).toEqual({
|
||||
modelsDir: path.join(paseoHome, "models", "local-speech"),
|
||||
@@ -91,14 +94,17 @@ describe("resolveSpeechConfig", () => {
|
||||
expect(result.speech.providers.dictationStt).toEqual({
|
||||
provider: "local",
|
||||
explicit: true,
|
||||
enabled: true,
|
||||
});
|
||||
expect(result.speech.providers.voiceStt).toEqual({
|
||||
provider: "openai",
|
||||
explicit: true,
|
||||
enabled: true,
|
||||
});
|
||||
expect(result.speech.providers.voiceTts).toEqual({
|
||||
provider: "local",
|
||||
explicit: true,
|
||||
enabled: true,
|
||||
});
|
||||
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");
|
||||
@@ -127,4 +133,35 @@ describe("resolveSpeechConfig", () => {
|
||||
expect(result.speech.local?.models.voiceTts).toBe("kokoro-en-v0_19");
|
||||
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)
|
||||
.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({
|
||||
dictationStt: OptionalSpeechProviderSchema.default("local"),
|
||||
voiceStt: OptionalSpeechProviderSchema.default("local"),
|
||||
@@ -29,10 +48,12 @@ function resolveRequestedSpeechProviders(params: {
|
||||
}): RequestedSpeechProviders {
|
||||
const resolveFeatureProvider = (
|
||||
configuredValue: string | undefined,
|
||||
parsedValue: z.infer<typeof SpeechProviderIdSchema>
|
||||
parsedValue: z.infer<typeof SpeechProviderIdSchema>,
|
||||
enabled: boolean
|
||||
): RequestedSpeechProvider => ({
|
||||
provider: parsedValue,
|
||||
explicit: configuredValue !== undefined,
|
||||
enabled,
|
||||
});
|
||||
|
||||
const dictationSttProviderFromConfig =
|
||||
@@ -44,6 +65,14 @@ function resolveRequestedSpeechProviders(params: {
|
||||
const voiceTtsProviderFromConfig =
|
||||
params.env.PASEO_VOICE_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({
|
||||
dictationStt: dictationSttProviderFromConfig ?? "local",
|
||||
@@ -54,15 +83,18 @@ function resolveRequestedSpeechProviders(params: {
|
||||
return {
|
||||
dictationStt: resolveFeatureProvider(
|
||||
dictationSttProviderFromConfig,
|
||||
parsed.dictationStt
|
||||
parsed.dictationStt,
|
||||
dictationEnabled
|
||||
),
|
||||
voiceStt: resolveFeatureProvider(
|
||||
voiceSttProviderFromConfig,
|
||||
parsed.voiceStt
|
||||
parsed.voiceStt,
|
||||
voiceModeEnabled
|
||||
),
|
||||
voiceTts: resolveFeatureProvider(
|
||||
voiceTtsProviderFromConfig,
|
||||
parsed.voiceTts
|
||||
parsed.voiceTts,
|
||||
voiceModeEnabled
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -23,9 +23,9 @@ function resolveRequestedSpeechProviders(
|
||||
}
|
||||
|
||||
return {
|
||||
dictationStt: { provider: "local", explicit: false },
|
||||
voiceStt: { provider: "local", explicit: false },
|
||||
voiceTts: { provider: "local", explicit: false },
|
||||
dictationStt: { provider: "local", explicit: false, enabled: true },
|
||||
voiceStt: { provider: "local", explicit: false, enabled: true },
|
||||
voiceTts: { provider: "local", explicit: false, enabled: true },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -59,9 +59,18 @@ export async function initializeSpeechRuntime(params: {
|
||||
logger.info(
|
||||
{
|
||||
requestedProviders: {
|
||||
dictationStt: providers.dictationStt.provider,
|
||||
voiceStt: providers.voiceStt.provider,
|
||||
voiceTts: providers.voiceTts.provider,
|
||||
dictationStt: {
|
||||
provider: providers.dictationStt.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: {
|
||||
openai: getOpenAiSpeechAvailability(openaiConfig),
|
||||
@@ -99,9 +108,11 @@ export async function initializeSpeechRuntime(params: {
|
||||
: "openai",
|
||||
};
|
||||
const unavailableFeatures = [
|
||||
!openAiSpeech.dictationSttService ? "dictation.stt" : null,
|
||||
!openAiSpeech.sttService ? "voice.stt" : null,
|
||||
!openAiSpeech.ttsService ? "voice.tts" : null,
|
||||
providers.dictationStt.enabled !== false && !openAiSpeech.dictationSttService
|
||||
? "dictation.stt"
|
||||
: 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);
|
||||
const explicitlyConfiguredUnavailableFeatures = unavailableFeatures.filter((feature) => {
|
||||
if (feature === "dictation.stt") {
|
||||
@@ -117,9 +128,18 @@ export async function initializeSpeechRuntime(params: {
|
||||
logger.error(
|
||||
{
|
||||
requestedProviders: {
|
||||
dictationStt: providers.dictationStt.provider,
|
||||
voiceStt: providers.voiceStt.provider,
|
||||
voiceTts: providers.voiceTts.provider,
|
||||
dictationStt: {
|
||||
provider: providers.dictationStt.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: {
|
||||
dictationStt: providers.dictationStt.explicit,
|
||||
@@ -140,9 +160,18 @@ export async function initializeSpeechRuntime(params: {
|
||||
logger.warn(
|
||||
{
|
||||
requestedProviders: {
|
||||
dictationStt: providers.dictationStt.provider,
|
||||
voiceStt: providers.voiceStt.provider,
|
||||
voiceTts: providers.voiceTts.provider,
|
||||
dictationStt: {
|
||||
provider: providers.dictationStt.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: {
|
||||
dictationStt: providers.dictationStt.explicit,
|
||||
|
||||
@@ -6,6 +6,7 @@ export type SpeechProviderId = z.infer<typeof SpeechProviderIdSchema>;
|
||||
export const RequestedSpeechProviderSchema = z.object({
|
||||
provider: SpeechProviderIdSchema,
|
||||
explicit: z.boolean(),
|
||||
enabled: z.boolean().optional(),
|
||||
});
|
||||
export type RequestedSpeechProvider = z.infer<typeof RequestedSpeechProviderSchema>;
|
||||
|
||||
|
||||
@@ -279,7 +279,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
})
|
||||
);
|
||||
|
||||
connectionLogger.info(
|
||||
connectionLogger.trace(
|
||||
{ clientId, totalSessions: this.sessions.size },
|
||||
"Client connected"
|
||||
);
|
||||
@@ -319,7 +319,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
const session = this.sessions.get(ws);
|
||||
if (!session) return;
|
||||
|
||||
connectionLogger.info(
|
||||
connectionLogger.trace(
|
||||
{ clientId, totalSessions: this.sessions.size - 1 },
|
||||
"Client disconnected"
|
||||
);
|
||||
@@ -386,28 +386,12 @@ export class VoiceAssistantWebSocketServer {
|
||||
|
||||
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") {
|
||||
this.sendToClient(ws, { type: "pong" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === "recording_state") {
|
||||
this.logger.debug({ isRecording: message.isRecording }, "Recording state");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -418,17 +402,6 @@ export class VoiceAssistantWebSocketServer {
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -502,23 +475,11 @@ export class VoiceAssistantWebSocketServer {
|
||||
} {
|
||||
const activity = session.getClientActivity();
|
||||
if (!activity) {
|
||||
this.logger.debug("getClientActivityState: no activity for session");
|
||||
return { deviceType: null, focusedAgentId: null, isStale: true, appVisible: false };
|
||||
}
|
||||
const now = Date.now();
|
||||
const ageMs = now - activity.lastActivityAt.getTime();
|
||||
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 {
|
||||
deviceType: activity.deviceType,
|
||||
focusedAgentId: activity.focusedAgentId,
|
||||
@@ -603,16 +564,6 @@ export class VoiceAssistantWebSocketServer {
|
||||
|
||||
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(
|
||||
(state) => state.deviceType === "web" && !state.isStale
|
||||
);
|
||||
@@ -627,11 +578,6 @@ export class VoiceAssistantWebSocketServer {
|
||||
!hasActiveWebClient &&
|
||||
!hasActiveMobileForegroundClient;
|
||||
|
||||
this.logger.debug(
|
||||
{ hasActiveWebClient, hasActiveMobileForegroundClient, shouldSendPush },
|
||||
"Push gating check"
|
||||
);
|
||||
|
||||
if (shouldSendPush) {
|
||||
const tokens = this.pushTokenStore.getAllTokens();
|
||||
this.logger.info({ tokenCount: tokens.length }, "Sending push notification");
|
||||
|
||||
@@ -4,6 +4,8 @@ import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
AgentStreamMessageSchema,
|
||||
AgentStreamSnapshotMessageSchema,
|
||||
SessionInboundMessageSchema,
|
||||
SessionOutboundMessageSchema,
|
||||
WSOutboundMessageSchema,
|
||||
} from "./messages.js";
|
||||
|
||||
@@ -64,4 +66,44 @@ describe("shared messages stream parsing", () => {
|
||||
});
|
||||
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,
|
||||
});
|
||||
|
||||
export const GitDiffRequestSchema = z.object({
|
||||
type: z.literal("git_diff_request"),
|
||||
agentId: z.string(),
|
||||
requestId: z.string(),
|
||||
});
|
||||
|
||||
const CheckoutErrorCodeSchema = z.enum([
|
||||
"NOT_GIT_REPO",
|
||||
"NOT_ALLOWED",
|
||||
@@ -837,12 +831,6 @@ const ParsedDiffFileSchema = z.object({
|
||||
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({
|
||||
name: z.string(),
|
||||
path: z.string(),
|
||||
@@ -1010,7 +998,6 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
|
||||
SetAgentModelRequestMessageSchema,
|
||||
SetAgentThinkingRequestMessageSchema,
|
||||
AgentPermissionResponseMessageSchema,
|
||||
GitDiffRequestSchema,
|
||||
CheckoutStatusRequestSchema,
|
||||
SubscribeCheckoutDiffRequestSchema,
|
||||
UnsubscribeCheckoutDiffRequestSchema,
|
||||
@@ -1023,7 +1010,6 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
|
||||
ValidateBranchRequestSchema,
|
||||
PaseoWorktreeListRequestSchema,
|
||||
PaseoWorktreeArchiveRequestSchema,
|
||||
HighlightedDiffRequestSchema,
|
||||
FileExplorerRequestSchema,
|
||||
ProjectIconRequestSchema,
|
||||
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({
|
||||
ahead: z.number(),
|
||||
behind: z.number(),
|
||||
@@ -1564,6 +1540,7 @@ export const CheckoutPrStatusResponseSchema = z.object({
|
||||
payload: z.object({
|
||||
cwd: z.string(),
|
||||
status: CheckoutPrStatusSchema.nullable(),
|
||||
githubFeaturesEnabled: z.boolean(),
|
||||
error: CheckoutErrorSchema.nullable(),
|
||||
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({
|
||||
type: z.literal("file_explorer_response"),
|
||||
payload: z.object({
|
||||
@@ -1834,7 +1801,6 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
||||
AgentPermissionResolvedMessageSchema,
|
||||
AgentDeletedMessageSchema,
|
||||
AgentArchivedMessageSchema,
|
||||
GitDiffResponseSchema,
|
||||
CheckoutStatusResponseSchema,
|
||||
SubscribeCheckoutDiffResponseSchema,
|
||||
CheckoutDiffUpdateSchema,
|
||||
@@ -1847,7 +1813,6 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
||||
ValidateBranchResponseSchema,
|
||||
PaseoWorktreeListResponseSchema,
|
||||
PaseoWorktreeArchiveResponseSchema,
|
||||
HighlightedDiffResponseSchema,
|
||||
FileExplorerResponseSchema,
|
||||
ProjectIconResponseSchema,
|
||||
FileDownloadTokenResponseSchema,
|
||||
@@ -1944,8 +1909,6 @@ export type SetAgentModeRequestMessage = z.infer<typeof SetAgentModeRequestMessa
|
||||
export type SetAgentModelRequestMessage = z.infer<typeof SetAgentModelRequestMessageSchema>;
|
||||
export type SetAgentThinkingRequestMessage = z.infer<typeof SetAgentThinkingRequestMessageSchema>;
|
||||
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 CheckoutStatusResponse = z.infer<typeof CheckoutStatusResponseSchema>;
|
||||
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 PaseoWorktreeArchiveRequest = z.infer<typeof PaseoWorktreeArchiveRequestSchema>;
|
||||
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 FileExplorerResponse = z.infer<typeof FileExplorerResponseSchema>;
|
||||
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 { 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 { tmpdir } from "os";
|
||||
import {
|
||||
commitAll,
|
||||
getCheckoutDiff,
|
||||
getPullRequestStatus,
|
||||
getCheckoutStatus,
|
||||
getCheckoutStatusLite,
|
||||
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 () => {
|
||||
const binaryPath = join(repoDir, "blob.bin");
|
||||
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`);
|
||||
});
|
||||
|
||||
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 () => {
|
||||
const conflictFile = join(repoDir, "conflict.txt");
|
||||
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;
|
||||
|
||||
async function tryGetNumstat(
|
||||
cwd: string,
|
||||
args: string[]
|
||||
): Promise<FileStat> {
|
||||
try {
|
||||
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;
|
||||
function normalizeNumstatPath(pathField: string): string {
|
||||
const braceRenameMatch = pathField.match(/^(.*)\{(.*) => (.*)\}(.*)$/);
|
||||
if (braceRenameMatch) {
|
||||
const [, prefix, , renamed, suffix] = braceRenameMatch;
|
||||
return `${prefix}${renamed}${suffix}`;
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -784,49 +830,32 @@ function buildPlaceholderParsedDiffFile(
|
||||
};
|
||||
}
|
||||
|
||||
async function getPerFileDiffText(
|
||||
async function getUntrackedDiffText(
|
||||
cwd: string,
|
||||
ref: string,
|
||||
change: CheckoutFileChange
|
||||
): Promise<{ text: string; truncated: boolean; stat: FileStat }> {
|
||||
if (change.isUntracked) {
|
||||
try {
|
||||
const inspected = await inspectUntrackedFile(cwd, change.path);
|
||||
if (inspected.stat?.isBinary || inspected.truncated) {
|
||||
return { text: "", truncated: inspected.truncated, stat: inspected.stat };
|
||||
}
|
||||
} catch {
|
||||
// Fall through to git diff path if metadata probing fails.
|
||||
try {
|
||||
const inspected = await inspectUntrackedFile(cwd, change.path);
|
||||
if (inspected.stat?.isBinary || inspected.truncated) {
|
||||
return { text: "", truncated: inspected.truncated, stat: inspected.stat };
|
||||
}
|
||||
|
||||
const result = await spawnLimitedText({
|
||||
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 };
|
||||
} catch {
|
||||
// Fall through to git diff path if metadata probing fails.
|
||||
}
|
||||
|
||||
const result = await spawnLimitedText({
|
||||
cmd: "git",
|
||||
args: ["diff", ref, "--", change.path],
|
||||
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 };
|
||||
return {
|
||||
text: result.text,
|
||||
truncated: result.truncated,
|
||||
stat: { additions: 0, deletions: 0, isBinary: false },
|
||||
};
|
||||
}
|
||||
|
||||
export async function getCheckoutStatus(
|
||||
@@ -966,8 +995,111 @@ export async function getCheckoutDiff(
|
||||
}
|
||||
};
|
||||
|
||||
for (const change of changes) {
|
||||
const { text, truncated, stat } = await getPerFileDiffText(cwd, refForDiff, change);
|
||||
const trackedChanges = changes.filter((change) => !change.isUntracked);
|
||||
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 (stat?.isBinary) {
|
||||
@@ -977,9 +1109,6 @@ export async function getCheckoutDiff(
|
||||
} else {
|
||||
appendDiff(text);
|
||||
}
|
||||
if (diffBytes >= TOTAL_DIFF_MAX_BYTES) {
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -1268,6 +1397,11 @@ export interface PullRequestStatus {
|
||||
headRefName: string;
|
||||
}
|
||||
|
||||
export interface PullRequestStatusResult {
|
||||
status: PullRequestStatus | null;
|
||||
githubFeaturesEnabled: boolean;
|
||||
}
|
||||
|
||||
async function ensureGhAvailable(cwd: string): Promise<void> {
|
||||
try {
|
||||
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> {
|
||||
try {
|
||||
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 };
|
||||
}
|
||||
|
||||
export async function getPullRequestStatus(cwd: string): Promise<PullRequestStatus | null> {
|
||||
export async function getPullRequestStatus(cwd: string): Promise<PullRequestStatusResult> {
|
||||
await requireGitRepo(cwd);
|
||||
await ensureGhAvailable(cwd);
|
||||
const repo = await resolveGitHubRepo(cwd);
|
||||
const head = await getCurrentBranch(cwd);
|
||||
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 { stdout } = await execFileAsync(
|
||||
"gh",
|
||||
[
|
||||
"api",
|
||||
`repos/${repo}/pulls`,
|
||||
"-X",
|
||||
"GET",
|
||||
"-F",
|
||||
`head=${owner}:${head}`,
|
||||
"-F",
|
||||
"state=open",
|
||||
],
|
||||
{ cwd }
|
||||
);
|
||||
let stdout: string;
|
||||
try {
|
||||
({ stdout } = await execFileAsync(
|
||||
"gh",
|
||||
[
|
||||
"api",
|
||||
`repos/${repo}/pulls`,
|
||||
"-X",
|
||||
"GET",
|
||||
"-F",
|
||||
`head=${owner}:${head}`,
|
||||
"-F",
|
||||
"state=open",
|
||||
],
|
||||
{ cwd }
|
||||
));
|
||||
} catch (error) {
|
||||
if (isGhAuthError(error)) {
|
||||
return {
|
||||
status: null,
|
||||
githubFeaturesEnabled: false,
|
||||
};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const parsed = JSON.parse(stdout.trim());
|
||||
const current = Array.isArray(parsed) && parsed.length > 0 ? parsed[0] : null;
|
||||
if (!current) {
|
||||
return null;
|
||||
return {
|
||||
status: null,
|
||||
githubFeaturesEnabled: true,
|
||||
};
|
||||
}
|
||||
return {
|
||||
url: current.html_url ?? current.url,
|
||||
title: current.title,
|
||||
state: current.state,
|
||||
baseRefName: current.base?.ref ?? "",
|
||||
headRefName: current.head?.ref ?? head,
|
||||
status: {
|
||||
url: current.html_url ?? current.url,
|
||||
title: current.title,
|
||||
state: current.state,
|
||||
baseRefName: current.base?.ref ?? "",
|
||||
headRefName: current.head?.ref ?? head,
|
||||
},
|
||||
githubFeaturesEnabled: true,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user