fix(desktop): wait for first-run bootstrap before connection timeout
This commit is contained in:
@@ -8,7 +8,12 @@ import { translateNow } from '@/i18n'
|
||||
import { desktopDefaultCwd } from '@/lib/desktop-fs'
|
||||
import { decideLivenessForceClose, LIVENESS_REPROBE_DELAY_MS } from '@/lib/gateway-liveness-policy'
|
||||
import { reconnectBackoffDelayMs } from '@/lib/reconnect-backoff'
|
||||
import { BACKEND_BOOT_WAIT_TIMEOUT_MS, RECONNECT_ATTEMPT_TIMEOUT_MS, withTimeout } from '@/lib/with-timeout'
|
||||
import {
|
||||
BACKEND_BOOT_WAIT_TIMEOUT_MS,
|
||||
RECONNECT_ATTEMPT_TIMEOUT_MS,
|
||||
withBootstrapAwareTimeout,
|
||||
withTimeout
|
||||
} from '@/lib/with-timeout'
|
||||
import {
|
||||
$desktopBoot,
|
||||
applyDesktopBootProgress,
|
||||
@@ -988,10 +993,12 @@ export function useGatewayBoot({
|
||||
// round-trip must not hang "Starting Hermes…" forever. Initial boot
|
||||
// rides out a full backend cold spawn, so it gets the shared 45s
|
||||
// backend-boot budget, not the 20s reconnect budget.
|
||||
const conn = await withTimeout(
|
||||
const conn = await withBootstrapAwareTimeout(
|
||||
desktop.getConnection(windowProfileOverride() ?? undefined),
|
||||
BACKEND_BOOT_WAIT_TIMEOUT_MS,
|
||||
'Timed out connecting to Hermes backend'
|
||||
'Timed out connecting to Hermes backend',
|
||||
desktop.getBootstrapState ? () => desktop.getBootstrapState!() : undefined,
|
||||
() => cancelled
|
||||
)
|
||||
|
||||
if (cancelled) {
|
||||
|
||||
@@ -15,6 +15,7 @@ import type {
|
||||
DesktopBootstrapState
|
||||
} from '@/global'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { aiturkInstallStage } from '@/i18n/aiturk'
|
||||
import { AlertCircle, ChevronDown, ChevronRight, Globe, iconSize, Loader2, Monitor } from '@/lib/icons'
|
||||
import { capitalize } from '@/lib/text'
|
||||
import { cn } from '@/lib/utils'
|
||||
@@ -59,6 +60,10 @@ interface StageRowProps {
|
||||
}
|
||||
|
||||
function formatStageName(name: string): string {
|
||||
const translated = aiturkInstallStage(name)
|
||||
if (translated) {
|
||||
return translated
|
||||
}
|
||||
// 'system-packages' -> 'System packages'; 'uv' stays 'uv'
|
||||
if (name.length <= 3) {
|
||||
return name
|
||||
|
||||
@@ -270,6 +270,12 @@ export function DesktopOnboardingOverlay({
|
||||
// do we know whether to dismiss (true) or surface the picker (false).
|
||||
// EXCEPTION: manual mode (user opened the selector from a working app to
|
||||
// add/switch a provider) shows the overlay regardless of configured state.
|
||||
// The local installer owns first-run choice and download progress. Provider
|
||||
// onboarding becomes meaningful after that runtime is available.
|
||||
if (boot.running && boot.phase === 'bootstrap.choice' && !onboarding.manual) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (onboarding.configured === true && !onboarding.manual) {
|
||||
return null
|
||||
}
|
||||
@@ -446,7 +452,13 @@ export function Picker({ ctx }: { ctx: OnboardingContext }) {
|
||||
const ordered = useMemo(() => (providers ? sortProviders(providers) : []), [providers])
|
||||
const hasOauth = ordered.length > 0
|
||||
const apiKeyOptions = useApiKeyCatalog()
|
||||
const aiturkProvider = <AiturkProvider onConnected={async () => { await refreshOnboarding(ctx) }} />
|
||||
const aiturkProvider = (
|
||||
<AiturkProvider
|
||||
onConnected={async () => {
|
||||
await refreshOnboarding(ctx)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
||||
// localEndpoint forces the key form regardless of `mode` (which a manual
|
||||
// provider refresh may flip back to 'oauth'); it preselects the local option
|
||||
@@ -674,7 +686,9 @@ export function ApiKeyForm({
|
||||
type="button"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-sm font-medium">{o.id === 'local' ? t.settings.providers.localEndpoint.title : o.name}</span>
|
||||
<span className="text-sm font-medium">
|
||||
{o.id === 'local' ? t.settings.providers.localEndpoint.title : o.name}
|
||||
</span>
|
||||
{isSet?.(o.envKey) ? <Check className="size-3.5 text-muted-foreground" /> : null}
|
||||
</div>
|
||||
{(t.onboarding.apiKeyOptions[o.id]?.short ?? o.short) ? (
|
||||
|
||||
+103
-24
@@ -1,45 +1,124 @@
|
||||
import { useI18n } from './context'
|
||||
import { getRuntimeI18nLocale } from './runtime'
|
||||
|
||||
const trInstallStages: Record<string, string> = {
|
||||
uv: 'uv',
|
||||
git: 'Git',
|
||||
node: 'Node.js',
|
||||
'system-packages': 'Sistem araçları',
|
||||
repository: 'Kaynak kodu',
|
||||
python: 'Python',
|
||||
venv: 'Python ortamı',
|
||||
dependencies: 'Ajan bağımlılıkları',
|
||||
'node-deps': 'Node.js bağımlılıkları',
|
||||
path: 'Komut yolları',
|
||||
'config-templates': 'Ayar şablonları',
|
||||
'platform-sdks': 'Platform araçları',
|
||||
'bootstrap-marker': 'Kurulum doğrulaması',
|
||||
configure: 'Yapılandırma',
|
||||
gateway: 'Ağ geçidi'
|
||||
}
|
||||
|
||||
export function aiturkInstallStage(name: string): string | undefined {
|
||||
return getRuntimeI18nLocale() === 'tr' ? trInstallStages[name] : undefined
|
||||
}
|
||||
|
||||
const en = {
|
||||
intro: 'Bring your code, question, or idea. Build with AITURK.',
|
||||
pitch: 'Connect your models with your TurkServis account.', key: 'TurkServis API key',
|
||||
getKey: 'My account and API keys', noModels: 'No models are available for this key.',
|
||||
pitch: 'Connect your models with your TurkServis account.',
|
||||
key: 'TurkServis API key',
|
||||
getKey: 'My account and API keys',
|
||||
noModels: 'No models are available for this key.',
|
||||
failed: 'Could not connect to TurkServis.',
|
||||
custom: {
|
||||
title: 'Custom endpoints', active: 'Active', keySet: 'API key set', use: 'Use', delete: 'Delete endpoint',
|
||||
empty: 'No custom endpoints', emptyDesc: 'Add an OpenAI-compatible endpoint below.', edit: 'Edit endpoint', add: 'Add endpoint',
|
||||
name: 'Name', providerId: 'Provider ID', url: 'Endpoint URL', model: 'Default model', context: 'Context', auto: 'Auto',
|
||||
apiKey: 'API key', keepKey: 'Leave blank to keep current key', optional: 'Optional', newChats: 'Use for new chats',
|
||||
discover: 'Discover models', test: 'Test', newEndpoint: 'New endpoint', loadFailed: 'Could not load custom endpoints',
|
||||
saved: 'Custom endpoint saved.', saveFailed: 'Save failed', reachable: 'Endpoint is reachable.', validationFailed: 'Endpoint validation failed.',
|
||||
activationFailed: 'Activation failed', deleteFailed: 'Delete failed', preset: 'Use TurkServis settings',
|
||||
found: (n: number) => `Endpoint is reachable. Found ${n} models.`, confirmDelete: (name: string) => `Delete ${name}?`
|
||||
title: 'Custom endpoints',
|
||||
active: 'Active',
|
||||
keySet: 'API key set',
|
||||
use: 'Use',
|
||||
delete: 'Delete endpoint',
|
||||
empty: 'No custom endpoints',
|
||||
emptyDesc: 'Add an OpenAI-compatible endpoint below.',
|
||||
edit: 'Edit endpoint',
|
||||
add: 'Add endpoint',
|
||||
name: 'Name',
|
||||
providerId: 'Provider ID',
|
||||
url: 'Endpoint URL',
|
||||
model: 'Default model',
|
||||
context: 'Context',
|
||||
auto: 'Auto',
|
||||
apiKey: 'API key',
|
||||
keepKey: 'Leave blank to keep current key',
|
||||
optional: 'Optional',
|
||||
newChats: 'Use for new chats',
|
||||
discover: 'Discover models',
|
||||
test: 'Test',
|
||||
newEndpoint: 'New endpoint',
|
||||
loadFailed: 'Could not load custom endpoints',
|
||||
saved: 'Custom endpoint saved.',
|
||||
saveFailed: 'Save failed',
|
||||
reachable: 'Endpoint is reachable.',
|
||||
validationFailed: 'Endpoint validation failed.',
|
||||
activationFailed: 'Activation failed',
|
||||
deleteFailed: 'Delete failed',
|
||||
preset: 'Use TurkServis settings',
|
||||
found: (n: number) => `Endpoint is reachable. Found ${n} models.`,
|
||||
confirmDelete: (name: string) => `Delete ${name}?`
|
||||
},
|
||||
about: 'TurkServis distribution based on Hermes Agent. Upstream: Nous Research, MIT license.',
|
||||
downloads: 'Official downloads and updates', source: 'Source code and license',
|
||||
downloads: 'Official downloads and updates',
|
||||
source: 'Source code and license',
|
||||
uninstallTitle: 'Uninstall AITURK IDE',
|
||||
uninstallDescription: 'Use your operating system’s application manager to uninstall AITURK IDE. Your separate agent data folder is preserved.'
|
||||
uninstallDescription:
|
||||
'Use your operating system’s application manager to uninstall AITURK IDE. Your separate agent data folder is preserved.'
|
||||
}
|
||||
|
||||
const tr: typeof en = {
|
||||
intro: 'Kodunuzu, sorunuzu veya fikrinizi paylaşın. AITURK ile birlikte geliştirin.',
|
||||
pitch: 'TurkServis hesabınızla modellerinize bağlanın.', key: 'TurkServis API anahtarı',
|
||||
getKey: 'Hesabım ve API anahtarlarım', noModels: 'Bu anahtarla kullanılabilecek model bulunamadı.',
|
||||
pitch: 'TurkServis hesabınızla modellerinize bağlanın.',
|
||||
key: 'TurkServis API anahtarı',
|
||||
getKey: 'Hesabım ve API anahtarlarım',
|
||||
noModels: 'Bu anahtarla kullanılabilecek model bulunamadı.',
|
||||
failed: 'TurkServis bağlantısı kurulamadı.',
|
||||
custom: {
|
||||
title: 'Özel API bağlantıları', active: 'Etkin', keySet: 'API anahtarı ayarlandı', use: 'Kullan', delete: 'Bağlantıyı sil',
|
||||
empty: 'Özel bağlantı yok', emptyDesc: 'Aşağıdan OpenAI uyumlu bir API bağlantısı ekleyin.', edit: 'Bağlantıyı düzenle', add: 'Bağlantı ekle',
|
||||
name: 'Ad', providerId: 'Sağlayıcı kimliği', url: 'API adresi', model: 'Varsayılan model', context: 'Bağlam', auto: 'Otomatik',
|
||||
apiKey: 'API anahtarı', keepKey: 'Mevcut anahtarı korumak için boş bırakın', optional: 'İsteğe bağlı', newChats: 'Yeni sohbetlerde kullan',
|
||||
discover: 'Modelleri keşfet', test: 'Sına', newEndpoint: 'Yeni bağlantı', loadFailed: 'Özel bağlantılar yüklenemedi',
|
||||
saved: 'Özel bağlantı kaydedildi.', saveFailed: 'Kaydedilemedi', reachable: 'API adresine erişiliyor.', validationFailed: 'Bağlantı doğrulanamadı.',
|
||||
activationFailed: 'Etkinleştirilemedi', deleteFailed: 'Silinemedi', preset: 'TurkServis ayarlarını kullan',
|
||||
found: n => `API adresine erişiliyor. ${n} model bulundu.`, confirmDelete: name => `${name} silinsin mi?`
|
||||
title: 'Özel API bağlantıları',
|
||||
active: 'Etkin',
|
||||
keySet: 'API anahtarı ayarlandı',
|
||||
use: 'Kullan',
|
||||
delete: 'Bağlantıyı sil',
|
||||
empty: 'Özel bağlantı yok',
|
||||
emptyDesc: 'Aşağıdan OpenAI uyumlu bir API bağlantısı ekleyin.',
|
||||
edit: 'Bağlantıyı düzenle',
|
||||
add: 'Bağlantı ekle',
|
||||
name: 'Ad',
|
||||
providerId: 'Sağlayıcı kimliği',
|
||||
url: 'API adresi',
|
||||
model: 'Varsayılan model',
|
||||
context: 'Bağlam',
|
||||
auto: 'Otomatik',
|
||||
apiKey: 'API anahtarı',
|
||||
keepKey: 'Mevcut anahtarı korumak için boş bırakın',
|
||||
optional: 'İsteğe bağlı',
|
||||
newChats: 'Yeni sohbetlerde kullan',
|
||||
discover: 'Modelleri keşfet',
|
||||
test: 'Sına',
|
||||
newEndpoint: 'Yeni bağlantı',
|
||||
loadFailed: 'Özel bağlantılar yüklenemedi',
|
||||
saved: 'Özel bağlantı kaydedildi.',
|
||||
saveFailed: 'Kaydedilemedi',
|
||||
reachable: 'API adresine erişiliyor.',
|
||||
validationFailed: 'Bağlantı doğrulanamadı.',
|
||||
activationFailed: 'Etkinleştirilemedi',
|
||||
deleteFailed: 'Silinemedi',
|
||||
preset: 'TurkServis ayarlarını kullan',
|
||||
found: n => `API adresine erişiliyor. ${n} model bulundu.`,
|
||||
confirmDelete: name => `${name} silinsin mi?`
|
||||
},
|
||||
about: 'Hermes Agent tabanlı TurkServis dağıtımı. Kaynak: Nous Research, MIT lisansı.',
|
||||
downloads: 'Resmî indirmeler ve güncellemeler', source: 'Kaynak kodu ve lisans',
|
||||
downloads: 'Resmî indirmeler ve güncellemeler',
|
||||
source: 'Kaynak kodu ve lisans',
|
||||
uninstallTitle: 'AITURK IDE’yi kaldır',
|
||||
uninstallDescription: 'AITURK IDE’yi işletim sisteminin uygulama yöneticisinden kaldırın. Windows’ta Ayarlar → Uygulamalar → Yüklü uygulamalar yolunu izleyin. Ayrı ajan veri klasörünüz korunur.'
|
||||
uninstallDescription:
|
||||
'AITURK IDE’yi işletim sisteminin uygulama yöneticisinden kaldırın. Windows’ta Ayarlar → Uygulamalar → Yüklü uygulamalar yolunu izleyin. Ayrı ajan veri klasörünüz korunur.'
|
||||
}
|
||||
|
||||
export function useAiturkCopy() {
|
||||
|
||||
@@ -1,8 +1,67 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { withTimeout } from './with-timeout'
|
||||
import { withBootstrapAwareTimeout, withTimeout } from './with-timeout'
|
||||
|
||||
describe('withTimeout', () => {
|
||||
it.each([{ active: true }, { setupChoice: { active: true } }])(
|
||||
'waits beyond cold boot during setup: %j',
|
||||
async state => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
let finish!: (value: string) => void
|
||||
const connection = new Promise<string>(resolve => {
|
||||
finish = resolve
|
||||
})
|
||||
const probe = vi.fn(async () => state)
|
||||
const result = withBootstrapAwareTimeout(connection, 45_000, 'timeout', probe)
|
||||
await vi.advanceTimersByTimeAsync(180_000)
|
||||
expect(probe).toHaveBeenCalledTimes(4)
|
||||
finish('connected')
|
||||
await expect(result).resolves.toBe('connected')
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
it('keeps an ordinary stalled backend bounded', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const result = withBootstrapAwareTimeout(new Promise<never>(() => {}), 45_000, 'timeout', async () => ({
|
||||
active: false
|
||||
}))
|
||||
const rejection = expect(result).rejects.toThrow('timeout')
|
||||
await vi.advanceTimersByTimeAsync(45_000)
|
||||
await rejection
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('bounds a stalled bootstrap IPC probe', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const result = withBootstrapAwareTimeout(
|
||||
new Promise<never>(() => {}),
|
||||
45_000,
|
||||
'timeout',
|
||||
() => new Promise(() => {})
|
||||
)
|
||||
const rejection = expect(result).rejects.toThrow('timeout')
|
||||
await vi.advanceTimersByTimeAsync(50_000)
|
||||
await rejection
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('propagates a real backend failure during setup', async () => {
|
||||
const failure = new Error('runtime install failed')
|
||||
await expect(
|
||||
withBootstrapAwareTimeout(Promise.reject(failure), 45_000, 'timeout', async () => ({ active: true }))
|
||||
).rejects.toBe(failure)
|
||||
})
|
||||
|
||||
it('rejects with an onTimeout exception instead of letting it escape the timer callback', async () => {
|
||||
vi.useFakeTimers()
|
||||
|
||||
|
||||
@@ -28,6 +28,43 @@ export function isTimeoutError(error: unknown): error is TimeoutError {
|
||||
return error instanceof TimeoutError
|
||||
}
|
||||
|
||||
/** First-run downloads and the user's setup choice are outside the normal
|
||||
* backend cold-start budget. Keep one connection request alive while main
|
||||
* reports that work; a stalled IPC probe and ordinary boots remain bounded. */
|
||||
export async function withBootstrapAwareTimeout<T>(
|
||||
promise: Promise<T>,
|
||||
ms: number,
|
||||
message: string,
|
||||
bootstrapState?: () => Promise<{ active?: boolean; setupChoice?: unknown } | null>,
|
||||
cancelled: () => boolean = () => false
|
||||
): Promise<T> {
|
||||
let settled = false
|
||||
const request = promise.then(
|
||||
value => {
|
||||
settled = true
|
||||
return value
|
||||
},
|
||||
error => {
|
||||
settled = true
|
||||
throw error
|
||||
}
|
||||
)
|
||||
|
||||
for (;;) {
|
||||
try {
|
||||
return await withTimeout(request, ms, message)
|
||||
} catch (error) {
|
||||
if (settled || cancelled() || !isTimeoutError(error) || !bootstrapState) {
|
||||
throw error
|
||||
}
|
||||
const state = await withTimeout(bootstrapState(), Math.min(ms, 5_000), message)
|
||||
if (!state?.active && !state?.setupChoice) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Settle with `promise`, or reject with a TimeoutError after `ms`.
|
||||
* `onTimeout` runs synchronously before the rejection is published so callers
|
||||
* can revoke ownership of work that would otherwise keep running unowned. If
|
||||
|
||||
Reference in New Issue
Block a user