From a32502a9feef77447b3b920a4cf27cee71299ea4 Mon Sep 17 00:00:00 2001 From: angelkawai <123734885+luokerenx4@users.noreply.github.com> Date: Wed, 8 Jul 2026 12:26:03 +0800 Subject: [PATCH 1/2] Add onboarding language picker and Electron smoke Merged validated onboarding language picker and packaged Electron onboarding smoke into dev. --- .github/workflows/desktop-package-smoke.yml | 2 + apps/desktop/src/main.ts | 101 ++++++- package.json | 1 + scripts/desktop-packaged-smoke-plan.mjs | 84 ++++++ scripts/desktop-packaged-smoke-plan.spec.ts | 63 ++++ scripts/desktop-packaged-smoke.mjs | 64 +++-- ui/src/components/FirstRunGuide.spec.ts | 6 + ui/src/components/FirstRunGuide.tsx | 303 +++++++++++++------- ui/src/components/first-run-guide-model.ts | 5 +- ui/src/i18n/locales/en.ts | 149 ++++++++++ ui/src/i18n/locales/ja.ts | 149 ++++++++++ ui/src/i18n/locales/zh-Hant.ts | 149 ++++++++++ ui/src/i18n/locales/zh.ts | 149 ++++++++++ ui/src/index.css | 15 +- 14 files changed, 1114 insertions(+), 126 deletions(-) create mode 100644 scripts/desktop-packaged-smoke-plan.mjs create mode 100644 scripts/desktop-packaged-smoke-plan.spec.ts diff --git a/.github/workflows/desktop-package-smoke.yml b/.github/workflows/desktop-package-smoke.yml index 266d4f50..0a8cbace 100644 --- a/.github/workflows/desktop-package-smoke.yml +++ b/.github/workflows/desktop-package-smoke.yml @@ -13,6 +13,7 @@ on: - "pnpm-lock.yaml" - "scripts/assert-desktop-package.mjs" - "scripts/desktop-packaged-smoke.mjs" + - "scripts/desktop-packaged-smoke-plan.mjs" - "scripts/vendor-managed-runtime.mjs" - "src/core/paths.ts" - "src/core/runtime-profile.ts" @@ -31,6 +32,7 @@ on: - "pnpm-lock.yaml" - "scripts/assert-desktop-package.mjs" - "scripts/desktop-packaged-smoke.mjs" + - "scripts/desktop-packaged-smoke-plan.mjs" - "scripts/vendor-managed-runtime.mjs" - "src/core/paths.ts" - "src/core/runtime-profile.ts" diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index a2ede34b..2ce84b53 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -39,6 +39,7 @@ let uta: ChildProcess | null = null let alice: ChildProcess | null = null let appQuitting = false let restartingUTA = false +let rendererOnboardingSmokeStarted = false const DEFAULT_WEB_PORT_START = 47331 const READY_TIMEOUT_MS = 30_000 @@ -362,6 +363,87 @@ async function runRendererPtySmoke(win: BrowserWindow): Promise { console.log(`[guardian] electron smoke pty → ok workspace=${result.workspaceId ?? ''} session=${result.sessionId ?? ''}`) } +async function runRendererOnboardingSmoke(win: BrowserWindow): Promise { + const result = await win.webContents.executeJavaScript(`(async () => { + const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) + const json = async (res) => { + const text = await res.text() + let body = null + try { body = text ? JSON.parse(text) : null } catch { body = text } + if (!res.ok) throw new Error(res.status + ' ' + text) + return body + } + const waitFor = async (label, predicate, timeoutMs = 12000) => { + const deadline = Date.now() + timeoutMs + let last = null + while (Date.now() < deadline) { + try { + const value = await predicate() + if (value) return value + } catch (err) { + last = err + } + await sleep(100) + } + throw new Error('Timed out waiting for ' + label + (last ? ': ' + (last.message || String(last)) : '')) + } + const activeStep = () => document + .querySelector('[data-testid="first-run-guide-step"]') + ?.getAttribute('data-onboarding-step') || null + const clickPrimary = () => { + const button = document.querySelector('[data-testid="first-run-guide-primary"]') + if (!button) throw new Error('first-run primary button missing') + button.click() + } + + await waitFor('Electron preload bridge', () => Boolean(window.openAlice?.runtime && window.openAlice?.pty)) + + const agents = await json(await fetch('/api/workspaces/agents')) + const pi = agents.agents?.find((agent) => agent.id === 'pi') + if (!pi?.installed) throw new Error('managed Pi was not detected by packaged /agents') + + const tradingStatus = await json(await fetch('/api/trading/status')) + if (tradingStatus.mode !== 'lite') { + throw new Error('expected fresh onboarding trading mode to be lite, got ' + tradingStatus.mode) + } + + await waitFor('first-run guide', () => document.querySelector('[data-testid="first-run-guide"]')) + await waitFor('language step', () => activeStep() === 'language' ? true : false) + clickPrimary() + await waitFor('welcome step', () => activeStep() === 'lite' ? true : false) + clickPrimary() + await waitFor('AI access step', () => activeStep() === 'ai' ? true : false) + + const mockCredential = await json(await fetch('/api/config/credentials/test', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + wireShape: 'openai-chat', + baseUrl: 'https://onboarding.openalice.test/openai-chat', + apiKey: 'oa_test_ok', + model: 'openalice-onboarding-test', + }), + })) + if (!mockCredential.ok) { + throw new Error('onboarding mock credential failed: ' + (mockCredential.error || 'unknown error')) + } + + clickPrimary() + await waitFor('onboarding credential modal', () => + document.body.innerText.includes('OpenAlice Test Provider') && + Array.from(document.querySelectorAll('input')).some((input) => input.value === 'oa_test_ok') + ) + + return { + ok: true, + step: activeStep(), + piPath: pi.binPath || null, + tradingMode: tradingStatus.mode, + } + })()`, true) as { ok?: boolean; step?: string; piPath?: string | null; tradingMode?: string } + console.log(`[guardian] electron smoke onboarding → ok step=${result.step ?? ''} mode=${result.tradingMode ?? ''} pi=${result.piPath ?? 'managed'}`) +} + app.whenReady().then(async () => { // Build output lives at /dist/electron/main.js, /dist/main.js // (Alice), and /services/uta/dist/uta.js (UTA). The desktop package @@ -605,6 +687,20 @@ app.whenReady().then(async () => { } }) } + if (process.env['OPENALICE_ELECTRON_SMOKE_ONBOARDING'] === '1' && !rendererOnboardingSmokeStarted) { + rendererOnboardingSmokeStarted = true + void runRendererOnboardingSmoke(win) + .then(() => { + if (process.env['OPENALICE_ELECTRON_SMOKE_EXIT'] === '1') shutdown() + }) + .catch((err) => { + console.error(`[guardian] electron smoke onboarding → failed: ${err instanceof Error ? err.message : String(err)}`) + if (process.env['OPENALICE_ELECTRON_SMOKE_EXIT'] === '1') { + process.exitCode = 1 + shutdown() + } + }) + } }) .catch((err) => { console.error(`[guardian] renderer bridge probe failed: ${err instanceof Error ? err.message : String(err)}`) @@ -688,7 +784,10 @@ async function stopChildren(): Promise { /** Cascade tree-kill both children, then exit once they're gone. */ function shutdown(): void { if (appQuitting) return - void stopChildren().finally(() => app.exit(0)) + void stopChildren().finally(() => { + const exitCode = typeof process.exitCode === 'number' ? process.exitCode : 0 + app.exit(exitCode) + }) } app.on('before-quit', (e) => { diff --git a/package.json b/package.json index 54422b5d..fcff4e13 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "electron:pack": "pnpm electron:build && pnpm vendor:runtime && pnpm -F @traderalice/desktop pack", "electron:assert-package": "node scripts/assert-desktop-package.mjs", "electron:smoke:packaged": "node scripts/desktop-packaged-smoke.mjs", + "electron:smoke:onboarding": "node scripts/desktop-packaged-smoke.mjs --onboarding", "electron:smoke:pty": "node scripts/desktop-pty-smoke.mjs", "vendor:runtime": "node scripts/vendor-managed-runtime.mjs", "test": "vitest run", diff --git a/scripts/desktop-packaged-smoke-plan.mjs b/scripts/desktop-packaged-smoke-plan.mjs new file mode 100644 index 00000000..80c6f4b0 --- /dev/null +++ b/scripts/desktop-packaged-smoke-plan.mjs @@ -0,0 +1,84 @@ +import { randomUUID } from 'node:crypto' + +export const DESKTOP_PACKAGED_SMOKE_ARGS = new Set([ + '--skip-build', + '--skip-pack', + '--keep', + '--temp-data', + '--real-data', + '--signed', + '--onboarding', + '--help', + '-h', +]) + +export function buildDesktopPackagedSmokePlan(argv, env = process.env, opts = {}) { + const args = new Set(argv) + const unknownArgs = [...args].filter((arg) => !DESKTOP_PACKAGED_SMOKE_ARGS.has(arg)) + const onboarding = args.has('--onboarding') + const realDataFlag = args.has('--real-data') + const tempDataFlag = args.has('--temp-data') + const errors = [] + const warnings = [] + + if (unknownArgs.length > 0) { + errors.push(`[desktop-smoke] unknown option(s): ${unknownArgs.join(', ')}`) + } + if (tempDataFlag && realDataFlag) { + errors.push('[desktop-smoke] choose either --temp-data or --real-data, not both') + } + if (onboarding && realDataFlag) { + errors.push('[desktop-smoke] --onboarding always uses isolated temp data; drop --real-data') + } + + const skipBuild = args.has('--skip-build') + const skipPack = args.has('--skip-pack') + if (onboarding && skipBuild) { + warnings.push('[desktop-smoke] --onboarding with --skip-build assumes ui/dist was already built with first-run guide flags') + } + if (onboarding && skipPack) { + warnings.push('[desktop-smoke] --onboarding with --skip-pack assumes the packaged app already contains that onboarding-enabled ui/dist') + } + + const tempData = onboarding || tempDataFlag + const realData = !tempData + const storageSuffix = env['VITE_OPENALICE_ONBOARDING_STORAGE_SUFFIX']?.trim() || opts.randomUUID?.() || randomUUID() + const onboardingBuildEnv = onboarding ? { + VITE_OPENALICE_FIRST_RUN_GUIDE: '1', + VITE_OPENALICE_ONBOARDING_TEST: '1', + VITE_OPENALICE_CREDENTIAL_TEST_MODE: 'mock', + VITE_OPENALICE_ONBOARDING_STORAGE_SUFFIX: storageSuffix, + } : {} + const onboardingLaunchEnv = onboarding ? { + ...onboardingBuildEnv, + OPENALICE_ONBOARDING_TEST: '1', + OPENALICE_CREDENTIAL_TEST_MODE: 'mock', + OPENALICE_AGENT_RUNTIME_INSTALLS: 'real', + OPENALICE_MCP_ENABLED: '0', + OPENALICE_ELECTRON_SMOKE_ONBOARDING: '1', + OPENALICE_ELECTRON_SMOKE_EXIT: '1', + } : {} + const unsetLaunchEnv = onboarding ? [ + 'OPENALICE_TRADING_MODE', + 'OPENALICE_LITE_MODE', + 'OPENALICE_UTA_DISABLED', + ] : [] + + return { + errors, + warnings, + options: { + help: args.has('--help') || args.has('-h'), + keep: args.has('--keep'), + onboarding, + realData, + signed: args.has('--signed'), + skipBuild, + skipPack, + tempData, + }, + buildEnv: onboardingBuildEnv, + launchEnv: onboardingLaunchEnv, + unsetLaunchEnv, + } +} diff --git a/scripts/desktop-packaged-smoke-plan.spec.ts b/scripts/desktop-packaged-smoke-plan.spec.ts new file mode 100644 index 00000000..93229d8b --- /dev/null +++ b/scripts/desktop-packaged-smoke-plan.spec.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'vitest' + +import { buildDesktopPackagedSmokePlan } from './desktop-packaged-smoke-plan.mjs' + +describe('buildDesktopPackagedSmokePlan', () => { + it('keeps the default packaged smoke on real data', () => { + const plan = buildDesktopPackagedSmokePlan([], {}, { randomUUID: () => 'fixed' }) + + expect(plan.errors).toEqual([]) + expect(plan.options).toMatchObject({ + onboarding: false, + realData: true, + tempData: false, + }) + expect(plan.buildEnv).toEqual({}) + expect(plan.launchEnv).toEqual({}) + }) + + it('makes onboarding smoke isolated and deterministic', () => { + const plan = buildDesktopPackagedSmokePlan(['--onboarding'], { + OPENALICE_TRADING_MODE: 'pro', + OPENALICE_LITE_MODE: '1', + }, { randomUUID: () => 'fixed-onboarding' }) + + expect(plan.errors).toEqual([]) + expect(plan.options).toMatchObject({ + onboarding: true, + realData: false, + tempData: true, + }) + expect(plan.buildEnv).toMatchObject({ + VITE_OPENALICE_FIRST_RUN_GUIDE: '1', + VITE_OPENALICE_ONBOARDING_TEST: '1', + VITE_OPENALICE_CREDENTIAL_TEST_MODE: 'mock', + VITE_OPENALICE_ONBOARDING_STORAGE_SUFFIX: 'fixed-onboarding', + }) + expect(plan.launchEnv).toMatchObject({ + OPENALICE_ONBOARDING_TEST: '1', + OPENALICE_CREDENTIAL_TEST_MODE: 'mock', + OPENALICE_AGENT_RUNTIME_INSTALLS: 'real', + OPENALICE_MCP_ENABLED: '0', + OPENALICE_ELECTRON_SMOKE_ONBOARDING: '1', + OPENALICE_ELECTRON_SMOKE_EXIT: '1', + }) + expect(plan.unsetLaunchEnv).toEqual([ + 'OPENALICE_TRADING_MODE', + 'OPENALICE_LITE_MODE', + 'OPENALICE_UTA_DISABLED', + ]) + }) + + it('rejects onboarding against real user data', () => { + const plan = buildDesktopPackagedSmokePlan(['--onboarding', '--real-data']) + + expect(plan.errors).toContain('[desktop-smoke] --onboarding always uses isolated temp data; drop --real-data') + }) + + it('rejects contradictory data flags', () => { + const plan = buildDesktopPackagedSmokePlan(['--temp-data', '--real-data']) + + expect(plan.errors).toContain('[desktop-smoke] choose either --temp-data or --real-data, not both') + }) +}) diff --git a/scripts/desktop-packaged-smoke.mjs b/scripts/desktop-packaged-smoke.mjs index cf0ea20c..c4fc1633 100644 --- a/scripts/desktop-packaged-smoke.mjs +++ b/scripts/desktop-packaged-smoke.mjs @@ -1,20 +1,14 @@ #!/usr/bin/env node import { spawn, spawnSync } from 'node:child_process' import { existsSync, mkdtempSync, rmSync } from 'node:fs' +import { createServer } from 'node:net' import { homedir, tmpdir } from 'node:os' import { delimiter, join, resolve } from 'node:path' +import { buildDesktopPackagedSmokePlan } from './desktop-packaged-smoke-plan.mjs' const repoRoot = resolve(import.meta.dirname, '..') -const args = new Set(process.argv.slice(2)) -const skipBuild = args.has('--skip-build') -const skipPack = args.has('--skip-pack') -const keep = args.has('--keep') -const tempData = args.has('--temp-data') -const realDataFlag = args.has('--real-data') -const signed = args.has('--signed') -const help = args.has('--help') || args.has('-h') -const knownArgs = new Set(['--skip-build', '--skip-pack', '--keep', '--temp-data', '--real-data', '--signed', '--help', '-h']) -const unknownArgs = [...args].filter((arg) => !knownArgs.has(arg)) +const plan = buildDesktopPackagedSmokePlan(process.argv.slice(2), process.env) +const { keep, onboarding, realData, signed, skipBuild, skipPack } = plan.options function printHelp() { console.log(`Usage: pnpm electron:smoke:packaged [options] @@ -26,29 +20,26 @@ Options: --skip-pack Reuse the existing dist/electron-app/OpenAlice.app --temp-data Use isolated temporary data/workspace/global stores --real-data Use real data explicitly (default; kept for compatibility) + --onboarding Build with first-run guide enabled, use temp data, run an + automated renderer onboarding smoke, then exit --signed Allow local macOS code signing (default disables it) --keep Keep the temporary smoke data directory after the app exits -h, --help Show this help `) } -if (help) { +if (plan.options.help) { printHelp() process.exit(0) } -if (unknownArgs.length > 0) { - console.error(`[desktop-smoke] unknown option(s): ${unknownArgs.join(', ')}`) +if (plan.errors.length > 0) { + for (const error of plan.errors) console.error(error) printHelp() process.exit(1) } -if (tempData && realDataFlag) { - console.error('[desktop-smoke] choose either --temp-data or --real-data, not both') - process.exit(1) -} - -const realData = !tempData +for (const warning of plan.warnings) console.warn(warning) function run(label, command, commandArgs, extraEnv = {}) { console.log(`\n[desktop-smoke] ${label}`) @@ -69,19 +60,36 @@ function findPackagedApp() { return candidates.find((p) => existsSync(join(p, 'Contents', 'MacOS', 'OpenAlice'))) ?? null } +function getAvailablePort() { + return new Promise((resolve, reject) => { + const server = createServer() + server.unref() + server.on('error', reject) + server.listen(0, '127.0.0.1', () => { + const address = server.address() + const port = typeof address === 'object' && address ? address.port : null + server.close((err) => { + if (err) reject(err) + else if (port) resolve(port) + else reject(new Error('unable to allocate a temporary port')) + }) + }) + }) +} + if (process.platform !== 'darwin') { console.error('[desktop-smoke] packaged .app smoke currently runs on macOS only') process.exit(1) } -if (!skipBuild) run('build desktop bundle', 'pnpm', ['electron:build']) +if (!skipBuild) run('build desktop bundle', 'pnpm', ['electron:build'], plan.buildEnv) if (!skipPack) { run('vendor managed runtime', 'pnpm', ['vendor:runtime']) run( signed ? 'pack signed app directory' : 'pack unsigned app directory', 'pnpm', ['-F', '@traderalice/desktop', 'run', 'pack'], - signed ? {} : { CSC_IDENTITY_AUTO_DISCOVERY: 'false' }, + signed ? plan.buildEnv : { ...plan.buildEnv, CSC_IDENTITY_AUTO_DISCOVERY: 'false' }, ) } @@ -105,10 +113,17 @@ const pathAdditions = [ const env = { ...process.env, + ...plan.launchEnv, PATH: [process.env['PATH'], ...pathAdditions].filter(Boolean).join(delimiter), OPENALICE_EXTRA_AGENT_PATH: pathAdditions.join(delimiter), } +for (const key of plan.unsetLaunchEnv) delete env[key] + +if (onboarding) { + env.OPENALICE_UTA_PORT = String(await getAvailablePort()) +} + if (!realData && smokeHome && smokeWorkspaces && smokeGlobal) { env.OPENALICE_HOME = smokeHome env.AQ_LAUNCHER_ROOT = smokeWorkspaces @@ -124,7 +139,12 @@ if (realData) { console.log(`[desktop-smoke] workspaces: ${smokeWorkspaces}`) console.log(`[desktop-smoke] global provider keys: ${smokeGlobal}`) } -console.log('[desktop-smoke] close the app window or press Ctrl-C here to stop') +if (onboarding) { + console.log('[desktop-smoke] onboarding smoke: enabled; app exits automatically after the renderer probe') + console.log(`[desktop-smoke] onboarding UTA port: ${env.OPENALICE_UTA_PORT}`) +} else { + console.log('[desktop-smoke] close the app window or press Ctrl-C here to stop') +} const child = spawn(join(appPath, 'Contents', 'MacOS', 'OpenAlice'), [], { cwd: repoRoot, diff --git a/ui/src/components/FirstRunGuide.spec.ts b/ui/src/components/FirstRunGuide.spec.ts index a7a2d06c..eace78ab 100644 --- a/ui/src/components/FirstRunGuide.spec.ts +++ b/ui/src/components/FirstRunGuide.spec.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import { + FIRST_RUN_STEP_KEYS, buildFirstRunGuideAccess, buildFirstRunGuideModel, parseFirstRunStepOverride, @@ -125,12 +126,17 @@ describe('buildFirstRunGuideModel', () => { }) describe('parseFirstRunStepOverride', () => { + it('starts onboarding with language choice', () => { + expect(FIRST_RUN_STEP_KEYS[0]).toBe('language') + }) + it('only accepts onboarding step overrides in onboarding test mode', () => { expect(parseFirstRunStepOverride('?onboardingStep=broker', false)).toBeNull() expect(parseFirstRunStepOverride('?onboardingStep=broker', true)).toBe('broker') }) it('supports short aliases for faster design checks', () => { + expect(parseFirstRunStepOverride('?step=locale', true)).toBe('language') expect(parseFirstRunStepOverride('?step=runtime', true)).toBe('ai') expect(parseFirstRunStepOverride('?onboardingStep=uta', true)).toBe('broker') expect(parseFirstRunStepOverride('?step=checklist', true)).toBe('finish') diff --git a/ui/src/components/FirstRunGuide.tsx b/ui/src/components/FirstRunGuide.tsx index 35053968..5f5bc07f 100644 --- a/ui/src/components/FirstRunGuide.tsx +++ b/ui/src/components/FirstRunGuide.tsx @@ -1,4 +1,5 @@ import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react' +import { useTranslation } from 'react-i18next' import { AlertTriangle, ArrowRight, @@ -8,6 +9,7 @@ import { Compass, GitBranch, KeyRound, + Languages, Lock, MousePointerClick, ShieldCheck, @@ -31,6 +33,8 @@ import { useWorkspaces } from '../contexts/workspaces-context' import { useTradingMode } from '../live/trading-mode' import { useWorkspace } from '../tabs/store' import { isApiKeyPreset } from '../lib/presetHelpers' +import { LOCALE_LABELS, useLocale, useSetLocale } from '../i18n/useLocale' +import type { AppLocale } from '../lib/intl' const BASE_DISMISS_KEY = 'openalice.onboarding.firstRunGuide.dismissed.v3' const STORAGE_SUFFIX = import.meta.env.VITE_OPENALICE_ONBOARDING_STORAGE_SUFFIX?.trim() @@ -76,6 +80,7 @@ interface GuideState { type StepDirection = 'forward' | 'back' type RowTone = 'ready' | 'attention' | 'muted' +const FIRST_RUN_LOCALES: AppLocale[] = ['en', 'zh', 'ja', 'zh-Hant'] const INITIAL_GUIDE_STATE: GuideState = { credentials: [], @@ -97,6 +102,9 @@ async function fetchGuideState(): Promise { } export function FirstRunGuide() { + const { t } = useTranslation() + const locale = useLocale() + const setLocale = useSetLocale() const { agents } = useWorkspaces() const openOrFocus = useWorkspace((s) => s.openOrFocus) const setTradingMode = useTradingMode((s) => s.setMode) @@ -236,9 +244,9 @@ export function FirstRunGuide() { await setTradingMode(mode) await refreshGuideState() } catch (err) { - setModeChoiceError(err instanceof Error ? err.message : 'Failed to save trading mode') + setModeChoiceError(err instanceof Error ? err.message : t('firstRunGuide.error.saveTradingMode')) } - }, [model.mode, refreshGuideState, setTradingMode, state.tradingStatus?.envLocked]) + }, [model.mode, refreshGuideState, setTradingMode, state.tradingStatus?.envLocked, t]) const saveCreatedUTA = useCallback(async (uta: Omit) => { const created = await tradingApi.createUTA(uta) @@ -250,114 +258,138 @@ export function FirstRunGuide() { const steps = useMemo(() => { const canStartWorkspace = model.hasUsableAiChain - const modeLabel = capitalize(model.mode) + const modeLabel = model.mode === 'readonly' + ? t('firstRunGuide.mode.readonly') + : model.mode === 'pro' + ? t('firstRunGuide.mode.pro') + : t('firstRunGuide.mode.lite') + const modeAccessLabel = model.mode === 'lite' + ? t('firstRunGuide.finish.noBrokerAccess') + : t('firstRunGuide.finish.modeBrokerAccess', { mode: modeLabel }) const brokerPrimary = model.needsUTASetup - ? 'Connect broker account' + ? t('firstRunGuide.action.connectBroker') : model.mode === 'lite' - ? 'Continue without UTA' - : `Continue with ${modeLabel}` + ? t('firstRunGuide.action.continueWithoutUTA') + : t('firstRunGuide.action.continueWithMode', { mode: modeLabel }) const brokerSecondary = model.needsUTASetup - ? 'Continue without UTA' + ? t('firstRunGuide.action.continueWithoutUTA') : model.mode === 'lite' - ? 'Choose later' - : 'Skip broker setup' + ? t('firstRunGuide.action.chooseLater') + : t('firstRunGuide.action.skipBrokerSetup') const brokerWriteText = model.mode === 'pro' - ? 'Controlled by account permissions.' - : 'Blocked.' - const runtimeText = model.runtimeLabel + ? t('firstRunGuide.broker.writesControlled') + : t('firstRunGuide.broker.writesBlocked') + const installedRuntimeCount = model.runtimeRows.filter((row) => row.installed).length + const runtimeText = model.hasAgentRuntime + ? t('firstRunGuide.ai.runtimeInstalled', { count: installedRuntimeCount }) + : model.hasManagedPi + ? t('firstRunGuide.ai.managedPiMissing') + : t('firstRunGuide.ai.runtimeMissing') const credentialText = model.noCredentials - ? 'No verified AI key yet.' + ? t('firstRunGuide.ai.noVerifiedKey') : model.hasUsableAiChain - ? 'One installed runtime can use a verified key.' - : 'Saved keys do not match an installed runtime yet.' + ? t('firstRunGuide.ai.usableKey') + : t('firstRunGuide.ai.keyMismatch') const aiTitle = model.hasAgentRuntime ? model.hasUsableAiChain - ? 'Alice has a working AI path.' - : 'Connect one runtime to AI access.' - : 'Managed runtime was not detected.' + ? t('firstRunGuide.ai.titleReady') + : t('firstRunGuide.ai.titleConnect') + : t('firstRunGuide.ai.titleMissingRuntime') const aiBody = model.hasAgentRuntime ? model.hasUsableAiChain - ? 'A workspace agent can now launch with a verified AI key. Broker and portfolio setup can stay off until you choose to enable it.' + ? t('firstRunGuide.ai.bodyReady') : model.hasManagedPi - ? 'To run workspace chat, Alice needs an agent runtime and a verified AI key. Pi is already installed here; add one key to continue.' - : 'To run workspace chat, Alice needs an agent runtime and a verified AI key. Add a key for any installed runtime to continue.' - : 'Packaged builds should include a managed Pi runtime. Open the setup checklist to repair the runtime path before continuing.' + ? t('firstRunGuide.ai.bodyPiInstalled') + : t('firstRunGuide.ai.bodyAddKey') + : t('firstRunGuide.ai.bodyMissingRuntime') const aiPrimary = model.hasUsableAiChain - ? 'Continue' + ? t('firstRunGuide.common.continue') : model.hasAgentRuntime - ? 'Add AI credential' - : 'Open setup checklist' + ? t('firstRunGuide.action.addCredential') + : t('firstRunGuide.action.openChecklist') return [ + { + key: 'language' as const, + navLabel: t('firstRunGuide.language.navLabel'), + eyebrow: t('firstRunGuide.language.eyebrow'), + title: t('firstRunGuide.language.title'), + body: t('firstRunGuide.language.body'), + primary: t('firstRunGuide.language.primary'), + secondary: undefined, + panelTitle: t('firstRunGuide.language.panelTitle'), + panelBody: t('firstRunGuide.language.panelBody'), + rows: [], + }, { key: 'lite' as const, - navLabel: 'Welcome', - eyebrow: 'Welcome', - title: 'OpenAlice is your AI trading workspace.', - body: 'Use Alice to research markets and run workspace agents first. Broker accounts stay disconnected until you choose to add them, and Alice cannot place orders in this setup.', - primary: 'Start setup', - secondary: model.hasUsableAiChain ? 'Start without broker setup' : undefined, - panelTitle: 'Safe by default', - panelBody: 'You can use OpenAlice without connecting a broker. Add power step by step when you need it.', + navLabel: t('firstRunGuide.welcome.navLabel'), + eyebrow: t('firstRunGuide.welcome.eyebrow'), + title: t('firstRunGuide.welcome.title'), + body: t('firstRunGuide.welcome.body'), + primary: t('firstRunGuide.action.startSetup'), + secondary: model.hasUsableAiChain ? t('firstRunGuide.action.startWithoutBrokerSetup') : undefined, + panelTitle: t('firstRunGuide.welcome.panelTitle'), + panelBody: t('firstRunGuide.welcome.panelBody'), rows: [ - { icon: , label: 'Workspace agents', value: 'Research and analysis workflows.', tone: model.hasAgentRuntime ? 'ready' as const : 'muted' as const }, - { icon: , label: 'Broker mode', value: model.mode === 'lite' ? 'No broker connection active.' : `${capitalize(model.mode)} active.`, tone: 'ready' as const }, - { icon: , label: 'Broker access', value: model.hasUTA ? 'Configured.' : 'Disconnected until you opt in.', tone: model.hasUTA ? 'ready' as const : 'muted' as const }, + { icon: , label: t('firstRunGuide.welcome.workspaceAgents'), value: t('firstRunGuide.welcome.workspaceAgentsValue'), tone: model.hasAgentRuntime ? 'ready' as const : 'muted' as const }, + { icon: , label: t('firstRunGuide.welcome.brokerMode'), value: model.mode === 'lite' ? t('firstRunGuide.welcome.noBrokerConnectionActive') : t('firstRunGuide.welcome.modeActive', { mode: modeLabel }), tone: 'ready' as const }, + { icon: , label: t('firstRunGuide.welcome.brokerAccess'), value: model.hasUTA ? t('firstRunGuide.common.configured') : t('firstRunGuide.welcome.disconnectedUntilOptIn'), tone: model.hasUTA ? 'ready' as const : 'muted' as const }, ], }, { key: 'ai' as const, - navLabel: 'AI access', - eyebrow: 'Make Alice Useful', + navLabel: t('firstRunGuide.ai.navLabel'), + eyebrow: t('firstRunGuide.ai.eyebrow'), title: aiTitle, body: aiBody, primary: aiPrimary, - secondary: model.hasUsableAiChain ? 'Skip broker setup' : undefined, - panelTitle: 'Runtime scan', - panelBody: 'Alice is ready when one row has both a runtime and AI access.', + secondary: model.hasUsableAiChain ? t('firstRunGuide.action.skipBrokerSetup') : undefined, + panelTitle: t('firstRunGuide.ai.panelTitle'), + panelBody: t('firstRunGuide.ai.panelBody'), rows: [ - { icon: , label: 'Runtime', value: runtimeText, tone: model.hasAgentRuntime ? 'ready' as const : 'attention' as const }, - { icon: , label: 'AI access', value: credentialText, tone: model.hasUsableAiChain ? 'ready' as const : 'attention' as const }, + { icon: , label: t('firstRunGuide.ai.runtime'), value: runtimeText, tone: model.hasAgentRuntime ? 'ready' as const : 'attention' as const }, + { icon: , label: t('firstRunGuide.ai.aiAccess'), value: credentialText, tone: model.hasUsableAiChain ? 'ready' as const : 'attention' as const }, ], }, { key: 'broker' as const, - navLabel: 'Broker access', - eyebrow: 'Trading Mode', - title: 'Decide whether to connect a broker account.', - body: 'You can keep broker access off and use Alice for research, or connect an account so Alice can read positions. Write access stays blocked unless you later choose Pro permissions.', + navLabel: t('firstRunGuide.broker.navLabel'), + eyebrow: t('firstRunGuide.broker.eyebrow'), + title: t('firstRunGuide.broker.title'), + body: t('firstRunGuide.broker.body'), primary: brokerPrimary, secondary: brokerSecondary, - panelTitle: 'Broker connection', + panelTitle: t('firstRunGuide.broker.panelTitle'), panelBody: model.needsUTASetup - ? 'The broker-connected options need a connector first. Connect one now, or continue without UTA and add it later.' - : 'Choose whether Alice should connect to a broker account. You can change this later in Settings.', + ? t('firstRunGuide.broker.panelBodyNeedsUTA') + : t('firstRunGuide.broker.panelBodyChoose'), rows: [ - { icon: , label: 'No broker connection', value: 'Use Alice for research; portfolio and trading stay off.', tone: model.mode === 'lite' ? 'ready' as const : 'muted' as const }, - { icon: , label: 'Read-only broker connection', value: 'Include balances and positions; block orders.', tone: model.mode === 'readonly' ? 'ready' as const : 'muted' as const }, - { icon: , label: 'Permissioned broker workflows', value: 'Use per-account approval policy.', tone: model.mode === 'pro' ? 'ready' as const : 'muted' as const }, + { icon: , label: t('firstRunGuide.broker.noBrokerConnection'), value: t('firstRunGuide.broker.noBrokerConnectionValue'), tone: model.mode === 'lite' ? 'ready' as const : 'muted' as const }, + { icon: , label: t('firstRunGuide.broker.readOnlyBrokerConnection'), value: t('firstRunGuide.broker.readOnlyBrokerConnectionValue'), tone: model.mode === 'readonly' ? 'ready' as const : 'muted' as const }, + { icon: , label: t('firstRunGuide.broker.permissionedBrokerWorkflows'), value: t('firstRunGuide.broker.permissionedBrokerWorkflowsValue'), tone: model.mode === 'pro' ? 'ready' as const : 'muted' as const }, ], }, { key: 'finish' as const, - navLabel: 'Ready', - eyebrow: 'Setup Complete', - title: canStartWorkspace ? "You're all set." : 'OpenAlice is ready to open.', + navLabel: t('firstRunGuide.finish.navLabel'), + eyebrow: t('firstRunGuide.finish.eyebrow'), + title: canStartWorkspace ? t('firstRunGuide.finish.titleReady') : t('firstRunGuide.finish.titleOpen'), body: canStartWorkspace - ? `OpenAlice is ready with a working AI path and ${model.mode === 'lite' ? 'no broker connection' : `${modeLabel} broker access`}. Broker accounts can stay disconnected until you add them.` - : 'Alice can open without a broker account. Add an AI credential later when you want workspace chat and automated research.', - primary: canStartWorkspace ? 'Start using Alice' : 'Open Alice now', - secondary: 'Open checklist', - panelTitle: 'Ready now', + ? t('firstRunGuide.finish.bodyReady', { access: modeAccessLabel }) + : t('firstRunGuide.finish.bodyOpen'), + primary: canStartWorkspace ? t('firstRunGuide.action.startUsingAlice') : t('firstRunGuide.action.openAliceNow'), + secondary: t('firstRunGuide.action.openChecklist'), + panelTitle: t('firstRunGuide.finish.panelTitle'), panelBody: '', rows: [ - { icon: , label: 'Workspace chat', value: canStartWorkspace ? 'Ready.' : model.hasAgentRuntime ? 'Needs AI access.' : 'Needs runtime.', tone: canStartWorkspace ? 'ready' as const : 'attention' as const }, - { icon: , label: 'Broker mode', value: model.mode === 'lite' ? 'No broker connection.' : `${modeLabel} saved.`, tone: 'ready' as const }, - { icon: , label: 'Broker writes', value: brokerWriteText, tone: model.mode === 'pro' ? 'muted' as const : 'ready' as const }, + { icon: , label: t('firstRunGuide.ai.workspaceChat'), value: canStartWorkspace ? t('firstRunGuide.common.ready') : model.hasAgentRuntime ? t('firstRunGuide.ai.needsAiAccess') : t('firstRunGuide.ai.needsRuntime'), tone: canStartWorkspace ? 'ready' as const : 'attention' as const }, + { icon: , label: t('firstRunGuide.finish.brokerMode'), value: model.mode === 'lite' ? t('firstRunGuide.finish.noBrokerConnection') : t('firstRunGuide.finish.modeSaved', { mode: modeLabel }), tone: 'ready' as const }, + { icon: , label: t('firstRunGuide.finish.brokerWrites'), value: brokerWriteText, tone: model.mode === 'pro' ? 'muted' as const : 'ready' as const }, ], }, ] - }, [model]) + }, [model, t]) const maxReachableStepIndex = useMemo(() => { const index = steps.findIndex((step) => step.key === guideAccess.maxReachableStepKey) @@ -394,7 +426,7 @@ export function FirstRunGuide() { setShowUTAForm(false) goToStep(activeStepIndex + 1) } catch (err) { - setModeChoiceError(err instanceof Error ? err.message : 'Failed to continue without UTA') + setModeChoiceError(err instanceof Error ? err.message : t('firstRunGuide.error.continueWithoutUTA')) } finally { setUtaEscapeSaving(false) } @@ -448,17 +480,17 @@ export function FirstRunGuide() {
- OpenAlice Setup + {t('firstRunGuide.header.setup')}
- Start safe. Add power only when you need it. + {t('firstRunGuide.header.subtitle')}
{guideAccess.canDismiss && ( + ) + })} + + ) +} + function StatusRow({ icon, label, @@ -699,6 +798,7 @@ function TradingModeChoices({ error: string | null onSelect: (mode: TradingMode) => void }) { + const { t } = useTranslation() const choices: Array<{ mode: TradingMode icon: ReactNode @@ -708,20 +808,20 @@ function TradingModeChoices({ { mode: 'lite', icon: , - label: 'Research only', - description: 'No broker connector. Portfolio and trading stay off.', + label: t('firstRunGuide.tradingChoices.researchOnly'), + description: t('firstRunGuide.tradingChoices.researchOnlyDescription'), }, { mode: 'readonly', icon: , - label: 'Read-only broker', - description: 'Read balances and positions; block orders.', + label: t('firstRunGuide.tradingChoices.readOnlyBroker'), + description: t('firstRunGuide.tradingChoices.readOnlyBrokerDescription'), }, { mode: 'pro', icon: , - label: 'Pro broker', - description: 'Use per-account approval policy.', + label: t('firstRunGuide.tradingChoices.proBroker'), + description: t('firstRunGuide.tradingChoices.proBrokerDescription'), }, ] const disabled = envLocked || saving !== null @@ -729,7 +829,7 @@ function TradingModeChoices({
- Choose broker access + {t('firstRunGuide.tradingChoices.badge')}
{choices.map((choice) => { @@ -761,14 +861,14 @@ function TradingModeChoices({ {isSaving && ( - Saving + {t('firstRunGuide.common.saving')} )} {!isSaving && ( - {active ? 'Selected' : 'Choose this option'} + {active ? t('firstRunGuide.common.selected') : t('firstRunGuide.common.chooseThisOption')} )} @@ -785,10 +885,10 @@ function TradingModeChoices({ {envLocked ? ( - Broker mode is locked by the current environment. + {t('firstRunGuide.tradingChoices.envLocked')} ) : ( - `Current source: ${modeSource}` + t('firstRunGuide.tradingChoices.source', { source: modeSource }) )}
{error && ( @@ -813,12 +913,13 @@ function RuntimeScanTable({ accessLabel: string }> }) { + const { t } = useTranslation() return (
- Runtime - CLI - AI access + {t('firstRunGuide.ai.runtime')} + {t('firstRunGuide.ai.cli')} + {t('firstRunGuide.ai.aiAccess')}
{rows.map((row) => { const tone: RowTone = row.chainReady ? 'ready' : row.installed ? 'attention' : 'muted' @@ -827,12 +928,16 @@ function RuntimeScanTable({ : tone === 'attention' ? 'text-red' : 'text-text-muted' - const cliText = row.installed ? 'Installed' : 'Missing' + const cliText = row.installed ? t('firstRunGuide.ai.installed') : t('firstRunGuide.ai.missing') const accessText = row.chainReady - ? 'Ready' + ? t('firstRunGuide.ai.ready') : row.installed && row.compatibleCredentialCount > 0 - ? 'Ready' - : row.accessLabel + ? t('firstRunGuide.ai.ready') + : row.installed + ? row.loginRuntime + ? t('firstRunGuide.ai.loginCheckPending') + : t('firstRunGuide.ai.needsAiKey') + : t('firstRunGuide.ai.cliNotInstalled') return (
{row.displayName}
- {row.loginRuntime ? 'CLI login or AI key' : 'AI key'} + {row.loginRuntime ? t('firstRunGuide.ai.loginOrKey') : t('firstRunGuide.ai.aiKey')}
{cliText} @@ -861,7 +966,3 @@ function RuntimeScanTable({
) } - -function capitalize(value: string) { - return `${value.charAt(0).toUpperCase()}${value.slice(1)}` -} diff --git a/ui/src/components/first-run-guide-model.ts b/ui/src/components/first-run-guide-model.ts index 55c9d8f6..448c00a0 100644 --- a/ui/src/components/first-run-guide-model.ts +++ b/ui/src/components/first-run-guide-model.ts @@ -12,10 +12,13 @@ const AGENT_WIRE_PREFERENCE: Record = { const LOGIN_RUNTIME_AGENTS = new Set(['claude', 'codex']) -export const FIRST_RUN_STEP_KEYS = ['lite', 'ai', 'broker', 'finish'] as const +export const FIRST_RUN_STEP_KEYS = ['language', 'lite', 'ai', 'broker', 'finish'] as const export type FirstRunStepKey = typeof FIRST_RUN_STEP_KEYS[number] const STEP_OVERRIDE_ALIASES: Record = { + language: 'language', + locale: 'language', + lang: 'language', lite: 'lite', safe: 'lite', ai: 'ai', diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index 51f46f12..35783744 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -194,6 +194,155 @@ export const en = { ex2: 'Build a thesis on NVDA', ex3: 'Scan the EV supply chain', }, + firstRunGuide: { + header: { + setup: 'OpenAlice Setup', + subtitle: 'Start safe. Add power only when you need it.', + close: 'Close onboarding', + }, + language: { + navLabel: 'Language', + eyebrow: 'Choose Language', + title: 'Choose your language.', + body: 'OpenAlice will use this language for the app interface and setup flow. You can change it later in Settings.', + primary: 'Continue', + panelTitle: 'Interface language', + panelBody: 'Pick the language you want to read first. The choice is saved on this device.', + current: 'Current', + choose: 'Choose', + option: { + en: 'English interface', + zh: 'Simplified Chinese interface', + ja: 'Japanese interface', + 'zh-Hant': 'Traditional Chinese interface', + }, + }, + common: { + step: 'Step {{current}} of {{total}} · {{label}}', + back: 'Back', + continue: 'Continue', + ready: 'Ready.', + configured: 'Configured.', + blocked: 'Blocked.', + selected: 'Selected', + chooseThisOption: 'Choose this option', + saving: 'Saving', + }, + action: { + startSetup: 'Start setup', + startWithoutBrokerSetup: 'Start without broker setup', + addCredential: 'Add AI credential', + openChecklist: 'Open setup checklist', + skipBrokerSetup: 'Skip broker setup', + connectBroker: 'Connect broker account', + continueWithoutUTA: 'Continue without UTA', + chooseLater: 'Choose later', + continueWithMode: 'Continue with {{mode}}', + startUsingAlice: 'Start using Alice', + openAliceNow: 'Open Alice now', + }, + mode: { + lite: 'Lite', + readonly: 'Readonly', + pro: 'Pro', + }, + welcome: { + navLabel: 'Welcome', + eyebrow: 'Welcome', + title: 'OpenAlice is your AI trading workspace.', + body: 'Use Alice to research markets and run workspace agents first. Broker accounts stay disconnected until you choose to add them, and Alice cannot place orders in this setup.', + panelTitle: 'Safe by default', + panelBody: 'You can use OpenAlice without connecting a broker. Add power step by step when you need it.', + workspaceAgents: 'Workspace agents', + workspaceAgentsValue: 'Research and analysis workflows.', + brokerMode: 'Broker mode', + noBrokerConnectionActive: 'No broker connection active.', + modeActive: '{{mode}} active.', + brokerAccess: 'Broker access', + disconnectedUntilOptIn: 'Disconnected until you opt in.', + }, + ai: { + navLabel: 'AI access', + eyebrow: 'Make Alice Useful', + titleReady: 'Alice has a working AI path.', + titleConnect: 'Connect one runtime to AI access.', + titleMissingRuntime: 'Managed runtime was not detected.', + bodyReady: 'A workspace agent can now launch with a verified AI key. Broker and portfolio setup can stay off until you choose to enable it.', + bodyPiInstalled: 'To run workspace chat, Alice needs an agent runtime and a verified AI key. Pi is already installed here; add one key to continue.', + bodyAddKey: 'To run workspace chat, Alice needs an agent runtime and a verified AI key. Add a key for any installed runtime to continue.', + bodyMissingRuntime: 'Packaged builds should include a managed Pi runtime. Open the setup checklist to repair the runtime path before continuing.', + panelTitle: 'Runtime scan', + panelBody: 'Alice is ready when one row has both a runtime and AI access.', + runtime: 'Runtime', + aiAccess: 'AI access', + noVerifiedKey: 'No verified AI key yet.', + usableKey: 'One installed runtime can use a verified key.', + keyMismatch: 'Saved keys do not match an installed runtime yet.', + runtimeInstalled_one: '{{count}} runtime installed', + runtimeInstalled_other: '{{count}} runtimes installed', + managedPiMissing: 'Managed Pi runtime not detected', + runtimeMissing: 'Agent runtime not detected', + cli: 'CLI', + installed: 'Installed', + missing: 'Missing', + ready: 'Ready', + loginCheckPending: 'Login check pending', + needsAiKey: 'Needs AI key', + cliNotInstalled: 'CLI not installed', + loginOrKey: 'CLI login or AI key', + aiKey: 'AI key', + workspaceChat: 'Workspace chat', + needsAiAccess: 'Needs AI access.', + needsRuntime: 'Needs runtime.', + }, + broker: { + navLabel: 'Broker access', + eyebrow: 'Trading Mode', + title: 'Decide whether to connect a broker account.', + body: 'You can keep broker access off and use Alice for research, or connect an account so Alice can read positions. Write access stays blocked unless you later choose Pro permissions.', + panelTitle: 'Broker connection', + panelBodyNeedsUTA: 'The broker-connected options need a connector first. Connect one now, or continue without UTA and add it later.', + panelBodyChoose: 'Choose whether Alice should connect to a broker account. You can change this later in Settings.', + noBrokerConnection: 'No broker connection', + noBrokerConnectionValue: 'Use Alice for research; portfolio and trading stay off.', + readOnlyBrokerConnection: 'Read-only broker connection', + readOnlyBrokerConnectionValue: 'Include balances and positions; block orders.', + permissionedBrokerWorkflows: 'Permissioned broker workflows', + permissionedBrokerWorkflowsValue: 'Use per-account approval policy.', + writesControlled: 'Controlled by account permissions.', + writesBlocked: 'Blocked.', + }, + tradingChoices: { + badge: 'Choose broker access', + researchOnly: 'Research only', + researchOnlyDescription: 'No broker connector. Portfolio and trading stay off.', + readOnlyBroker: 'Read-only broker', + readOnlyBrokerDescription: 'Read balances and positions; block orders.', + proBroker: 'Pro broker', + proBrokerDescription: 'Use per-account approval policy.', + envLocked: 'Broker mode is locked by the current environment.', + source: 'Current source: {{source}}', + }, + finish: { + navLabel: 'Ready', + eyebrow: 'Setup Complete', + titleReady: "You're all set.", + titleOpen: 'OpenAlice is ready to open.', + bodyReady: 'OpenAlice is ready with a working AI path and {{access}}. Broker accounts can stay disconnected until you add them.', + bodyOpen: 'Alice can open without a broker account. Add an AI credential later when you want workspace chat and automated research.', + noBrokerAccess: 'no broker connection', + modeBrokerAccess: '{{mode}} broker access', + panelTitle: 'Ready now', + brokerMode: 'Broker mode', + noBrokerConnection: 'No broker connection.', + modeSaved: '{{mode}} saved.', + brokerWrites: 'Broker writes', + }, + error: { + saveTradingMode: 'Failed to save trading mode', + continueWithoutUTA: 'Failed to continue without UTA', + }, + }, theme: { mode: { auto: 'Auto', light: 'Light', dark: 'Dark' }, switchTo: 'Switch to {{mode}}', diff --git a/ui/src/i18n/locales/ja.ts b/ui/src/i18n/locales/ja.ts index f4e28b66..93fb481b 100644 --- a/ui/src/i18n/locales/ja.ts +++ b/ui/src/i18n/locales/ja.ts @@ -183,6 +183,155 @@ export const ja: Resources = { ex2: 'NVDA の強気シナリオを作って', ex3: 'EV サプライチェーンを整理して', }, + firstRunGuide: { + header: { + setup: 'OpenAlice セットアップ', + subtitle: '安全に始め、必要なときだけ機能を追加します。', + close: 'オンボーディングを閉じる', + }, + language: { + navLabel: '言語', + eyebrow: '言語を選択', + title: '言語を選択', + body: 'OpenAlice はこの言語でアプリ画面とセットアップを表示します。あとで設定から変更できます。', + primary: '続ける', + panelTitle: 'インターフェース言語', + panelBody: '最初に読みやすい言語を選んでください。この選択はこの端末に保存されます。', + current: '現在', + choose: '選択', + option: { + en: '英語インターフェース', + zh: '簡体字中国語インターフェース', + ja: '日本語インターフェース', + 'zh-Hant': '繁体字中国語インターフェース', + }, + }, + common: { + step: 'ステップ {{current}} / {{total}} · {{label}}', + back: '戻る', + continue: '続ける', + ready: '準備完了。', + configured: '設定済み。', + blocked: 'ブロック済み。', + selected: '選択済み', + chooseThisOption: 'この項目を選択', + saving: '保存中', + }, + action: { + startSetup: 'セットアップ開始', + startWithoutBrokerSetup: 'ブローカー設定なしで開始', + addCredential: 'AI 認証情報を追加', + openChecklist: 'セットアップ一覧を開く', + skipBrokerSetup: 'ブローカー設定をスキップ', + connectBroker: 'ブローカー口座を接続', + continueWithoutUTA: 'UTA なしで続ける', + chooseLater: 'あとで選ぶ', + continueWithMode: '{{mode}} で続ける', + startUsingAlice: 'Alice を使い始める', + openAliceNow: 'Alice を開く', + }, + mode: { + lite: 'Lite', + readonly: 'Readonly', + pro: 'Pro', + }, + welcome: { + navLabel: 'ようこそ', + eyebrow: 'ようこそ', + title: 'OpenAlice は AI トレーディングワークスペースです。', + body: 'まず Alice で市場調査とワークスペースエージェントを使えます。ブローカー口座はあなたが追加するまで接続されず、この設定では Alice は発注できません。', + panelTitle: 'デフォルトで安全', + panelBody: 'ブローカーを接続しなくても OpenAlice は使えます。必要になったら段階的に機能を追加してください。', + workspaceAgents: 'ワークスペースエージェント', + workspaceAgentsValue: '調査と分析のワークフロー。', + brokerMode: 'ブローカーモード', + noBrokerConnectionActive: '有効なブローカー接続はありません。', + modeActive: '{{mode}} が有効です。', + brokerAccess: 'ブローカーアクセス', + disconnectedUntilOptIn: 'あなたが有効にするまで未接続です。', + }, + ai: { + navLabel: 'AI アクセス', + eyebrow: 'Alice を使える状態にする', + titleReady: 'Alice には動作する AI 経路があります。', + titleConnect: 'ランタイムを AI アクセスにつなげます。', + titleMissingRuntime: '管理ランタイムが検出されませんでした。', + bodyReady: 'ワークスペースエージェントは検証済みの AI キーで起動できます。ブローカーとポートフォリオ設定は必要になるまでオフのままで構いません。', + bodyPiInstalled: 'ワークスペースチャットにはエージェントランタイムと検証済み AI キーが必要です。ここでは Pi がすでにインストールされています。キーを 1 つ追加してください。', + bodyAddKey: 'ワークスペースチャットにはエージェントランタイムと検証済み AI キーが必要です。インストール済みランタイム向けのキーを 1 つ追加してください。', + bodyMissingRuntime: 'パッケージ版には管理 Pi ランタイムが含まれているはずです。続行前にセットアップ一覧でランタイムパスを修復してください。', + panelTitle: 'ランタイムスキャン', + panelBody: 'ランタイムと AI アクセスの両方がそろう行が 1 つあれば Alice は使用できます。', + runtime: 'ランタイム', + aiAccess: 'AI アクセス', + noVerifiedKey: '検証済み AI キーはまだありません。', + usableKey: 'インストール済みランタイムが検証済みキーを使用できます。', + keyMismatch: '保存済みキーがインストール済みランタイムと一致しません。', + runtimeInstalled_one: '{{count}} 個のランタイムがインストール済み', + runtimeInstalled_other: '{{count}} 個のランタイムがインストール済み', + managedPiMissing: '管理 Pi ランタイムが見つかりません', + runtimeMissing: 'エージェントランタイムが見つかりません', + cli: 'CLI', + installed: 'インストール済み', + missing: 'なし', + ready: '準備完了', + loginCheckPending: 'ログイン確認待ち', + needsAiKey: 'AI キーが必要', + cliNotInstalled: 'CLI 未インストール', + loginOrKey: 'CLI ログインまたは AI キー', + aiKey: 'AI キー', + workspaceChat: 'ワークスペースチャット', + needsAiAccess: 'AI アクセスが必要。', + needsRuntime: 'ランタイムが必要。', + }, + broker: { + navLabel: 'ブローカーアクセス', + eyebrow: '取引モード', + title: 'ブローカー口座を接続するか決めます。', + body: 'ブローカーアクセスをオフにして Alice を調査だけに使うことも、口座を接続してポジションを読ませることもできます。書き込みはあとで Pro 権限を選ぶまでブロックされます。', + panelTitle: 'ブローカー接続', + panelBodyNeedsUTA: 'ブローカー接続オプションには先にコネクタが必要です。今接続するか、UTA なしで続けてあとで追加してください。', + panelBodyChoose: 'Alice がブローカー口座へ接続するか選んでください。あとで設定から変更できます。', + noBrokerConnection: 'ブローカー未接続', + noBrokerConnectionValue: 'Alice を調査に使い、ポートフォリオと取引はオフにします。', + readOnlyBrokerConnection: '読み取り専用ブローカー接続', + readOnlyBrokerConnectionValue: '残高とポジションを読み取り、注文をブロックします。', + permissionedBrokerWorkflows: '権限付きブローカーワークフロー', + permissionedBrokerWorkflowsValue: '口座ごとの承認ポリシーを使用します。', + writesControlled: '口座権限で制御されます。', + writesBlocked: 'ブロック済み。', + }, + tradingChoices: { + badge: 'ブローカーアクセスを選択', + researchOnly: '調査のみ', + researchOnlyDescription: 'ブローカーコネクタなし。ポートフォリオと取引はオフです。', + readOnlyBroker: '読み取り専用ブローカー', + readOnlyBrokerDescription: '残高とポジションを読み取り、注文をブロックします。', + proBroker: 'Pro ブローカー', + proBrokerDescription: '口座ごとの承認ポリシーを使用します。', + envLocked: 'ブローカーモードは現在の環境でロックされています。', + source: '現在のソース: {{source}}', + }, + finish: { + navLabel: '準備完了', + eyebrow: 'セットアップ完了', + titleReady: '準備できました。', + titleOpen: 'OpenAlice を開けます。', + bodyReady: 'OpenAlice は動作する AI 経路と {{access}} で準備できました。ブローカー口座は追加するまで未接続のままで構いません。', + bodyOpen: 'Alice はブローカー口座なしで開けます。ワークスペースチャットや自動調査を使うときに AI 認証情報を追加してください。', + noBrokerAccess: 'ブローカー未接続', + modeBrokerAccess: '{{mode}} ブローカーアクセス', + panelTitle: '今すぐ使える状態', + brokerMode: 'ブローカーモード', + noBrokerConnection: 'ブローカー接続なし。', + modeSaved: '{{mode}} を保存済み。', + brokerWrites: 'ブローカー書き込み', + }, + error: { + saveTradingMode: '取引モードの保存に失敗しました', + continueWithoutUTA: 'UTA なしで続行できませんでした', + }, + }, theme: { mode: { auto: '自動', light: 'ライト', dark: 'ダーク' }, switchTo: '{{mode}}に切り替え', diff --git a/ui/src/i18n/locales/zh-Hant.ts b/ui/src/i18n/locales/zh-Hant.ts index b7f32a80..cc9d9172 100644 --- a/ui/src/i18n/locales/zh-Hant.ts +++ b/ui/src/i18n/locales/zh-Hant.ts @@ -191,6 +191,155 @@ export const zhHant: Resources = { ex2: '幫我做一個輝達的多頭邏輯', ex3: '梳理一下電動車產業鏈', }, + firstRunGuide: { + header: { + setup: 'OpenAlice 設定', + subtitle: '先安全啟動,需要時再逐步開啟能力。', + close: '關閉引導', + }, + language: { + navLabel: '語言', + eyebrow: '選擇語言', + title: '先選擇你想看的語言。', + body: 'OpenAlice 會用這個語言顯示應用介面和設定引導。之後也可以在設定裡修改。', + primary: '繼續', + panelTitle: '介面語言', + panelBody: '先選你最容易閱讀的語言。這個選擇只儲存在本機。', + current: '目前', + choose: '選擇', + option: { + en: '英文介面', + zh: '簡體中文介面', + ja: '日文介面', + 'zh-Hant': '繁體中文介面', + }, + }, + common: { + step: '第 {{current}} / {{total}} 步 · {{label}}', + back: '返回', + continue: '繼續', + ready: '已就緒。', + configured: '已設定。', + blocked: '已阻止。', + selected: '已選擇', + chooseThisOption: '選擇此項', + saving: '儲存中', + }, + action: { + startSetup: '開始設定', + startWithoutBrokerSetup: '先不設定券商', + addCredential: '新增 AI 憑證', + openChecklist: '開啟設定清單', + skipBrokerSetup: '略過券商設定', + connectBroker: '連接券商帳戶', + continueWithoutUTA: '不連接 UTA,繼續', + chooseLater: '稍後再選', + continueWithMode: '以 {{mode}} 繼續', + startUsingAlice: '開始使用 Alice', + openAliceNow: '現在開啟 Alice', + }, + mode: { + lite: 'Lite', + readonly: 'Readonly', + pro: 'Pro', + }, + welcome: { + navLabel: '歡迎', + eyebrow: '歡迎', + title: 'OpenAlice 是你的 AI 交易工作區。', + body: '先用 Alice 做市場研究並執行工作區 agent。券商帳戶會保持斷開,直到你主動新增;在這個設定下 Alice 不能下單。', + panelTitle: '預設安全', + panelBody: '不連接券商也可以使用 OpenAlice。需要更多能力時,再一步步開啟。', + workspaceAgents: '工作區 agent', + workspaceAgentsValue: '研究和分析工作流。', + brokerMode: '券商模式', + noBrokerConnectionActive: '目前沒有活動的券商連接。', + modeActive: '{{mode}} 已啟用。', + brokerAccess: '券商存取', + disconnectedUntilOptIn: '保持斷開,直到你主動開啟。', + }, + ai: { + navLabel: 'AI 存取', + eyebrow: '讓 Alice 可用', + titleReady: 'Alice 已經有可用的 AI 路徑。', + titleConnect: '把一個執行環境接到 AI 存取上。', + titleMissingRuntime: '未偵測到託管執行環境。', + bodyReady: '工作區 agent 現在可以用已驗證的 AI key 啟動。券商和投資組合設定可以先保持關閉,等你需要時再開啟。', + bodyPiInstalled: '要執行工作區聊天,Alice 需要一個 agent 執行環境和一個已驗證的 AI key。這裡已經安裝了 Pi;新增一個 key 就能繼續。', + bodyAddKey: '要執行工作區聊天,Alice 需要一個 agent 執行環境和一個已驗證的 AI key。給任意已安裝執行環境新增一個 key 即可繼續。', + bodyMissingRuntime: '打包版本應該自帶託管 Pi 執行環境。繼續之前,請開啟設定清單修復執行環境路徑。', + panelTitle: '執行環境掃描', + panelBody: '只要有一列同時具備執行環境和 AI 存取,Alice 就可用。', + runtime: '執行環境', + aiAccess: 'AI 存取', + noVerifiedKey: '還沒有已驗證的 AI key。', + usableKey: '已有一個已安裝執行環境可以使用已驗證 key。', + keyMismatch: '已儲存的 key 和目前已安裝執行環境不相符。', + runtimeInstalled_one: '已安裝 {{count}} 個執行環境', + runtimeInstalled_other: '已安裝 {{count}} 個執行環境', + managedPiMissing: '未偵測到託管 Pi 執行環境', + runtimeMissing: '未偵測到 agent 執行環境', + cli: 'CLI', + installed: '已安裝', + missing: '缺少', + ready: '可用', + loginCheckPending: '等待登入檢查', + needsAiKey: '需要 AI key', + cliNotInstalled: 'CLI 未安裝', + loginOrKey: 'CLI 登入或 AI key', + aiKey: 'AI key', + workspaceChat: '工作區聊天', + needsAiAccess: '需要 AI 存取。', + needsRuntime: '需要執行環境。', + }, + broker: { + navLabel: '券商存取', + eyebrow: '交易模式', + title: '決定是否連接券商帳戶。', + body: '你可以保持券商存取關閉,只用 Alice 做研究;也可以連接帳戶,讓 Alice 讀取持倉。除非之後切到 Pro 權限,否則寫入仍會被阻止。', + panelTitle: '券商連接', + panelBodyNeedsUTA: '連接券商的選項需要先有一個連接器。現在連接一個,或者先不連接 UTA 繼續,之後再新增。', + panelBodyChoose: '選擇 Alice 是否應該連接券商帳戶。之後可以在設定中修改。', + noBrokerConnection: '不連接券商', + noBrokerConnectionValue: '只用 Alice 做研究;投資組合和交易保持關閉。', + readOnlyBrokerConnection: '唯讀券商連接', + readOnlyBrokerConnectionValue: '讀取餘額和持倉;阻止下單。', + permissionedBrokerWorkflows: '帶權限控制的券商工作流', + permissionedBrokerWorkflowsValue: '使用每帳戶核准策略。', + writesControlled: '由帳戶權限控制。', + writesBlocked: '已阻止。', + }, + tradingChoices: { + badge: '選擇券商存取', + researchOnly: '只做研究', + researchOnlyDescription: '不連接券商。投資組合和交易保持關閉。', + readOnlyBroker: '唯讀券商', + readOnlyBrokerDescription: '讀取餘額和持倉;阻止下單。', + proBroker: 'Pro 券商', + proBrokerDescription: '使用每帳戶核准策略。', + envLocked: '券商模式已被目前環境鎖定。', + source: '目前來源:{{source}}', + }, + finish: { + navLabel: '就緒', + eyebrow: '設定完成', + titleReady: '已經準備好了。', + titleOpen: 'OpenAlice 可以開啟了。', + bodyReady: 'OpenAlice 已經具備可用 AI 路徑,並處於{{access}}狀態。券商帳戶可以保持斷開,等你新增時再啟用。', + bodyOpen: 'Alice 可以在沒有券商帳戶的情況下開啟。之後想使用工作區聊天和自動研究時,再新增 AI 憑證。', + noBrokerAccess: '不連接券商', + modeBrokerAccess: '{{mode}} 券商存取', + panelTitle: '現在可用', + brokerMode: '券商模式', + noBrokerConnection: '沒有券商連接。', + modeSaved: '{{mode}} 已儲存。', + brokerWrites: '券商寫入', + }, + error: { + saveTradingMode: '儲存交易模式失敗', + continueWithoutUTA: '無法在不連接 UTA 的情況下繼續', + }, + }, theme: { mode: { auto: '自動', light: '淺色', dark: '深色' }, switchTo: '切換到{{mode}}', diff --git a/ui/src/i18n/locales/zh.ts b/ui/src/i18n/locales/zh.ts index 4a6f5e41..d7a1c7cb 100644 --- a/ui/src/i18n/locales/zh.ts +++ b/ui/src/i18n/locales/zh.ts @@ -183,6 +183,155 @@ export const zh: Resources = { ex2: '给我做一个英伟达的多头逻辑', ex3: '梳理一下电动车产业链', }, + firstRunGuide: { + header: { + setup: 'OpenAlice 设置', + subtitle: '先安全启动,需要时再逐步开启能力。', + close: '关闭引导', + }, + language: { + navLabel: '语言', + eyebrow: '选择语言', + title: '先选择你想看的语言。', + body: 'OpenAlice 会用这个语言显示应用界面和设置引导。之后也可以在设置里修改。', + primary: '继续', + panelTitle: '界面语言', + panelBody: '先选你最容易阅读的语言。这个选择只保存在本设备。', + current: '当前', + choose: '选择', + option: { + en: '英文界面', + zh: '简体中文界面', + ja: '日文界面', + 'zh-Hant': '繁体中文界面', + }, + }, + common: { + step: '第 {{current}} / {{total}} 步 · {{label}}', + back: '返回', + continue: '继续', + ready: '已就绪。', + configured: '已配置。', + blocked: '已阻止。', + selected: '已选择', + chooseThisOption: '选择此项', + saving: '保存中', + }, + action: { + startSetup: '开始设置', + startWithoutBrokerSetup: '先不配置券商', + addCredential: '添加 AI 凭证', + openChecklist: '打开设置清单', + skipBrokerSetup: '跳过券商设置', + connectBroker: '连接券商账户', + continueWithoutUTA: '不连接 UTA,继续', + chooseLater: '稍后再选', + continueWithMode: '以 {{mode}} 继续', + startUsingAlice: '开始使用 Alice', + openAliceNow: '现在打开 Alice', + }, + mode: { + lite: 'Lite', + readonly: 'Readonly', + pro: 'Pro', + }, + welcome: { + navLabel: '欢迎', + eyebrow: '欢迎', + title: 'OpenAlice 是你的 AI 交易工作区。', + body: '先用 Alice 做市场研究并运行工作区 agent。券商账户会保持断开,直到你主动添加;在这个设置下 Alice 不能下单。', + panelTitle: '默认安全', + panelBody: '不连接券商也可以使用 OpenAlice。需要更多能力时,再一步步开启。', + workspaceAgents: '工作区 agent', + workspaceAgentsValue: '研究和分析工作流。', + brokerMode: '券商模式', + noBrokerConnectionActive: '当前没有活动的券商连接。', + modeActive: '{{mode}} 已启用。', + brokerAccess: '券商访问', + disconnectedUntilOptIn: '保持断开,直到你主动开启。', + }, + ai: { + navLabel: 'AI 访问', + eyebrow: '让 Alice 可用', + titleReady: 'Alice 已经有可用的 AI 路径。', + titleConnect: '把一个运行时接到 AI 访问上。', + titleMissingRuntime: '未检测到托管运行时。', + bodyReady: '工作区 agent 现在可以用已验证的 AI key 启动。券商和投资组合设置可以先保持关闭,等你需要时再开启。', + bodyPiInstalled: '要运行工作区聊天,Alice 需要一个 agent 运行时和一个已验证的 AI key。这里已经安装了 Pi;添加一个 key 就能继续。', + bodyAddKey: '要运行工作区聊天,Alice 需要一个 agent 运行时和一个已验证的 AI key。给任意已安装运行时添加一个 key 即可继续。', + bodyMissingRuntime: '打包版本应该自带托管 Pi 运行时。继续之前,请打开设置清单修复运行时路径。', + panelTitle: '运行时扫描', + panelBody: '只要有一行同时具备运行时和 AI 访问,Alice 就可用。', + runtime: '运行时', + aiAccess: 'AI 访问', + noVerifiedKey: '还没有已验证的 AI key。', + usableKey: '已有一个已安装运行时可以使用已验证 key。', + keyMismatch: '已保存的 key 和当前已安装运行时不匹配。', + runtimeInstalled_one: '已安装 {{count}} 个运行时', + runtimeInstalled_other: '已安装 {{count}} 个运行时', + managedPiMissing: '未检测到托管 Pi 运行时', + runtimeMissing: '未检测到 agent 运行时', + cli: 'CLI', + installed: '已安装', + missing: '缺失', + ready: '可用', + loginCheckPending: '等待登录检查', + needsAiKey: '需要 AI key', + cliNotInstalled: 'CLI 未安装', + loginOrKey: 'CLI 登录或 AI key', + aiKey: 'AI key', + workspaceChat: '工作区聊天', + needsAiAccess: '需要 AI 访问。', + needsRuntime: '需要运行时。', + }, + broker: { + navLabel: '券商访问', + eyebrow: '交易模式', + title: '决定是否连接券商账户。', + body: '你可以保持券商访问关闭,只用 Alice 做研究;也可以连接账户,让 Alice 读取持仓。除非之后切到 Pro 权限,否则写入仍会被阻止。', + panelTitle: '券商连接', + panelBodyNeedsUTA: '连接券商的选项需要先有一个连接器。现在连接一个,或者先不连接 UTA 继续,之后再添加。', + panelBodyChoose: '选择 Alice 是否应该连接券商账户。之后可以在设置中修改。', + noBrokerConnection: '不连接券商', + noBrokerConnectionValue: '只用 Alice 做研究;投资组合和交易保持关闭。', + readOnlyBrokerConnection: '只读券商连接', + readOnlyBrokerConnectionValue: '读取余额和持仓;阻止下单。', + permissionedBrokerWorkflows: '带权限控制的券商工作流', + permissionedBrokerWorkflowsValue: '使用每账户审批策略。', + writesControlled: '由账户权限控制。', + writesBlocked: '已阻止。', + }, + tradingChoices: { + badge: '选择券商访问', + researchOnly: '只做研究', + researchOnlyDescription: '不连接券商。投资组合和交易保持关闭。', + readOnlyBroker: '只读券商', + readOnlyBrokerDescription: '读取余额和持仓;阻止下单。', + proBroker: 'Pro 券商', + proBrokerDescription: '使用每账户审批策略。', + envLocked: '券商模式已被当前环境锁定。', + source: '当前来源:{{source}}', + }, + finish: { + navLabel: '就绪', + eyebrow: '设置完成', + titleReady: '已经准备好了。', + titleOpen: 'OpenAlice 可以打开了。', + bodyReady: 'OpenAlice 已经具备可用 AI 路径,并处于{{access}}状态。券商账户可以保持断开,等你添加时再启用。', + bodyOpen: 'Alice 可以在没有券商账户的情况下打开。之后想使用工作区聊天和自动研究时,再添加 AI 凭证。', + noBrokerAccess: '不连接券商', + modeBrokerAccess: '{{mode}} 券商访问', + panelTitle: '现在可用', + brokerMode: '券商模式', + noBrokerConnection: '没有券商连接。', + modeSaved: '{{mode}} 已保存。', + brokerWrites: '券商写入', + }, + error: { + saveTradingMode: '保存交易模式失败', + continueWithoutUTA: '无法在不连接 UTA 的情况下继续', + }, + }, theme: { mode: { auto: '自动', light: '浅色', dark: '深色' }, switchTo: '切换到{{mode}}', diff --git a/ui/src/index.css b/ui/src/index.css index 1861dd23..272254e1 100644 --- a/ui/src/index.css +++ b/ui/src/index.css @@ -276,9 +276,22 @@ body { gap: 1rem; align-content: start; overflow-y: auto; + padding-bottom: 0.75rem; padding-top: 0.75rem; } +.oa-onboarding-title { + overflow-wrap: normal; + text-wrap: balance; + word-break: keep-all; +} + +html[lang="ja"] .oa-onboarding-title { + line-break: strict; + overflow-wrap: anywhere; + word-break: normal; +} + .oa-onboarding-completion { position: relative; width: 5rem; @@ -358,7 +371,7 @@ body { @media (min-width: 1024px) { .oa-onboarding-step-layout { - grid-template-columns: minmax(0, 480px) minmax(340px, 360px); + grid-template-columns: minmax(0, 540px) minmax(340px, 360px); } } From e4cc538862dc5f99f9b32a357afc27160031c742 Mon Sep 17 00:00:00 2001 From: angelkawai <123734885+luokerenx4@users.noreply.github.com> Date: Wed, 8 Jul 2026 21:02:40 +0800 Subject: [PATCH 2/2] Add agent runtime readiness probes (#473) * Add onboarding language picker and Electron smoke Merged validated onboarding language picker and packaged Electron onboarding smoke into dev. * Add agent runtime readiness probes * Bundle managed Git Bash on Windows * Smoke packaged managed toolchain in CI --------- Co-authored-by: Ame --- .github/workflows/desktop-package-smoke.yml | 5 + apps/desktop/src/main.ts | 49 ++-- docs/managed-workspace-runtime.md | 48 ++-- package.json | 1 + scripts/assert-desktop-package.mjs | 123 +++++++-- scripts/assert-desktop-package.spec.ts | 97 ++++++++ scripts/smoke-packaged-toolchain.mjs | 136 ++++++++++ scripts/smoke-packaged-toolchain.spec.ts | 107 ++++++++ scripts/vendor-managed-runtime.mjs | 164 ++++++++++-- scripts/vendor-managed-runtime.spec.ts | 49 ++++ src/webui/routes/workspaces-quickchat.spec.ts | 1 + src/webui/routes/workspaces.spec.ts | 89 ++++++- src/webui/routes/workspaces.ts | 52 +++- src/workspaces/adapters/shell.spec.ts | 26 ++ src/workspaces/adapters/shell.ts | 18 +- .../agent-runtime-readiness.spec.ts | 94 +++++++ src/workspaces/agent-runtime-readiness.ts | 214 ++++++++++++++++ src/workspaces/headless-task-win-shim.spec.ts | 51 ++++ src/workspaces/headless-task.ts | 8 +- src/workspaces/service.ts | 234 +++++++++++++++++- ui/src/components/FirstRunGuide.spec.ts | 51 +++- ui/src/components/FirstRunGuide.tsx | 167 ++++++++++--- ui/src/components/first-run-guide-model.ts | 72 ++++-- ui/src/components/workspace/api.ts | 63 +++++ ui/src/demo/handlers/workspaces.ts | 61 +++++ ui/src/i18n/locales/en.ts | 14 ++ ui/src/i18n/locales/ja.ts | 14 ++ ui/src/i18n/locales/zh-Hant.ts | 14 ++ ui/src/i18n/locales/zh.ts | 14 ++ ui/src/pages/ChatLandingPage.tsx | 57 ++++- 30 files changed, 1927 insertions(+), 166 deletions(-) create mode 100644 scripts/assert-desktop-package.spec.ts create mode 100644 scripts/smoke-packaged-toolchain.mjs create mode 100644 scripts/smoke-packaged-toolchain.spec.ts create mode 100644 scripts/vendor-managed-runtime.spec.ts create mode 100644 src/workspaces/adapters/shell.spec.ts create mode 100644 src/workspaces/agent-runtime-readiness.spec.ts create mode 100644 src/workspaces/agent-runtime-readiness.ts create mode 100644 src/workspaces/headless-task-win-shim.spec.ts diff --git a/.github/workflows/desktop-package-smoke.yml b/.github/workflows/desktop-package-smoke.yml index 0a8cbace..b571e7ea 100644 --- a/.github/workflows/desktop-package-smoke.yml +++ b/.github/workflows/desktop-package-smoke.yml @@ -14,6 +14,7 @@ on: - "scripts/assert-desktop-package.mjs" - "scripts/desktop-packaged-smoke.mjs" - "scripts/desktop-packaged-smoke-plan.mjs" + - "scripts/smoke-packaged-toolchain.mjs" - "scripts/vendor-managed-runtime.mjs" - "src/core/paths.ts" - "src/core/runtime-profile.ts" @@ -33,6 +34,7 @@ on: - "scripts/assert-desktop-package.mjs" - "scripts/desktop-packaged-smoke.mjs" - "scripts/desktop-packaged-smoke-plan.mjs" + - "scripts/smoke-packaged-toolchain.mjs" - "scripts/vendor-managed-runtime.mjs" - "src/core/paths.ts" - "src/core/runtime-profile.ts" @@ -92,3 +94,6 @@ jobs: - name: Assert packaged resource layout run: pnpm electron:assert-package + + - name: Smoke packaged managed toolchain + run: pnpm electron:smoke-toolchain diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 2ce84b53..68d3251b 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -414,34 +414,45 @@ async function runRendererOnboardingSmoke(win: BrowserWindow): Promise { clickPrimary() await waitFor('AI access step', () => activeStep() === 'ai' ? true : false) - const mockCredential = await json(await fetch('/api/config/credentials/test', { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ - wireShape: 'openai-chat', - baseUrl: 'https://onboarding.openalice.test/openai-chat', - apiKey: 'oa_test_ok', - model: 'openalice-onboarding-test', - }), - })) - if (!mockCredential.ok) { - throw new Error('onboarding mock credential failed: ' + (mockCredential.error || 'unknown error')) - } + const readiness = await waitFor('Pi runtime readiness', async () => { + const snapshot = await json(await fetch('/api/workspaces/agent-runtime-readiness')) + const row = snapshot.agents?.pi + if (row?.ready && row.status === 'ready') return snapshot + if (row && row.status !== 'unknown' && row.status !== 'checking') { + throw new Error('Pi readiness was ' + row.status + ': ' + (row.message || 'no detail')) + } + return null + }, 60000) + const piReady = readiness.agents.pi + await waitFor('AI ready primary button', async () => { + const snapshot = await json(await fetch('/api/workspaces/agent-runtime-readiness')) + const row = snapshot.agents?.pi + const button = document.querySelector('[data-testid="first-run-guide-primary"]') + return activeStep() === 'ai' && row?.ready === true && button && !button.disabled + }, 60000) clickPrimary() - await waitFor('onboarding credential modal', () => - document.body.innerText.includes('OpenAlice Test Provider') && - Array.from(document.querySelectorAll('input')).some((input) => input.value === 'oa_test_ok') - ) + await waitFor('broker step', () => activeStep() === 'broker' ? true : false) return { ok: true, step: activeStep(), piPath: pi.binPath || null, + runtimeStatus: piReady.status, + runtimeSource: piReady.source, tradingMode: tradingStatus.mode, } - })()`, true) as { ok?: boolean; step?: string; piPath?: string | null; tradingMode?: string } - console.log(`[guardian] electron smoke onboarding → ok step=${result.step ?? ''} mode=${result.tradingMode ?? ''} pi=${result.piPath ?? 'managed'}`) + })()`, true) as { + ok?: boolean + step?: string + piPath?: string | null + runtimeStatus?: string + runtimeSource?: string + tradingMode?: string + } + console.log( + `[guardian] electron smoke onboarding → ok step=${result.step ?? ''} mode=${result.tradingMode ?? ''} pi=${result.piPath ?? 'managed'} runtime=${result.runtimeStatus ?? ''}/${result.runtimeSource ?? ''}`, + ) } app.whenReady().then(async () => { diff --git a/docs/managed-workspace-runtime.md b/docs/managed-workspace-runtime.md index e376520f..7804a3a6 100644 --- a/docs/managed-workspace-runtime.md +++ b/docs/managed-workspace-runtime.md @@ -36,8 +36,9 @@ Node runtime for the backend and workspace bootstrap. - Packaged OpenAlice should include a managed Pi npm runtime on macOS and Windows. -- Windows packaged OpenAlice should include exactly one managed Git+shell - runtime. Do not ship two Git trees. +- Windows packaged OpenAlice should include an incremental managed Git Bash + runtime. Dugite's embedded Git payload is retained in the first slice and + deduplicated only after the new runtime path is stable. - The managed runtime must be discovered through explicit capability/profile injection, not scattered `process.platform` guesses. - Existing Workspace semantics stay intact: Pi is a coding runtime, not a @@ -49,8 +50,7 @@ Node runtime for the backend and workspace bootstrap. ## Non-Goals - Do not make Pi responsible for account/trading safety. -- Do not introduce a second Windows Git runtime beside the existing packaged - Git story. +- Do not remove or rewrite dugite in the first managed Git Bash slice. - Do not require WSL, Git for Windows, Node, npm, pnpm, or system Git as a first-run prerequisite. - Do not rewrite every Git call in the first implementation if a smaller step @@ -83,7 +83,7 @@ Ship: - Electron Node (already present) - managed Pi npm runtime (`@earendil-works/pi-coding-agent`) -- one managed Git for Windows / MinGit style runtime that includes: +- one managed Git for Windows / PortableGit runtime that includes: - `git.exe` - `bash.exe` - `sh.exe` @@ -93,16 +93,17 @@ Ship: Use: - managed `bash.exe` as Pi's `shellPath` -- the same managed Git runtime for workspace bootstrap, Git UI, and agent shell - commands +- the same managed Git runtime for agent shell commands and as the preferred + `LOCAL_GIT_DIRECTORY` for existing dugite call sites Rationale: - Pi's npm package does not provide a shell. OpenAlice must provide one. - The existing dugite Windows native payload contains Git and POSIX-ish tools, but not a reliable full Bash story. -- Shipping both dugite's embedded Git and another Git for Windows tree would be - wasteful and hard to reason about. +- Shipping both dugite's embedded Git and PortableGit is wasteful, but this + incremental slice accepts the temporary duplication so Windows gets a reliable + Bash/toolchain without rewriting launcher Git semantics in the same change. ## Runtime Profile @@ -243,7 +244,7 @@ There are two separate concerns: 1. Git executable/runtime payload 2. JS API used by Alice code and bootstrap scripts -### Phase 1: Replace Windows Git Payload, Keep Dugite API +### Phase 1: Incremental Windows Git Bash, Keep Dugite API Keep `dugite.exec()` call sites initially: @@ -254,15 +255,16 @@ Keep `dugite.exec()` call sites initially: But on Windows packaged builds: - ship the managed Git for Windows runtime as `vendor/git/...` -- exclude or avoid packaging dugite's own `node_modules/dugite/git/**` - set `LOCAL_GIT_DIRECTORY` to the managed Git dir before Alice starts +- keep dugite's own `node_modules/dugite/git/**` payload for now Dugite supports `LOCAL_GIT_DIRECTORY`; with that env set, existing `dugite.exec()` calls should resolve Git from the managed runtime instead of dugite's embedded payload. -This gets us one Windows Git runtime while preserving the established API and -test surface. +This gives Windows a reliable Bash/toolchain while preserving the established +API and test surface. Having both Git payloads in the package is a known +temporary cost, not the final topology. ### Phase 2: OpenAlice Git Wrapper @@ -321,15 +323,11 @@ Responsibilities: - download pinned Pi install package + lockfile from the Pi release - verify checksums - run an isolated `npm ci --omit=dev` under `vendor/pi` -- emit a machine-readable manifest - -Later responsibilities: - -- download pinned Git for Windows / MinGit runtime for Windows +- download pinned Git for Windows / PortableGit runtime on Windows only - verify checksums - unpack into a deterministic vendor directory - prune docs/examples only if license obligations remain satisfied -- extend the machine-readable manifest: +- emit a machine-readable manifest: ```json { @@ -340,9 +338,12 @@ Later responsibilities: }, "git": { "win32-x64": { + "version": "2.55.0.2", + "distribution": "PortableGit", "path": "vendor/git/win32-x64", "gitBin": "cmd/git.exe", - "shellPath": "bin/bash.exe" + "shellPath": "bin/bash.exe", + "shPath": "bin/sh.exe" } } } @@ -487,8 +488,9 @@ Windows acceptance smoke: - Vendor one Windows Git+Shell runtime. - Set `LOCAL_GIT_DIRECTORY`. - Set managed `shellPath` to `bash.exe`. -- Exclude or avoid packaging dugite's embedded Windows Git payload. -- Confirm there is only one Git runtime in the Windows installer. +- Keep dugite's embedded Windows Git payload in this slice. +- Confirm PortableGit is packaged and the temporary dugite duplication is + visible in package assertions/docs. ### Step 5: Optional Dugite API Removal @@ -498,7 +500,7 @@ Windows acceptance smoke: ## Open Questions -- Which Git for Windows / MinGit artifact should be pinned for Windows? +- When should the temporary Windows dugite + PortableGit duplication be removed? - Can `LOCAL_GIT_DIRECTORY` fully cover every existing dugite call in packaged Windows, or do any call sites depend on dugite-specific env setup? - Should managed Pi be updated only with app releases, or should OpenAlice diff --git a/package.json b/package.json index fcff4e13..1112dfaf 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "electron:dev": "pnpm electron:build && pnpm -F @traderalice/desktop dev", "electron:pack": "pnpm electron:build && pnpm vendor:runtime && pnpm -F @traderalice/desktop pack", "electron:assert-package": "node scripts/assert-desktop-package.mjs", + "electron:smoke-toolchain": "node scripts/smoke-packaged-toolchain.mjs", "electron:smoke:packaged": "node scripts/desktop-packaged-smoke.mjs", "electron:smoke:onboarding": "node scripts/desktop-packaged-smoke.mjs --onboarding", "electron:smoke:pty": "node scripts/desktop-pty-smoke.mjs", diff --git a/scripts/assert-desktop-package.mjs b/scripts/assert-desktop-package.mjs index d0b2911e..63d153d6 100644 --- a/scripts/assert-desktop-package.mjs +++ b/scripts/assert-desktop-package.mjs @@ -1,22 +1,22 @@ #!/usr/bin/env node import { existsSync, readFileSync } from 'node:fs' import { dirname, join, relative, resolve } from 'node:path' -import { fileURLToPath } from 'node:url' +import { fileURLToPath, pathToFileURL } from 'node:url' const __filename = fileURLToPath(import.meta.url) const __dirname = dirname(__filename) const repoRoot = resolve(__dirname, '..') const packageRoot = resolve(repoRoot, 'dist', 'electron-app') -const resourceRootCandidates = [ +export const RESOURCE_ROOT_RELATIVE_CANDIDATES = [ 'mac-arm64/OpenAlice.app/Contents/Resources/app', 'mac/OpenAlice.app/Contents/Resources/app', 'OpenAlice.app/Contents/Resources/app', 'win-unpacked/resources/app', 'linux-unpacked/resources/app', -].map((p) => resolve(packageRoot, p)) +] -const requiredFiles = [ +export const BASE_REQUIRED_FILES = [ 'package.json', 'dist/main.js', 'dist/electron/main.js', @@ -31,33 +31,104 @@ const requiredFiles = [ 'vendor/pi/node_modules/@earendil-works/pi-coding-agent/dist/cli.js', ] -const appRoot = resourceRootCandidates.find((p) => existsSync(join(p, 'package.json'))) -if (!appRoot) { - console.error('[desktop-package] app resources root not found. Checked:') - for (const candidate of resourceRootCandidates) { - console.error(` - ${relative(repoRoot, candidate)}`) +export function assertDesktopPackage(options = {}) { + const root = options.packageRoot ?? packageRoot + const repo = options.repoRoot ?? repoRoot + const candidates = RESOURCE_ROOT_RELATIVE_CANDIDATES.map((p) => resolve(root, p)) + const appRoot = options.appRoot ?? candidates.find((p) => existsSync(join(p, 'package.json'))) + const errors = [] + if (!appRoot) { + errors.push('[desktop-package] app resources root not found. Checked:') + for (const candidate of candidates) { + errors.push(` - ${relative(repo, candidate)}`) + } + return { ok: false, errors, appRoot: null, manifest: null, platform: null, platformArch: null } + } + + const platform = options.platform ?? platformFromAppRoot(appRoot) + const arch = options.arch ?? process.arch + const platformArch = platform === 'win32' ? `win32-${arch}` : null + const requiredFiles = [...BASE_REQUIRED_FILES, ...platformRequiredFiles(platform, platformArch)] + const missing = requiredFiles.filter((file) => !existsSync(join(appRoot, file))) + if (missing.length > 0) { + errors.push(`[desktop-package] ${relative(repo, appRoot)} is missing required packaged files:`) + for (const file of missing) errors.push(` - ${file}`) + } + + const manifestPath = join(appRoot, 'vendor', 'manifest.json') + let manifest = null + try { + manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) + } catch (err) { + errors.push(`[desktop-package] failed to read vendor manifest: ${err instanceof Error ? err.message : String(err)}`) } - process.exit(1) + + if (manifest?.pi?.mode !== 'npm') { + errors.push(`[desktop-package] expected manifest.pi.mode="npm", got ${JSON.stringify(manifest?.pi?.mode)}`) + } + const piCli = typeof manifest?.pi?.cli === 'string' ? manifest.pi.cli.replaceAll('\\', '/') : null + if (piCli !== 'vendor/pi/node_modules/@earendil-works/pi-coding-agent/dist/cli.js') { + errors.push(`[desktop-package] unexpected manifest.pi.cli: ${JSON.stringify(manifest?.pi?.cli)}`) + } + + if (platform === 'win32' && platformArch) { + const git = manifest?.git?.[platformArch] + if (!git) { + errors.push(`[desktop-package] expected manifest.git.${platformArch} for Windows managed Git Bash`) + } else { + if (git.path !== `vendor/git/${platformArch}`) { + errors.push(`[desktop-package] unexpected manifest.git.${platformArch}.path: ${JSON.stringify(git.path)}`) + } + if (normalizeManifestPath(git.gitBin) !== 'cmd/git.exe') { + errors.push(`[desktop-package] unexpected manifest.git.${platformArch}.gitBin: ${JSON.stringify(git.gitBin)}`) + } + if (normalizeManifestPath(git.shellPath) !== 'bin/bash.exe') { + errors.push(`[desktop-package] unexpected manifest.git.${platformArch}.shellPath: ${JSON.stringify(git.shellPath)}`) + } + if (normalizeManifestPath(git.shPath) !== 'bin/sh.exe') { + errors.push(`[desktop-package] unexpected manifest.git.${platformArch}.shPath: ${JSON.stringify(git.shPath)}`) + } + } + } + + return { ok: errors.length === 0, errors, appRoot, manifest, platform, platformArch } +} + +export function platformFromAppRoot(appRoot) { + const normalized = appRoot.replaceAll('\\', '/') + if (normalized.includes('/win-unpacked/')) return 'win32' + if (normalized.includes('/linux-unpacked/')) return 'linux' + if (normalized.includes('.app/Contents/Resources/app')) return 'darwin' + return process.platform } -const missing = requiredFiles.filter((file) => !existsSync(join(appRoot, file))) -if (missing.length > 0) { - console.error(`[desktop-package] ${relative(repoRoot, appRoot)} is missing required packaged files:`) - for (const file of missing) console.error(` - ${file}`) - process.exit(1) +function platformRequiredFiles(platform, platformArch) { + if (platform !== 'win32' || !platformArch) return [] + return [ + `vendor/git/${platformArch}/cmd/git.exe`, + `vendor/git/${platformArch}/bin/bash.exe`, + `vendor/git/${platformArch}/bin/sh.exe`, + ] } -const manifestPath = join(appRoot, 'vendor', 'manifest.json') -const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) -if (manifest?.pi?.mode !== 'npm') { - console.error(`[desktop-package] expected manifest.pi.mode="npm", got ${JSON.stringify(manifest?.pi?.mode)}`) - process.exit(1) +function normalizeManifestPath(value) { + return typeof value === 'string' ? value.replaceAll('\\', '/') : null } -const piCli = typeof manifest?.pi?.cli === 'string' ? manifest.pi.cli.replaceAll('\\', '/') : null -if (piCli !== 'vendor/pi/node_modules/@earendil-works/pi-coding-agent/dist/cli.js') { - console.error(`[desktop-package] unexpected manifest.pi.cli: ${JSON.stringify(manifest?.pi?.cli)}`) - process.exit(1) + +function main() { + const result = assertDesktopPackage() + if (!result.ok) { + for (const error of result.errors) console.error(error) + process.exit(1) + } + console.log(`[desktop-package] app resources OK: ${relative(repoRoot, result.appRoot)}`) + console.log(`[desktop-package] managed Pi: ${result.manifest.pi.version} (${result.manifest.pi.mode})`) + if (result.platform === 'win32') { + const git = result.manifest.git[result.platformArch] + console.log(`[desktop-package] managed Git Bash: ${git.version} (${result.platformArch})`) + } } -console.log(`[desktop-package] app resources OK: ${relative(repoRoot, appRoot)}`) -console.log(`[desktop-package] managed Pi: ${manifest.pi.version} (${manifest.pi.mode})`) +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main() +} diff --git a/scripts/assert-desktop-package.spec.ts b/scripts/assert-desktop-package.spec.ts new file mode 100644 index 00000000..6a6a66f9 --- /dev/null +++ b/scripts/assert-desktop-package.spec.ts @@ -0,0 +1,97 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' + +import { describe, expect, it } from 'vitest' + +import { BASE_REQUIRED_FILES, assertDesktopPackage } from './assert-desktop-package.mjs' + +const PI_CLI = 'vendor/pi/node_modules/@earendil-works/pi-coding-agent/dist/cli.js' + +function writePackageFile(appRoot: string, file: string, content = '') { + const path = join(appRoot, file) + mkdirSync(dirname(path), { recursive: true }) + writeFileSync(path, content) +} + +function writeBasePackage(appRoot: string, manifest: unknown) { + for (const file of BASE_REQUIRED_FILES) { + if (file === 'vendor/manifest.json') continue + writePackageFile(appRoot, file) + } + writePackageFile(appRoot, 'vendor/manifest.json', JSON.stringify(manifest)) +} + +function piManifest() { + return { + pi: { + version: '0.80.3', + mode: 'npm', + cli: PI_CLI, + }, + } +} + +describe('assertDesktopPackage', () => { + it('does not require vendor Git in macOS packages', () => { + const root = mkdtempSync(join(tmpdir(), 'openalice-package-mac-')) + try { + const appRoot = join(root, 'mac-arm64/OpenAlice.app/Contents/Resources/app') + writeBasePackage(appRoot, piManifest()) + + const result = assertDesktopPackage({ packageRoot: root, repoRoot: root, arch: 'arm64' }) + + expect(result.ok).toBe(true) + expect(result.platform).toBe('darwin') + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) + + it('requires managed Git Bash files and manifest metadata in Windows packages', () => { + const root = mkdtempSync(join(tmpdir(), 'openalice-package-win-missing-')) + try { + const appRoot = join(root, 'win-unpacked/resources/app') + writeBasePackage(appRoot, piManifest()) + + const result = assertDesktopPackage({ packageRoot: root, repoRoot: root, arch: 'x64' }) + + expect(result.ok).toBe(false) + expect(result.errors.join('\n')).toContain('vendor/git/win32-x64/cmd/git.exe') + expect(result.errors.join('\n')).toContain('vendor/git/win32-x64/bin/bash.exe') + expect(result.errors.join('\n')).toContain('expected manifest.git.win32-x64') + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) + + it('accepts Windows packages with PortableGit files', () => { + const root = mkdtempSync(join(tmpdir(), 'openalice-package-win-ok-')) + try { + const appRoot = join(root, 'win-unpacked/resources/app') + writeBasePackage(appRoot, { + ...piManifest(), + git: { + 'win32-x64': { + version: '2.55.0.2', + path: 'vendor/git/win32-x64', + gitBin: 'cmd/git.exe', + shellPath: 'bin/bash.exe', + shPath: 'bin/sh.exe', + }, + }, + }) + writePackageFile(appRoot, 'vendor/git/win32-x64/cmd/git.exe') + writePackageFile(appRoot, 'vendor/git/win32-x64/bin/bash.exe') + writePackageFile(appRoot, 'vendor/git/win32-x64/bin/sh.exe') + + const result = assertDesktopPackage({ packageRoot: root, repoRoot: root, arch: 'x64' }) + + expect(result.ok).toBe(true) + expect(result.platform).toBe('win32') + expect(result.platformArch).toBe('win32-x64') + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) +}) diff --git a/scripts/smoke-packaged-toolchain.mjs b/scripts/smoke-packaged-toolchain.mjs new file mode 100644 index 00000000..12c3a477 --- /dev/null +++ b/scripts/smoke-packaged-toolchain.mjs @@ -0,0 +1,136 @@ +#!/usr/bin/env node +import { spawnSync } from 'node:child_process' +import { existsSync } from 'node:fs' +import { delimiter, dirname, join, relative, resolve } from 'node:path' +import { pathToFileURL } from 'node:url' + +import { assertDesktopPackage } from './assert-desktop-package.mjs' + +export function buildPackagedToolchainSmokePlan(packageResult) { + const errors = [...packageResult.errors] + const commands = [] + if (!packageResult.ok || !packageResult.appRoot || !packageResult.manifest) { + return { ok: false, errors, commands } + } + + const electron = packagedElectronExecutable(packageResult.appRoot, packageResult.platform) + if (!electron || !existsSync(electron)) { + errors.push(`[packaged-toolchain] packaged Electron executable not found for ${packageResult.platform}`) + return { ok: false, errors, commands } + } + + commands.push({ + label: 'packaged Electron Node mode', + command: electron, + args: ['-e', 'console.log("OPENALICE_ELECTRON_NODE_OK " + process.versions.node)'], + env: { ELECTRON_RUN_AS_NODE: '1' }, + expectStdout: /OPENALICE_ELECTRON_NODE_OK \d+\.\d+\.\d+/, + }) + + const piCli = join(packageResult.appRoot, packageResult.manifest.pi.cli) + commands.push({ + label: 'managed Pi through packaged Electron Node', + command: electron, + args: [piCli, '--version'], + env: { ELECTRON_RUN_AS_NODE: '1' }, + expectStdout: /\b0\.80\.3\b/, + }) + + if (packageResult.platform === 'win32') { + const git = packageResult.manifest.git?.[packageResult.platformArch] + if (!git) { + errors.push(`[packaged-toolchain] missing Windows managed Git manifest entry ${packageResult.platformArch}`) + return { ok: false, errors, commands } + } + const gitRoot = join(packageResult.appRoot, git.path) + const gitExe = join(gitRoot, git.gitBin) + const bashExe = join(gitRoot, git.shellPath) + const shExe = join(gitRoot, git.shPath) + const toolchainPath = (Array.isArray(git.toolchainPaths) ? git.toolchainPaths : ['cmd', 'bin', 'usr/bin']) + .map((entry) => join(gitRoot, entry)) + .join(delimiter) + const winEnv = { + PATH: [toolchainPath, process.env['PATH']].filter(Boolean).join(delimiter), + CHERE_INVOKING: '1', + MSYSTEM: packageResult.platformArch.endsWith('arm64') ? 'CLANGARM64' : 'MINGW64', + } + + commands.push({ + label: 'managed git.exe', + command: gitExe, + args: ['--version'], + expectStdout: /^git version /m, + }) + commands.push({ + label: 'managed bash.exe', + command: bashExe, + args: ['--version'], + expectStdout: /GNU bash/, + }) + commands.push({ + label: 'managed sh.exe can resolve git and bash on PATH', + command: shExe, + args: [ + '-lc', + 'printf "OPENALICE_SH_OK\\n"; git --version; bash --version | head -n 1; command -v git; command -v bash', + ], + env: winEnv, + expectStdout: /OPENALICE_SH_OK[\s\S]*git version [\s\S]*GNU bash/, + }) + } + + return { ok: errors.length === 0, errors, commands } +} + +export function packagedElectronExecutable(appRoot, platform) { + if (platform === 'win32') return resolve(appRoot, '..', '..', 'OpenAlice.exe') + if (platform === 'darwin') return resolve(appRoot, '..', '..', 'MacOS', 'OpenAlice') + if (platform === 'linux') return resolve(appRoot, '..', '..', 'open-alice') + return null +} + +function runCommand(repoRoot, appRoot, spec) { + console.log(`[packaged-toolchain] ${spec.label}`) + console.log(`[packaged-toolchain] command: ${relative(repoRoot, spec.command)} ${spec.args.join(' ')}`) + const result = spawnSync(spec.command, spec.args, { + cwd: appRoot, + encoding: 'utf8', + env: { ...process.env, ...spec.env }, + }) + const stdout = result.stdout ?? '' + const stderr = result.stderr ?? '' + if (stdout.trim()) console.log(stdout.trim()) + if (stderr.trim()) console.error(stderr.trim()) + if (result.error) { + throw new Error(`${spec.label} failed to start: ${result.error.message}`) + } + if (result.status !== 0) { + throw new Error(`${spec.label} exited ${result.status ?? 'unknown'}${result.signal ? ` (${result.signal})` : ''}`) + } + if (spec.expectStdout && !spec.expectStdout.test(stdout)) { + throw new Error(`${spec.label} stdout did not match ${spec.expectStdout}: ${JSON.stringify(stdout)}`) + } +} + +function main() { + const packageResult = assertDesktopPackage() + const plan = buildPackagedToolchainSmokePlan(packageResult) + if (!plan.ok) { + for (const error of plan.errors) console.error(error) + process.exit(1) + } + const repoRoot = resolve(import.meta.dirname, '..') + for (const command of plan.commands) { + runCommand(repoRoot, packageResult.appRoot, command) + } + console.log('[packaged-toolchain] smoke OK') +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + try { + main() + } catch (err) { + console.error(`[packaged-toolchain] ${err instanceof Error ? err.message : String(err)}`) + process.exit(1) + } +} diff --git a/scripts/smoke-packaged-toolchain.spec.ts b/scripts/smoke-packaged-toolchain.spec.ts new file mode 100644 index 00000000..d139fd03 --- /dev/null +++ b/scripts/smoke-packaged-toolchain.spec.ts @@ -0,0 +1,107 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' + +import { describe, expect, it } from 'vitest' + +import { + buildPackagedToolchainSmokePlan, + packagedElectronExecutable, +} from './smoke-packaged-toolchain.mjs' + +function touch(path: string) { + mkdirSync(dirname(path), { recursive: true }) + writeFileSync(path, '') +} + +describe('buildPackagedToolchainSmokePlan', () => { + it('builds a macOS packaged Electron + Pi smoke plan', () => { + const root = mkdtempSync(join(tmpdir(), 'openalice-toolchain-mac-')) + try { + const appRoot = join(root, 'OpenAlice.app/Contents/Resources/app') + touch(join(root, 'OpenAlice.app/Contents/MacOS/OpenAlice')) + const plan = buildPackagedToolchainSmokePlan({ + ok: true, + errors: [], + appRoot, + platform: 'darwin', + platformArch: null, + manifest: { + pi: { + cli: 'vendor/pi/node_modules/@earendil-works/pi-coding-agent/dist/cli.js', + }, + }, + }) + + expect(plan.ok).toBe(true) + expect(plan.commands.map((command) => command.label)).toEqual([ + 'packaged Electron Node mode', + 'managed Pi through packaged Electron Node', + ]) + expect(packagedElectronExecutable(appRoot, 'darwin')?.replaceAll('\\', '/')) + .toContain('OpenAlice.app/Contents/MacOS/OpenAlice') + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) + + it('adds Windows managed Git Bash command probes', () => { + const root = mkdtempSync(join(tmpdir(), 'openalice-toolchain-win-')) + try { + const appRoot = join(root, 'win-unpacked/resources/app') + touch(join(root, 'win-unpacked/OpenAlice.exe')) + const plan = buildPackagedToolchainSmokePlan({ + ok: true, + errors: [], + appRoot, + platform: 'win32', + platformArch: 'win32-x64', + manifest: { + pi: { + cli: 'vendor/pi/node_modules/@earendil-works/pi-coding-agent/dist/cli.js', + }, + git: { + 'win32-x64': { + path: 'vendor/git/win32-x64', + gitBin: 'cmd/git.exe', + shellPath: 'bin/bash.exe', + shPath: 'bin/sh.exe', + toolchainPaths: ['cmd', 'bin', 'usr/bin', 'mingw64/bin'], + }, + }, + }, + }) + + expect(plan.ok).toBe(true) + expect(plan.commands.map((command) => command.label)).toEqual([ + 'packaged Electron Node mode', + 'managed Pi through packaged Electron Node', + 'managed git.exe', + 'managed bash.exe', + 'managed sh.exe can resolve git and bash on PATH', + ]) + expect(plan.commands[2].command.replaceAll('\\', '/')).toContain('vendor/git/win32-x64/cmd/git.exe') + expect(plan.commands[4].env?.PATH.replaceAll('\\', '/')).toContain('vendor/git/win32-x64/mingw64/bin') + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) + + it('refuses a missing packaged executable', () => { + const plan = buildPackagedToolchainSmokePlan({ + ok: true, + errors: [], + appRoot: '/tmp/missing/OpenAlice.app/Contents/Resources/app', + platform: 'darwin', + platformArch: null, + manifest: { + pi: { + cli: 'vendor/pi/node_modules/@earendil-works/pi-coding-agent/dist/cli.js', + }, + }, + }) + + expect(plan.ok).toBe(false) + expect(plan.errors.join('\n')).toContain('packaged Electron executable not found') + }) +}) diff --git a/scripts/vendor-managed-runtime.mjs b/scripts/vendor-managed-runtime.mjs index a9a47542..f4942718 100644 --- a/scripts/vendor-managed-runtime.mjs +++ b/scripts/vendor-managed-runtime.mjs @@ -2,9 +2,10 @@ import { spawnSync } from 'node:child_process' import { createHash } from 'node:crypto' import { existsSync, readFileSync } from 'node:fs' -import { mkdir, rm, writeFile } from 'node:fs/promises' -import { dirname, relative, resolve } from 'node:path' -import { fileURLToPath } from 'node:url' +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { basename, dirname, relative, resolve } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' const __filename = fileURLToPath(import.meta.url) const __dirname = dirname(__filename) @@ -21,11 +22,8 @@ const piCliPath = resolve( 'cli.js', ) -const args = new Set(process.argv.slice(2)) -const force = args.has('--force') -const help = args.has('--help') || args.has('-h') const knownArgs = new Set(['--force', '--help', '-h']) -const unknownArgs = [...args].filter((arg) => !knownArgs.has(arg)) +let force = false const PI_VERSION = '0.80.3' const PI_RELEASE_BASE = `https://github.com/earendil-works/pi/releases/download/v${PI_VERSION}` @@ -42,6 +40,21 @@ const PI_ASSETS = [ }, ] +const PORTABLE_GIT_VERSION = '2.55.0.2' +const PORTABLE_GIT_TAG = 'v2.55.0.windows.2' +const WINDOWS_GIT_RUNTIMES = { + x64: { + platformArch: 'win32-x64', + assetName: `PortableGit-${PORTABLE_GIT_VERSION}-64-bit.7z.exe`, + sha256: 'b20d42da3afa228e9fa6174480de820282667e799440d655e308f700dfa0d0df', + }, + arm64: { + platformArch: 'win32-arm64', + assetName: `PortableGit-${PORTABLE_GIT_VERSION}-arm64.7z.exe`, + sha256: '65b913a56a62d7a91fc11a2eecb08422aaa34332d3b2ea39457d2eda02c2f99c', + }, +} + function printHelp() { console.log(`Usage: pnpm vendor:runtime [options] @@ -53,21 +66,28 @@ Options: `) } -if (help) { - printHelp() - process.exit(0) -} - -if (unknownArgs.length > 0) { - console.error(`[vendor-runtime] unknown option(s): ${unknownArgs.join(', ')}`) - printHelp() - process.exit(1) -} - async function main() { + parseArgs(process.argv.slice(2)) await mkdir(vendorRoot, { recursive: true }) await vendorPi() - await writeManifest() + const gitSpec = await vendorWindowsGit() + await writeManifest(gitSpec) +} + +function parseArgs(argv) { + const args = new Set(argv) + const help = args.has('--help') || args.has('-h') + const unknownArgs = [...args].filter((arg) => !knownArgs.has(arg)) + force = args.has('--force') + if (help) { + printHelp() + process.exit(0) + } + if (unknownArgs.length > 0) { + console.error(`[vendor-runtime] unknown option(s): ${unknownArgs.join(', ')}`) + printHelp() + process.exit(1) + } } async function vendorPi() { @@ -104,6 +124,49 @@ async function vendorPi() { console.log(`[vendor-runtime] Pi CLI -> ${relativeForLog(piCliPath)}`) } +async function vendorWindowsGit() { + const spec = resolveWindowsGitRuntimeSpec() + if (!spec) { + console.log('[vendor-runtime] managed Git Bash skipped on non-Windows host') + return null + } + + const existingManifest = readManifest() + if ( + !force && + existingManifest?.git?.[spec.platformArch]?.version === spec.version && + requiredWindowsGitFiles(spec).every((file) => existsSync(resolve(repoRoot, spec.root, file))) + ) { + console.log(`[vendor-runtime] Git for Windows ${spec.version} already present at ${spec.root}`) + return spec + } + + const gitRoot = resolve(repoRoot, spec.root) + console.log(`[vendor-runtime] preparing Git for Windows ${spec.version} at ${relativeForLog(gitRoot)}`) + await rm(gitRoot, { recursive: true, force: true }) + await mkdir(gitRoot, { recursive: true }) + + const bytes = await download(spec.url) + verifySha256(bytes, spec.sha256, spec.url) + + const tmpRoot = await mkdtemp(resolve(tmpdir(), 'openalice-portablegit-')) + const archivePath = resolve(tmpRoot, basename(spec.url)) + try { + await writeFile(archivePath, bytes) + run('extract Git for Windows PortableGit', archivePath, ['-y', `-o${gitRoot}`]) + } finally { + await rm(tmpRoot, { recursive: true, force: true }) + } + + const missing = requiredWindowsGitFiles(spec) + .filter((file) => !existsSync(resolve(repoRoot, spec.root, file))) + if (missing.length > 0) { + throw new Error(`Git for Windows extraction missing required files: ${missing.join(', ')}`) + } + console.log(`[vendor-runtime] Git for Windows -> ${relativeForLog(gitRoot)}`) + return spec +} + async function download(url) { console.log(`[vendor-runtime] download ${url}`) const res = await fetch(url) @@ -140,7 +203,7 @@ function readManifest() { } } -async function writeManifest() { +export function buildVendorRuntimeManifest(gitSpec = null) { const manifest = { pi: { version: PI_VERSION, @@ -150,10 +213,61 @@ async function writeManifest() { node: 'electron', }, } + if (gitSpec) { + manifest.git = { + [gitSpec.platformArch]: { + version: gitSpec.version, + distribution: 'PortableGit', + url: gitSpec.url, + sha256: gitSpec.sha256, + path: gitSpec.root, + gitBin: gitSpec.gitBin, + shellPath: gitSpec.shellPath, + shPath: gitSpec.shPath, + toolchainPaths: gitSpec.toolchainPaths, + }, + } + } + return manifest +} + +async function writeManifest(gitSpec) { + const manifest = buildVendorRuntimeManifest(gitSpec) await writeFile(manifestPath, JSON.stringify(manifest, null, 2) + '\n') console.log(`[vendor-runtime] manifest -> ${relativeForLog(manifestPath)}`) } +export function resolveWindowsGitRuntimeSpec(opts = {}) { + const platform = opts.platform ?? process.platform + const arch = opts.arch ?? process.arch + if (platform !== 'win32') return null + const runtime = WINDOWS_GIT_RUNTIMES[arch] + if (!runtime) { + throw new Error(`unsupported Windows architecture for managed Git runtime: ${arch}`) + } + const root = `vendor/git/${runtime.platformArch}` + return { + version: PORTABLE_GIT_VERSION, + platformArch: runtime.platformArch, + url: `https://github.com/git-for-windows/git/releases/download/${PORTABLE_GIT_TAG}/${runtime.assetName}`, + sha256: runtime.sha256, + root, + gitBin: 'cmd/git.exe', + shellPath: 'bin/bash.exe', + shPath: 'bin/sh.exe', + toolchainPaths: [ + 'cmd', + 'bin', + 'usr/bin', + arch === 'arm64' ? 'clangarm64/bin' : 'mingw64/bin', + ], + } +} + +export function requiredWindowsGitFiles(spec) { + return [spec.gitBin, spec.shellPath, spec.shPath] +} + function relativeForManifest(path) { return relative(repoRoot, path).replaceAll('\\', '/') } @@ -162,7 +276,9 @@ function relativeForLog(path) { return relative(repoRoot, path) } -main().catch((err) => { - console.error(`[vendor-runtime] ${err instanceof Error ? err.message : String(err)}`) - process.exit(1) -}) +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main().catch((err) => { + console.error(`[vendor-runtime] ${err instanceof Error ? err.message : String(err)}`) + process.exit(1) + }) +} diff --git a/scripts/vendor-managed-runtime.spec.ts b/scripts/vendor-managed-runtime.spec.ts new file mode 100644 index 00000000..8e955712 --- /dev/null +++ b/scripts/vendor-managed-runtime.spec.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest' + +import { + buildVendorRuntimeManifest, + requiredWindowsGitFiles, + resolveWindowsGitRuntimeSpec, +} from './vendor-managed-runtime.mjs' + +describe('vendor managed runtime helpers', () => { + it('does not select a managed Git runtime on non-Windows hosts', () => { + expect(resolveWindowsGitRuntimeSpec({ platform: 'darwin', arch: 'arm64' })).toBeNull() + expect(resolveWindowsGitRuntimeSpec({ platform: 'linux', arch: 'x64' })).toBeNull() + }) + + it('pins the Windows x64 PortableGit runtime', () => { + const spec = resolveWindowsGitRuntimeSpec({ platform: 'win32', arch: 'x64' }) + + expect(spec).toMatchObject({ + version: '2.55.0.2', + platformArch: 'win32-x64', + root: 'vendor/git/win32-x64', + gitBin: 'cmd/git.exe', + shellPath: 'bin/bash.exe', + shPath: 'bin/sh.exe', + sha256: 'b20d42da3afa228e9fa6174480de820282667e799440d655e308f700dfa0d0df', + }) + expect(spec?.url).toContain('PortableGit-2.55.0.2-64-bit.7z.exe') + expect(requiredWindowsGitFiles(spec!)).toEqual([ + 'cmd/git.exe', + 'bin/bash.exe', + 'bin/sh.exe', + ]) + }) + + it('writes Git metadata only when a Windows Git spec is provided', () => { + const macManifest = buildVendorRuntimeManifest(null) + expect(macManifest.git).toBeUndefined() + + const spec = resolveWindowsGitRuntimeSpec({ platform: 'win32', arch: 'x64' }) + const winManifest = buildVendorRuntimeManifest(spec) + expect(winManifest.git['win32-x64']).toMatchObject({ + distribution: 'PortableGit', + path: 'vendor/git/win32-x64', + gitBin: 'cmd/git.exe', + shellPath: 'bin/bash.exe', + shPath: 'bin/sh.exe', + }) + }) +}) diff --git a/src/webui/routes/workspaces-quickchat.spec.ts b/src/webui/routes/workspaces-quickchat.spec.ts index 03ef883a..e88748cb 100644 --- a/src/webui/routes/workspaces-quickchat.spec.ts +++ b/src/webui/routes/workspaces-quickchat.spec.ts @@ -70,6 +70,7 @@ function build(opts: { workspaces?: any[]; opencodeConfig?: WorkspaceAiCred | nu pool: { spawn, get: vi.fn(() => undefined) }, publicMeta: vi.fn(async () => META), config: { launcherRepoRoot: '/repo' }, + getAgentRuntimeReadiness: vi.fn(() => ({ agents: {}, overallReady: false, checkedAt: null })), } as unknown as WorkspaceService; return { app: createWorkspaceRoutes(svc), opencode, spawn, creator }; } diff --git a/src/webui/routes/workspaces.spec.ts b/src/webui/routes/workspaces.spec.ts index 364759dd..4e5d0650 100644 --- a/src/webui/routes/workspaces.spec.ts +++ b/src/webui/routes/workspaces.spec.ts @@ -26,7 +26,13 @@ const HEADLESS_RESULT = { }; function build( - opts: { meta?: any; adapters?: Record; resolveTo?: any; dispatch?: any } = {}, + opts: { + meta?: any; + adapters?: Record; + resolveTo?: any; + dispatch?: any; + runtimeReadiness?: any; + } = {}, ) { const claude = { id: 'claude', @@ -38,6 +44,39 @@ function build( const adapters = opts.adapters ?? { claude }; const runHeadlessTask = vi.fn(async () => HEADLESS_RESULT); const dispatchHeadlessTask = opts.dispatch ?? vi.fn(async () => ({ taskId: 'task-1' })); + const runtimeReadiness = opts.runtimeReadiness ?? { + agents: { + claude: { + agent: 'claude', + displayName: 'Claude', + installed: true, + binPath: '/usr/bin/claude', + status: 'unknown', + ready: false, + source: 'unknown', + checkedAt: null, + durationMs: null, + }, + }, + overallReady: false, + checkedAt: null, + }; + const getAgentRuntimeReadiness = vi.fn(() => runtimeReadiness); + const probeAgentRuntimeReadiness = vi.fn(async () => ({ + ...runtimeReadiness, + overallReady: true, + checkedAt: '2026-07-08T00:00:00.000Z', + agents: { + ...runtimeReadiness.agents, + claude: { + ...runtimeReadiness.agents.claude, + status: 'ready', + ready: true, + source: 'global-login', + checkedAt: '2026-07-08T00:00:00.000Z', + }, + }, + })); const svc = { registry: { get: (id: string) => (id === 'ws-1' ? meta : undefined) }, adapters: { get: (a: string) => adapters[a] }, @@ -45,12 +84,20 @@ function build( config: { launcherRepoRoot: '/repo' }, runHeadlessTask, dispatchHeadlessTask, + getAgentRuntimeReadiness, + probeAgentRuntimeReadiness, publicMeta: vi.fn(async (m: any) => { const res = await readWorkspaceMetadata(m.dir); return { ...m, ...(res.ok ? res.metadata : {}) }; }), } as unknown as WorkspaceService; - return { app: createWorkspaceRoutes(svc), runHeadlessTask, dispatchHeadlessTask }; + return { + app: createWorkspaceRoutes(svc), + runHeadlessTask, + dispatchHeadlessTask, + getAgentRuntimeReadiness, + probeAgentRuntimeReadiness, + }; } async function post(app: any, path: string, body?: unknown) { @@ -111,6 +158,44 @@ describe('PATCH /:id/metadata', () => { }); }); +describe('agent runtime readiness routes', () => { + it('GET returns the cached snapshot without triggering a probe', async () => { + const { app, getAgentRuntimeReadiness, probeAgentRuntimeReadiness } = build(); + const res = await app.request('/agent-runtime-readiness'); + const body = await res.json(); + + expect(res.status).toBe(200); + expect(body.overallReady).toBe(false); + expect(getAgentRuntimeReadiness).toHaveBeenCalledOnce(); + expect(probeAgentRuntimeReadiness).not.toHaveBeenCalled(); + }); + + it('POST /probe runs all runtimes by default or one requested runtime', async () => { + const { app, probeAgentRuntimeReadiness } = build(); + const all = await post(app, '/agent-runtime-readiness/probe', {}); + const one = await post(app, '/agent-runtime-readiness/probe', { agent: 'claude' }); + + expect(all.status).toBe(200); + expect(all.body.overallReady).toBe(true); + expect(one.status).toBe(200); + expect(probeAgentRuntimeReadiness).toHaveBeenNthCalledWith(1, undefined); + expect(probeAgentRuntimeReadiness).toHaveBeenNthCalledWith(2, 'claude'); + }); + + it('POST /probe rejects unknown or utility agents before probing', async () => { + const shell = { id: 'shell', kind: 'utility', capabilities: {} }; + const { app, probeAgentRuntimeReadiness } = build({ adapters: { shell } }); + const unknown = await post(app, '/agent-runtime-readiness/probe', { agent: 'ghost' }); + const utility = await post(app, '/agent-runtime-readiness/probe', { agent: 'shell' }); + + expect(unknown.status).toBe(400); + expect(unknown.body.error).toBe('unknown_agent'); + expect(utility.status).toBe(400); + expect(utility.body.error).toBe('unknown_agent'); + expect(probeAgentRuntimeReadiness).not.toHaveBeenCalled(); + }); +}); + describe('POST /:id/headless', () => { it('404 on a malformed workspace id', async () => { const { app } = build(); diff --git a/src/webui/routes/workspaces.ts b/src/webui/routes/workspaces.ts index 59c36262..8561d711 100644 --- a/src/webui/routes/workspaces.ts +++ b/src/webui/routes/workspaces.ts @@ -165,14 +165,22 @@ export function createWorkspaceRoutes(svc: WorkspaceService): Hono { return { ok: false, status: 400, body: { error: 'unknown_agent', message: `no adapter: ${agentId}` } }; } const adapter = svc.resolveAdapter(meta, agentId); + const runtimeReadiness = svc.getAgentRuntimeReadiness().agents[adapter.id]; + const runtimeIsGloballyReady = + runtimeReadiness?.ready === true && + (runtimeReadiness.source === 'global-config' || + runtimeReadiness.source === 'global-login' || + runtimeReadiness.source === 'managed-runtime'); try { - await ensureAgentCredentialReady({ - meta, - agentId: adapter.id, - adapter, - ...(opts.credentialSlug ? { pickedCredentialSlug: opts.credentialSlug } : {}), - logger: launcherLogger, - }); + if (!runtimeIsGloballyReady) { + await ensureAgentCredentialReady({ + meta, + agentId: adapter.id, + adapter, + ...(opts.credentialSlug ? { pickedCredentialSlug: opts.credentialSlug } : {}), + logger: launcherLogger, + }); + } } catch (err) { if (err instanceof AgentCredentialError) { return { ok: false, status: 400, body: err.toBody() }; @@ -372,6 +380,36 @@ export function createWorkspaceRoutes(svc: WorkspaceService): Hono { }); }); + app.get('/agent-runtime-readiness', (c) => { + return c.json(svc.getAgentRuntimeReadiness()); + }); + + app.post('/agent-runtime-readiness/probe', async (c) => { + const body = await safeJson(c).catch(() => null); + const fields = body && typeof body === 'object' ? (body as Record) : {}; + const rawAgent = fields['agent']; + let agent: string | undefined; + if (rawAgent !== undefined) { + if (typeof rawAgent !== 'string' || rawAgent.length === 0) { + return c.json({ error: 'bad_request', message: 'agent must be a non-empty string' }, 400); + } + const adapter = svc.adapters.get(rawAgent); + if (!adapter || !isAgentRuntime(adapter)) { + return c.json({ error: 'unknown_agent', message: `no agent runtime: ${rawAgent}` }, 400); + } + agent = rawAgent; + } + try { + return c.json(await svc.probeAgentRuntimeReadiness(agent)); + } catch (err) { + launcherLogger.warn('agent_runtime_readiness.probe_failed', { agent, err }); + return c.json( + { error: 'runtime_readiness_probe_failed', message: (err as Error).message }, + 500, + ); + } + }); + // ── workspaces collection ──────────────────────────────────────────────── app.get('/', async (c) => { diff --git a/src/workspaces/adapters/shell.spec.ts b/src/workspaces/adapters/shell.spec.ts new file mode 100644 index 00000000..1faad04b --- /dev/null +++ b/src/workspaces/adapters/shell.spec.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest'; + +import { composeShellCommand } from './shell.js'; + +describe('composeShellCommand', () => { + it('uses the managed shell when provided', () => { + expect(composeShellCommand({ + OPENALICE_MANAGED_SHELL_PATH: 'C:\\OpenAlice\\vendor\\git\\win32-x64\\bin\\bash.exe', + SHELL: '/bin/zsh', + }, 'win32')).toEqual([ + 'C:\\OpenAlice\\vendor\\git\\win32-x64\\bin\\bash.exe', + '--login', + ]); + }); + + it('falls back to ComSpec on unmanaged Windows hosts', () => { + expect(composeShellCommand({ + ComSpec: 'C:\\Windows\\System32\\cmd.exe', + }, 'win32')).toEqual(['C:\\Windows\\System32\\cmd.exe']); + }); + + it('keeps POSIX login-shell behavior without a managed shell', () => { + expect(composeShellCommand({ SHELL: '/bin/bash' }, 'darwin')).toEqual(['/bin/bash', '--login']); + expect(composeShellCommand({}, 'linux')).toEqual(['/bin/zsh', '--login']); + }); +}); diff --git a/src/workspaces/adapters/shell.ts b/src/workspaces/adapters/shell.ts index 55d6708b..2f6ac32f 100644 --- a/src/workspaces/adapters/shell.ts +++ b/src/workspaces/adapters/shell.ts @@ -1,4 +1,5 @@ import type { CliAdapter, SpawnContext } from '../cli-adapter.js'; +import { runtimeProfileFromEnv } from '@/core/runtime-profile.js'; /** * The bare-metal terminal — `zsh --login` (or whatever's on `$SHELL`), @@ -22,8 +23,19 @@ export const shellAdapter: CliAdapter = { transcriptDiscovery: 'none', }, - composeCommand(_base: readonly string[], _ctx: SpawnContext): readonly string[] { - const shell = process.env['SHELL'] ?? '/bin/zsh'; - return [shell, '--login']; + composeCommand(_base: readonly string[], ctx: SpawnContext): readonly string[] { + return composeShellCommand(ctx.env); }, }; + +export function composeShellCommand( + env: Readonly>, + platform: NodeJS.Platform = process.platform, +): readonly string[] { + const managedShell = runtimeProfileFromEnv(env, { platform }).managedShellPath; + if (managedShell) return [managedShell, '--login']; + if (platform === 'win32') { + return [env['SHELL'] ?? env['ComSpec'] ?? env['COMSPEC'] ?? 'cmd.exe']; + } + return [env['SHELL'] ?? '/bin/zsh', '--login']; +} diff --git a/src/workspaces/agent-runtime-readiness.spec.ts b/src/workspaces/agent-runtime-readiness.spec.ts new file mode 100644 index 00000000..99634616 --- /dev/null +++ b/src/workspaces/agent-runtime-readiness.spec.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from 'vitest'; + +import { + classifyRuntimeReadinessFailure, + runtimeProbeSucceeded, + snapshotRuntimeReadiness, + type AgentRuntimeReadinessRow, +} from './agent-runtime-readiness.js'; +import type { CliAdapter } from './cli-adapter.js'; +import type { HeadlessTaskResult } from './headless-task.js'; + +const piAdapter: CliAdapter = { + id: 'pi', + displayName: 'Pi', + kind: 'agent', + binary: 'pi', + capabilities: { + parallelPerCwd: true, + resumeLast: false, + resumeById: true, + transcriptDiscovery: 'none', + headless: true, + }, + composeCommand: () => ['pi'], + composeHeadlessCommand: () => ['pi', '-p', 'hi'], +}; + +function result(overrides: Partial): HeadlessTaskResult { + return { + command: ['agent'], + cwd: '/tmp/openalice-runtime-probe', + exitCode: 1, + signal: null, + killed: false, + durationMs: 12, + stdoutTail: '', + stderrTail: '', + agentSessionId: null, + ...overrides, + }; +} + +describe('agent runtime readiness helpers', () => { + it('classifies timeout, auth, provider, and generic failures', () => { + expect(classifyRuntimeReadinessFailure(result({ killed: true }))).toBe('timeout'); + expect( + classifyRuntimeReadinessFailure(result({ stderrTail: '401 unauthorized: login required' })), + ).toBe('auth_required'); + expect( + classifyRuntimeReadinessFailure(result({ stderrTail: 'missing API key provider config' })), + ).toBe('provider_required'); + expect(classifyRuntimeReadinessFailure(result({ stderrTail: 'boom' }))).toBe('failed'); + }); + + it('requires a clean exit with non-empty output to count as ready', () => { + expect(runtimeProbeSucceeded(result({ exitCode: 0, stdoutTail: 'OPENALICE_READY' }))).toBe(true); + expect(runtimeProbeSucceeded(result({ exitCode: 0, stdoutTail: '' }))).toBe(false); + expect(runtimeProbeSucceeded(result({ exitCode: 1, stdoutTail: 'OPENALICE_READY' }))).toBe(false); + }); + + it('GET snapshot uses cached rows without inventing readiness', () => { + const cache = new Map(); + const unknown = snapshotRuntimeReadiness( + [piAdapter], + { pi: { installed: true, path: '/usr/bin/pi' } }, + cache, + ); + + expect(unknown.overallReady).toBe(false); + expect(unknown.agents.pi?.status).toBe('unknown'); + + cache.set('pi', { + agent: 'pi', + displayName: 'Pi', + installed: true, + binPath: '/usr/bin/pi', + status: 'ready', + ready: true, + source: 'global-config', + checkedAt: '2026-07-08T00:00:00.000Z', + durationMs: 25, + }); + + const ready = snapshotRuntimeReadiness( + [piAdapter], + { pi: { installed: true, path: '/usr/bin/pi' } }, + cache, + ); + + expect(ready.overallReady).toBe(true); + expect(ready.checkedAt).toBe('2026-07-08T00:00:00.000Z'); + expect(ready.agents.pi?.source).toBe('global-config'); + }); +}); diff --git a/src/workspaces/agent-runtime-readiness.ts b/src/workspaces/agent-runtime-readiness.ts new file mode 100644 index 00000000..5f7a2735 --- /dev/null +++ b/src/workspaces/agent-runtime-readiness.ts @@ -0,0 +1,214 @@ +import type { AgentAvailability } from './agent-detect.js'; +import type { CliAdapter } from './cli-adapter.js'; +import type { HeadlessTaskResult } from './headless-task.js'; + +export const RUNTIME_READINESS_PROMPT = + 'Reply exactly with OPENALICE_READY and no extra words.'; + +export const RUNTIME_READINESS_TIMEOUT_MS = 45_000; + +export type AgentRuntimeReadinessStatus = + | 'unknown' + | 'checking' + | 'ready' + | 'not_installed' + | 'auth_required' + | 'provider_required' + | 'timeout' + | 'failed'; + +export type AgentRuntimeReadinessSource = + | 'global-login' + | 'global-config' + | 'launcher-vault' + | 'workspace-override' + | 'managed-runtime' + | 'unknown'; + +export type AgentRuntimeRepairTarget = + | 'runtime-install' + | 'cli-login' + | 'ai-provider' + | 'retry'; + +export interface AgentRuntimeReadinessRow { + readonly agent: string + readonly displayName: string + readonly installed: boolean + readonly binPath: string | null + readonly status: AgentRuntimeReadinessStatus + readonly ready: boolean + readonly source: AgentRuntimeReadinessSource + readonly checkedAt: string | null + readonly durationMs: number | null + readonly repairTarget?: AgentRuntimeRepairTarget + readonly message?: string; +} + +export interface AgentRuntimeReadinessSnapshot { + readonly agents: Record + readonly overallReady: boolean + readonly checkedAt: string | null; +} + +export function initialRuntimeReadinessRow( + adapter: CliAdapter, + availability: AgentAvailability | undefined, +): AgentRuntimeReadinessRow { + const installed = availability?.installed ?? true; + return { + agent: adapter.id, + displayName: adapter.displayName, + installed, + binPath: availability?.path ?? null, + status: installed ? 'unknown' : 'not_installed', + ready: false, + source: 'unknown', + checkedAt: null, + durationMs: null, + ...(installed ? {} : { + repairTarget: 'runtime-install' as const, + message: `${adapter.displayName} is not installed or not on PATH.`, + }), + }; +} + +export function checkingRuntimeReadinessRow(row: AgentRuntimeReadinessRow): AgentRuntimeReadinessRow { + return { + ...row, + status: 'checking', + ready: false, + source: 'unknown', + message: 'Checking runtime with a headless probe.', + }; +} + +export function snapshotRuntimeReadiness( + adapters: readonly CliAdapter[], + availability: Record, + cache: ReadonlyMap, +): AgentRuntimeReadinessSnapshot { + const rows = adapters.map((adapter) => + cache.get(adapter.id) ?? initialRuntimeReadinessRow(adapter, availability[adapter.id]), + ); + const checked = rows + .map((row) => row.checkedAt) + .filter((value): value is string => value !== null) + .sort(); + return { + agents: Object.fromEntries(rows.map((row) => [row.agent, row])), + overallReady: rows.some((row) => row.ready), + checkedAt: checked.at(-1) ?? null, + }; +} + +export function notInstalledRuntimeReadinessRow( + adapter: CliAdapter, + availability: AgentAvailability | undefined, +): AgentRuntimeReadinessRow { + return { + agent: adapter.id, + displayName: adapter.displayName, + installed: false, + binPath: availability?.path ?? null, + status: 'not_installed', + ready: false, + source: 'unknown', + checkedAt: new Date().toISOString(), + durationMs: null, + repairTarget: 'runtime-install', + message: `${adapter.displayName} is not installed or not on PATH.`, + }; +} + +export function readyRuntimeReadinessRow(opts: { + readonly adapter: CliAdapter + readonly availability: AgentAvailability | undefined + readonly source: AgentRuntimeReadinessSource + readonly durationMs: number; +}): AgentRuntimeReadinessRow { + return { + agent: opts.adapter.id, + displayName: opts.adapter.displayName, + installed: true, + binPath: opts.availability?.path ?? null, + status: 'ready', + ready: true, + source: opts.source, + checkedAt: new Date().toISOString(), + durationMs: opts.durationMs, + message: `${opts.adapter.displayName} replied to the readiness probe.`, + }; +} + +export function failedRuntimeReadinessRow(opts: { + readonly adapter: CliAdapter + readonly availability: AgentAvailability | undefined + readonly result: HeadlessTaskResult + readonly source?: AgentRuntimeReadinessSource; +}): AgentRuntimeReadinessRow { + const status = classifyRuntimeReadinessFailure(opts.result); + return { + agent: opts.adapter.id, + displayName: opts.adapter.displayName, + installed: true, + binPath: opts.availability?.path ?? null, + status, + ready: false, + source: opts.source ?? 'unknown', + checkedAt: new Date().toISOString(), + durationMs: opts.result.durationMs, + repairTarget: repairTargetForStatus(status, opts.adapter.id), + message: summarizeRuntimeReadinessFailure(opts.result, status), + }; +} + +export function classifyRuntimeReadinessFailure( + result: Pick, +): AgentRuntimeReadinessStatus { + if (result.killed) return 'timeout'; + const text = `${result.stderrTail}\n${result.stdoutTail}`.toLowerCase(); + if (/\b(unauthorized|unauthorised|forbidden|401|403|oauth|log in|login|sign in|signin|auth|authentication|not authenticated)\b/.test(text)) { + return 'auth_required'; + } + if (/(api[_ -]?key|provider|model|base[_ -]?url|configuration|config|missing key|no key|no provider|openai_api_key|anthropic_api_key)/.test(text)) { + return 'provider_required'; + } + if (result.exitCode !== 0) return 'failed'; + return 'failed'; +} + +export function runtimeProbeSucceeded(result: HeadlessTaskResult): boolean { + if (result.killed || result.exitCode !== 0) return false; + return `${result.stdoutTail}\n${result.stderrTail}`.trim().length > 0; +} + +function repairTargetForStatus( + status: AgentRuntimeReadinessStatus, + agentId: string, +): AgentRuntimeRepairTarget { + if (status === 'auth_required') { + return agentId === 'claude' || agentId === 'codex' ? 'cli-login' : 'ai-provider'; + } + if (status === 'provider_required') return 'ai-provider'; + if (status === 'not_installed') return 'runtime-install'; + return 'retry'; +} + +function summarizeRuntimeReadinessFailure( + result: HeadlessTaskResult, + status: AgentRuntimeReadinessStatus, +): string { + if (status === 'timeout') { + return 'The runtime did not finish the readiness probe before the timeout.'; + } + const tail = `${result.stderrTail || result.stdoutTail}`.trim().replace(/\s+/g, ' '); + const detail = tail ? ` ${tail.slice(0, 280)}` : ''; + if (status === 'auth_required') { + return `The runtime appears to need CLI login or authentication.${detail}`; + } + if (status === 'provider_required') { + return `The runtime appears to need provider or API-key configuration.${detail}`; + } + return `The runtime readiness probe failed.${detail}`; +} diff --git a/src/workspaces/headless-task-win-shim.spec.ts b/src/workspaces/headless-task-win-shim.spec.ts new file mode 100644 index 00000000..bf6ef6bc --- /dev/null +++ b/src/workspaces/headless-task-win-shim.spec.ts @@ -0,0 +1,51 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { Logger } from './logger.js'; + +vi.mock('./win-command.js', () => ({ + resolveLaunchCommand: vi.fn(() => ({ + argv: ['node', '-e', 'process.stdout.write("shim-ok")'], + viaShell: true, + })), +})); + +const { runHeadlessTask } = await import('./headless-task.js'); + +const noopLogger = { + info() {}, + warn() {}, + error() {}, + debug() {}, + child() { + return noopLogger; + }, +} as unknown as Logger; + +describe('runHeadlessTask Windows shim guard', () => { + it('rejects shell shims by default', async () => { + const result = await runHeadlessTask({ + command: ['pi.cmd', '-p', 'user prompt'], + cwd: process.cwd(), + env: { PATH: process.env['PATH'] ?? '' }, + timeoutMs: 5_000, + logger: noopLogger, + }); + + expect(result.exitCode).toBe(-1); + expect(result.stderrTail).toContain('headless dispatch is unsupported'); + }); + + it('allows shell shims for launcher-owned readiness probes', async () => { + const result = await runHeadlessTask({ + command: ['pi.cmd', '-p', 'Reply exactly with OPENALICE_READY and no extra words.'], + cwd: process.cwd(), + env: { PATH: process.env['PATH'] ?? '' }, + timeoutMs: 5_000, + logger: noopLogger, + allowShellShim: true, + }); + + expect(result.exitCode).toBe(0); + expect(result.stdoutTail).toBe('shim-ok'); + }); +}); diff --git a/src/workspaces/headless-task.ts b/src/workspaces/headless-task.ts index c3315195..f2cace2f 100644 --- a/src/workspaces/headless-task.ts +++ b/src/workspaces/headless-task.ts @@ -57,6 +57,12 @@ export interface HeadlessTaskArgs { */ readonly extractSessionId?: (line: string) => string | null; readonly onSessionId?: (id: string) => void; + /** + * Default false. The normal automation path refuses Windows npm .cmd shims + * because the task prompt is user-controlled. Runtime readiness probes pass a + * launcher-owned fixed prompt and may opt in so opencode/Pi can be checked. + */ + readonly allowShellShim?: boolean; } export interface HeadlessTaskResult { @@ -174,7 +180,7 @@ export async function runHeadlessTask(args: HeadlessTaskArgs): Promise; + /** Cached install/ready snapshot for global first-run runtime gating. */ + getAgentRuntimeReadiness(): AgentRuntimeReadinessSnapshot; + /** + * Run real headless readiness probes for one or all agent runtimes. Uses + * launcher-owned scratch dirs, never registered workspaces or user projects. + */ + probeAgentRuntimeReadiness(agentId?: string): Promise; /** * ASYNC dispatch — records the task, spawns it in the background, returns the * taskId immediately (the automation path). Throws `HeadlessCapacityError` @@ -282,6 +310,7 @@ export async function createWorkspaceService(opts: CreateWorkspaceServiceOptions adapters.register(opencodeAdapter); adapters.register(piAdapter); adapters.register(shellAdapter); + const runtimeReadinessCache = new Map(); const creator = new WorkspaceCreator({ workspacesRoot: `${config.launcherRoot}/workspaces`, @@ -442,6 +471,207 @@ export async function createWorkspaceService(opts: CreateWorkspaceServiceOptions return { command, cwd: ws.dir, env, transcriptDir }; }; + const getRuntimeAdapters = () => adapters.list().filter(isAgentRuntime); + + const getAgentRuntimeReadinessMethod = (): AgentRuntimeReadinessSnapshot => + snapshotRuntimeReadiness(getRuntimeAdapters(), detectAgents(), runtimeReadinessCache); + + const runtimeReadinessSourceFor = ( + adapter: CliAdapter, + availability?: AgentAvailability, + ): AgentRuntimeReadinessSource => { + if (adapter.id === 'claude' || adapter.id === 'codex') return 'global-login'; + const binaryPath = availability?.path ?? ''; + if ( + adapter.id === 'pi' && + (binaryPath.includes('/vendor/pi/') || binaryPath.includes('\\vendor\\pi\\')) + ) { + return 'managed-runtime'; + } + return 'global-config'; + }; + + const prepareRuntimeReadinessWorkspace = async (adapter: CliAdapter): Promise => { + const id = `__runtime_readiness_${adapter.id}`; + const dir = join(config.launcherRoot, 'state', 'runtime-readiness', adapter.id); + await rm(dir, { recursive: true, force: true }); + await mkdir(dir, { recursive: true }); + + const template = templates.get('chat'); + if (template) { + await injectWorkspaceContext({ template, wsId: id, dir }); + } + if (adapter.bootstrap) { + await adapter.bootstrap({ wsId: id, cwd: dir, launcherRepoRoot: config.launcherRepoRoot }); + } + + return { + id, + tag: id, + dir, + createdAt: new Date().toISOString(), + template: 'chat', + agents: [adapter.id], + }; + }; + + const writeFirstCompatibleRuntimeCredential = async ( + adapter: CliAdapter, + dir: string, + ): Promise => { + if (!adapter.writeAiConfig) return false; + const credentials = await readCredentials(); + const [, credential] = compatibleCredentials(credentials, adapter.id)[0] ?? []; + if (!credential) return false; + + const model = resolveInjectionModel(credential); + const workspaceCredential = credentialToWorkspaceAiCred( + credential, + adapter.id, + model ? { model } : {}, + ); + if (!workspaceCredential) return false; + await adapter.writeAiConfig(dir, workspaceCredential); + return true; + }; + + const runRuntimeReadinessProbeAttempt = async ( + adapter: CliAdapter, + source: AgentRuntimeReadinessSource, + options: { injectCredential?: boolean } = {}, + ) => { + const ws = await prepareRuntimeReadinessWorkspace(adapter); + try { + if (options.injectCredential) { + const wroteCredential = await writeFirstCompatibleRuntimeCredential(adapter, ws.dir); + if (!wroteCredential) return null; + } + const { cwd, env } = composeSpawnInputs(ws, adapter, undefined); + const command = adapter.composeHeadlessCommand?.( + config.command, + { cwd, env }, + RUNTIME_READINESS_PROMPT, + ); + if (!command) return null; + const result = await runHeadlessTask({ + command, + cwd, + env, + timeoutMs: RUNTIME_READINESS_TIMEOUT_MS, + logger: launcherLogger.child({ scope: 'runtime-readiness', agent: adapter.id }), + allowShellShim: true, + ...(adapter.extractHeadlessSessionId + ? { extractSessionId: adapter.extractHeadlessSessionId.bind(adapter) } + : {}), + }); + return { result, source }; + } finally { + await rm(ws.dir, { recursive: true, force: true }).catch(() => {}); + } + }; + + const syntheticRuntimeReadinessFailure = (message: string): HeadlessTaskResult => ({ + command: [], + cwd: config.launcherRoot, + exitCode: -1, + signal: null, + killed: false, + durationMs: 0, + stdoutTail: '', + stderrTail: message, + agentSessionId: null, + }); + + const probeSingleAgentRuntimeReadiness = async ( + adapter: CliAdapter, + availability?: AgentAvailability, + ): Promise => { + if (!availability?.installed) { + const row = notInstalledRuntimeReadinessRow(adapter, availability); + runtimeReadinessCache.set(adapter.id, row); + return row; + } + if (!adapter.capabilities.headless || !adapter.composeHeadlessCommand) { + const row = failedRuntimeReadinessRow({ + adapter, + availability, + result: syntheticRuntimeReadinessFailure('Agent does not support headless probes.'), + }); + runtimeReadinessCache.set(adapter.id, row); + return row; + } + + const existing = + runtimeReadinessCache.get(adapter.id) ?? initialRuntimeReadinessRow(adapter, availability); + runtimeReadinessCache.set(adapter.id, checkingRuntimeReadinessRow(existing)); + + const globalSource = runtimeReadinessSourceFor(adapter, availability); + let lastAttempt: Awaited> | null = null; + + try { + lastAttempt = await runRuntimeReadinessProbeAttempt(adapter, globalSource); + if (lastAttempt && runtimeProbeSucceeded(lastAttempt.result)) { + const row = readyRuntimeReadinessRow({ + adapter, + availability, + source: lastAttempt.source, + durationMs: lastAttempt.result.durationMs, + }); + runtimeReadinessCache.set(adapter.id, row); + return row; + } + + const launcherAttempt = await runRuntimeReadinessProbeAttempt(adapter, 'launcher-vault', { + injectCredential: true, + }); + if (launcherAttempt) lastAttempt = launcherAttempt; + if (launcherAttempt && runtimeProbeSucceeded(launcherAttempt.result)) { + const row = readyRuntimeReadinessRow({ + adapter, + availability, + source: 'launcher-vault', + durationMs: launcherAttempt.result.durationMs, + }); + runtimeReadinessCache.set(adapter.id, row); + return row; + } + + const row = failedRuntimeReadinessRow({ + adapter, + availability, + result: + lastAttempt?.result ?? + syntheticRuntimeReadinessFailure('No compatible credential or runtime config was found.'), + source: lastAttempt?.source ?? globalSource, + }); + runtimeReadinessCache.set(adapter.id, row); + return row; + } catch (err) { + const row = failedRuntimeReadinessRow({ + adapter, + availability, + result: syntheticRuntimeReadinessFailure(err instanceof Error ? err.message : String(err)), + source: lastAttempt?.source ?? globalSource, + }); + runtimeReadinessCache.set(adapter.id, row); + return row; + } + }; + + const probeAgentRuntimeReadinessMethod = async ( + agentId?: string, + ): Promise => { + const runtimeAdapters = getRuntimeAdapters(); + const targets = agentId + ? runtimeAdapters.filter((adapter) => adapter.id === agentId) + : runtimeAdapters; + const availability = detectAgents(); + await Promise.all( + targets.map((adapter) => probeSingleAgentRuntimeReadiness(adapter, availability[adapter.id])), + ); + return snapshotRuntimeReadiness(runtimeAdapters, detectAgents(), runtimeReadinessCache); + }; + const computeSpawnPlan = ( ws: WorkspaceMeta, adapter: CliAdapter, @@ -1013,6 +1243,8 @@ export async function createWorkspaceService(opts: CreateWorkspaceServiceOptions resolveAdapter, publicMeta, detectAgents, + getAgentRuntimeReadiness: getAgentRuntimeReadinessMethod, + probeAgentRuntimeReadiness: probeAgentRuntimeReadinessMethod, computeSpawnPlan, runHeadlessProbe: runHeadlessProbeMethod, runHeadlessTask: runHeadlessTaskMethod, diff --git a/ui/src/components/FirstRunGuide.spec.ts b/ui/src/components/FirstRunGuide.spec.ts index eace78ab..c5ae01f6 100644 --- a/ui/src/components/FirstRunGuide.spec.ts +++ b/ui/src/components/FirstRunGuide.spec.ts @@ -17,12 +17,32 @@ const liteStatus: TradingServiceStatus = { hasUTAConfig: false, } +const readyPiRuntime = { + agents: { + pi: { + agent: 'pi', + displayName: 'Pi', + installed: true, + binPath: '/vendor/pi/pi', + status: 'ready', + ready: true, + source: 'launcher-vault', + checkedAt: '2026-07-08T00:00:00.000Z', + durationMs: 12, + message: 'Pi replied to the readiness probe.', + }, + }, + overallReady: true, + checkedAt: '2026-07-08T00:00:00.000Z', +} as const + describe('buildFirstRunGuideModel', () => { - it('treats an installed Pi runtime plus a compatible vault credential as usable', () => { + it('treats a ready Pi runtime probe as usable', () => { const model = buildFirstRunGuideModel({ agents: [ { id: 'pi', displayName: 'Pi', kind: 'agent', installed: true }, ], + runtimeReadiness: readyPiRuntime, credentials: [{ wires: { 'openai-chat': '' } }], tradingStatus: liteStatus, utas: [], @@ -35,7 +55,25 @@ describe('buildFirstRunGuideModel', () => { expect(model.hasUsableAiChain).toBe(true) expect(model.runtimeLabel).toBe('1 runtime installed') expect(model.shouldShow).toBe(true) - expect(model.aiAccessLabel).toBe('AI key ready') + expect(model.aiAccessLabel).toBe('Agent runtime ready') + }) + + it('does not treat a compatible vault credential as usable before a runtime probe succeeds', () => { + const model = buildFirstRunGuideModel({ + agents: [ + { id: 'pi', displayName: 'Pi', kind: 'agent', installed: true }, + ], + runtimeReadiness: null, + credentials: [{ wires: { 'openai-chat': '' } }], + tradingStatus: liteStatus, + utas: [], + loaded: true, + dismissed: false, + }) + + expect(model.hasAgentRuntime).toBe(true) + expect(model.hasUsableAiChain).toBe(false) + expect(model.aiAccessLabel).toBe('Retry runtime test') }) it('shows the guide for a fresh Lite install with missing runtimes', () => { @@ -44,6 +82,7 @@ describe('buildFirstRunGuideModel', () => { { id: 'codex', displayName: 'Codex', kind: 'agent', installed: false }, { id: 'pi', displayName: 'Pi', kind: 'agent', installed: false }, ], + runtimeReadiness: null, credentials: [], tradingStatus: liteStatus, utas: [], @@ -63,6 +102,7 @@ describe('buildFirstRunGuideModel', () => { { id: 'codex', displayName: 'Codex', kind: 'agent', installed: true }, { id: 'claude', displayName: 'Claude Code', kind: 'agent', installed: true }, ], + runtimeReadiness: null, credentials: [], tradingStatus: liteStatus, utas: [], @@ -73,8 +113,8 @@ describe('buildFirstRunGuideModel', () => { expect(model.hasAgentRuntime).toBe(true) expect(model.hasUsableAiChain).toBe(false) expect(model.runtimeRows.map((row) => row.accessLabel)).toEqual([ - 'Login check pending', - 'Login check pending', + 'Not checked yet', + 'Not checked yet', ]) expect(model.shouldShow).toBe(true) }) @@ -84,6 +124,7 @@ describe('buildFirstRunGuideModel', () => { agents: [ { id: 'pi', displayName: 'Pi', kind: 'agent', installed: true }, ], + runtimeReadiness: null, credentials: [], tradingStatus: liteStatus, utas: [], @@ -99,6 +140,7 @@ describe('buildFirstRunGuideModel', () => { agents: [ { id: 'pi', displayName: 'Pi', kind: 'agent', installed: true }, ], + runtimeReadiness: readyPiRuntime, credentials: [{ wires: { 'openai-chat': '' } }], tradingStatus: { ...liteStatus, mode: 'readonly', modeSource: 'config', available: true }, utas: [], @@ -114,6 +156,7 @@ describe('buildFirstRunGuideModel', () => { agents: [ { id: 'pi', displayName: 'Pi', kind: 'agent', installed: true }, ], + runtimeReadiness: readyPiRuntime, credentials: [{ wires: { 'openai-chat': '' } }], tradingStatus: { ...liteStatus, mode: 'pro', modeSource: 'config', available: true, hasUTAConfig: true }, utas: [], diff --git a/ui/src/components/FirstRunGuide.tsx b/ui/src/components/FirstRunGuide.tsx index 5f5bc07f..25c9bcfd 100644 --- a/ui/src/components/FirstRunGuide.tsx +++ b/ui/src/components/FirstRunGuide.tsx @@ -35,6 +35,11 @@ import { useWorkspace } from '../tabs/store' import { isApiKeyPreset } from '../lib/presetHelpers' import { LOCALE_LABELS, useLocale, useSetLocale } from '../i18n/useLocale' import type { AppLocale } from '../lib/intl' +import { + getAgentRuntimeReadiness, + probeAgentRuntimeReadiness, + type AgentRuntimeReadinessSnapshot, +} from './workspace/api' const BASE_DISMISS_KEY = 'openalice.onboarding.firstRunGuide.dismissed.v3' const STORAGE_SUFFIX = import.meta.env.VITE_OPENALICE_ONBOARDING_STORAGE_SUFFIX?.trim() @@ -74,6 +79,7 @@ const ONBOARDING_TEST_PRESET: Preset = { interface GuideState { credentials: CredentialSummary[] + runtimeReadiness: AgentRuntimeReadinessSnapshot | null tradingStatus: TradingServiceStatus | null utas: UTAConfig[] } @@ -84,18 +90,21 @@ const FIRST_RUN_LOCALES: AppLocale[] = ['en', 'zh', 'ja', 'zh-Hant'] const INITIAL_GUIDE_STATE: GuideState = { credentials: [], + runtimeReadiness: null, tradingStatus: null, utas: [], } async function fetchGuideState(): Promise { - const [credentials, tradingStatus, tradingConfig] = await Promise.all([ + const [credentials, runtimeReadiness, tradingStatus, tradingConfig] = await Promise.all([ configApi.getCredentials(), + getAgentRuntimeReadiness().catch(() => null), tradingApi.status(), tradingApi.loadTradingConfig(), ]) return { credentials: credentials.credentials, + runtimeReadiness, tradingStatus, utas: tradingConfig.utas, } @@ -123,6 +132,9 @@ export function FirstRunGuide() { const [showCredentialForm, setShowCredentialForm] = useState(false) const [showUTAForm, setShowUTAForm] = useState(false) const [utaEscapeSaving, setUtaEscapeSaving] = useState(false) + const [runtimeProbeRunning, setRuntimeProbeRunning] = useState(false) + const [runtimeProbeAttempted, setRuntimeProbeAttempted] = useState(false) + const [runtimeProbeError, setRuntimeProbeError] = useState(null) const [aiPresets, setAiPresets] = useState([]) const [brokerPresets, setBrokerPresets] = useState([]) const [sessionStarted, setSessionStarted] = useState(false) @@ -141,6 +153,23 @@ export function FirstRunGuide() { return next }, []) + const runRuntimeReadinessProbe = useCallback(async (agent?: string) => { + setRuntimeProbeRunning(true) + setRuntimeProbeAttempted(true) + setRuntimeProbeError(null) + try { + const runtimeReadiness = await probeAgentRuntimeReadiness(agent) + setState((prev) => ({ ...prev, runtimeReadiness })) + return runtimeReadiness + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + setRuntimeProbeError(message) + return null + } finally { + setRuntimeProbeRunning(false) + } + }, []) + useEffect(() => { let live = true fetchGuideState() @@ -189,6 +218,7 @@ export function FirstRunGuide() { const model = useMemo(() => buildFirstRunGuideModel({ agents, + runtimeReadiness: state.runtimeReadiness, credentials: state.credentials, tradingStatus: state.tradingStatus, utas: state.utas, @@ -198,6 +228,9 @@ export function FirstRunGuide() { const guideAccess = useMemo(() => buildFirstRunGuideAccess(model), [model]) const shouldStartGuide = loaded && (model.shouldShow || !!stepOverride) const shouldShowGuide = loaded && !sessionClosed && (sessionStarted || shouldStartGuide) + const requestedStepKey = FIRST_RUN_STEP_KEYS[ + Math.max(0, Math.min(stepIndex, FIRST_RUN_STEP_KEYS.length - 1)) + ] const apiKeyPresets = useMemo(() => { const base = aiPresets.filter(isApiKeyPreset) return ONBOARDING_TEST_MODE && MOCK_CREDENTIAL_TEST @@ -209,6 +242,22 @@ export function FirstRunGuide() { if (shouldStartGuide && !sessionClosed) setSessionStarted(true) }, [sessionClosed, shouldStartGuide]) + useEffect(() => { + if (!shouldShowGuide) return + if (requestedStepKey !== 'ai') return + if (model.hasUsableAiChain || model.runtimeProbeChecking) return + if (runtimeProbeAttempted || runtimeProbeRunning) return + void runRuntimeReadinessProbe() + }, [ + model.hasUsableAiChain, + model.runtimeProbeChecking, + requestedStepKey, + runRuntimeReadinessProbe, + runtimeProbeAttempted, + runtimeProbeRunning, + shouldShowGuide, + ]) + useEffect(() => { if (!shouldShowGuide) return const prev = document.body.style.overflow @@ -287,9 +336,20 @@ export function FirstRunGuide() { : t('firstRunGuide.ai.runtimeMissing') const credentialText = model.noCredentials ? t('firstRunGuide.ai.noVerifiedKey') - : model.hasUsableAiChain - ? t('firstRunGuide.ai.usableKey') - : t('firstRunGuide.ai.keyMismatch') + : t('firstRunGuide.ai.keyMismatch') + const aiAccessText = model.hasUsableAiChain + ? t('firstRunGuide.ai.runtimeReady') + : runtimeProbeError + ? t('firstRunGuide.ai.probeFailed') + : model.runtimeProbeChecking || runtimeProbeRunning + ? t('firstRunGuide.ai.checkingRuntime') + : model.aiRepairTarget === 'cli-login' + ? t('firstRunGuide.ai.cliLoginNeeded') + : model.aiRepairTarget === 'ai-provider' + ? t('firstRunGuide.ai.providerNeeded') + : model.aiRepairTarget === 'runtime-install' + ? t('firstRunGuide.ai.runtimeMissing') + : credentialText const aiTitle = model.hasAgentRuntime ? model.hasUsableAiChain ? t('firstRunGuide.ai.titleReady') @@ -298,15 +358,25 @@ export function FirstRunGuide() { const aiBody = model.hasAgentRuntime ? model.hasUsableAiChain ? t('firstRunGuide.ai.bodyReady') - : model.hasManagedPi - ? t('firstRunGuide.ai.bodyPiInstalled') - : t('firstRunGuide.ai.bodyAddKey') + : model.runtimeProbeChecking || runtimeProbeRunning + ? t('firstRunGuide.ai.bodyChecking') + : model.aiRepairTarget === 'cli-login' + ? t('firstRunGuide.ai.bodyCliLogin') + : model.aiRepairTarget === 'ai-provider' + ? t('firstRunGuide.ai.bodyAddKey') + : model.hasManagedPi + ? t('firstRunGuide.ai.bodyPiInstalled') + : t('firstRunGuide.ai.bodyRetry') : t('firstRunGuide.ai.bodyMissingRuntime') const aiPrimary = model.hasUsableAiChain ? t('firstRunGuide.common.continue') - : model.hasAgentRuntime - ? t('firstRunGuide.action.addCredential') - : t('firstRunGuide.action.openChecklist') + : model.runtimeProbeChecking || runtimeProbeRunning + ? t('firstRunGuide.common.checking') + : model.aiRepairTarget === 'ai-provider' + ? t('firstRunGuide.action.addCredential') + : model.aiRepairTarget === 'retry' + ? t('firstRunGuide.action.testRuntime') + : t('firstRunGuide.action.openChecklist') return [ { @@ -349,7 +419,7 @@ export function FirstRunGuide() { panelBody: t('firstRunGuide.ai.panelBody'), rows: [ { icon: , label: t('firstRunGuide.ai.runtime'), value: runtimeText, tone: model.hasAgentRuntime ? 'ready' as const : 'attention' as const }, - { icon: , label: t('firstRunGuide.ai.aiAccess'), value: credentialText, tone: model.hasUsableAiChain ? 'ready' as const : 'attention' as const }, + { icon: , label: t('firstRunGuide.ai.aiAccess'), value: aiAccessText, tone: model.hasUsableAiChain ? 'ready' as const : 'attention' as const }, ], }, { @@ -389,7 +459,7 @@ export function FirstRunGuide() { ], }, ] - }, [model, t]) + }, [model, runtimeProbeError, runtimeProbeRunning, t]) const maxReachableStepIndex = useMemo(() => { const index = steps.findIndex((step) => step.key === guideAccess.maxReachableStepKey) @@ -410,6 +480,7 @@ export function FirstRunGuide() { const activeStepIndex = Math.max(0, Math.min(stepIndex, maxReachableStepIndex, steps.length - 1)) const activeStep = steps[activeStepIndex] + const primaryDisabled = activeStep.key === 'ai' && (runtimeProbeRunning || model.runtimeProbeChecking) const goToStep = (nextIndex: number) => { const targetIndex = Math.max(0, Math.min(steps.length - 1, maxReachableStepIndex, nextIndex)) @@ -433,12 +504,17 @@ export function FirstRunGuide() { } const runPrimary = () => { - if (activeStep.key === 'ai' && !model.hasAgentRuntime) { - openChecklist() - return - } if (activeStep.key === 'ai' && !model.hasUsableAiChain) { - setShowCredentialForm(true) + if (runtimeProbeRunning || model.runtimeProbeChecking) return + if (!model.hasAgentRuntime || model.aiRepairTarget === 'runtime-install' || model.aiRepairTarget === 'cli-login') { + openChecklist() + return + } + if (model.aiRepairTarget === 'ai-provider') { + setShowCredentialForm(true) + return + } + void runRuntimeReadinessProbe() return } if (activeStep.key === 'broker') { @@ -533,7 +609,7 @@ export function FirstRunGuide() { {activeStep.key === 'language' ? ( ) : activeStep.key === 'ai' ? ( - + ) : activeStep.key === 'broker' ? ( {activeStep.primary} @@ -637,9 +714,11 @@ export function FirstRunGuide() { onClose={() => setShowCredentialForm(false)} onSaved={async () => { const nextState = await refreshGuideState() + const runtimeReadiness = await runRuntimeReadinessProbe() setShowCredentialForm(false) const nextModel = buildFirstRunGuideModel({ agents, + runtimeReadiness: runtimeReadiness ?? nextState.runtimeReadiness, credentials: nextState.credentials, tradingStatus: nextState.tradingStatus, utas: nextState.utas, @@ -902,7 +981,9 @@ function TradingModeChoices({ function RuntimeScanTable({ rows, + error, }: { + error: string | null rows: Array<{ id: string displayName: string @@ -911,15 +992,24 @@ function RuntimeScanTable({ compatibleCredentialCount: number chainReady: boolean accessLabel: string + source: string + readinessStatus: string + readinessMessage: string | null }> }) { const { t } = useTranslation() return ( -
-
- {t('firstRunGuide.ai.runtime')} - {t('firstRunGuide.ai.cli')} - {t('firstRunGuide.ai.aiAccess')} +
+ {error && ( +
+ {error} +
+ )} +
+
+ {t('firstRunGuide.ai.runtime')} + {t('firstRunGuide.ai.cli')} + {t('firstRunGuide.ai.readyProbe')}
{rows.map((row) => { const tone: RowTone = row.chainReady ? 'ready' : row.installed ? 'attention' : 'muted' @@ -931,17 +1021,24 @@ function RuntimeScanTable({ const cliText = row.installed ? t('firstRunGuide.ai.installed') : t('firstRunGuide.ai.missing') const accessText = row.chainReady ? t('firstRunGuide.ai.ready') - : row.installed && row.compatibleCredentialCount > 0 - ? t('firstRunGuide.ai.ready') - : row.installed - ? row.loginRuntime - ? t('firstRunGuide.ai.loginCheckPending') - : t('firstRunGuide.ai.needsAiKey') - : t('firstRunGuide.ai.cliNotInstalled') + : row.readinessStatus === 'checking' + ? t('firstRunGuide.ai.checkingRuntime') + : row.readinessStatus === 'not_installed' + ? t('firstRunGuide.ai.cliNotInstalled') + : row.readinessStatus === 'auth_required' + ? t('firstRunGuide.ai.cliLoginNeeded') + : row.readinessStatus === 'provider_required' + ? t('firstRunGuide.ai.providerNeeded') + : row.readinessStatus === 'unknown' + ? t('firstRunGuide.ai.notChecked') + : t('firstRunGuide.ai.probeFailed') + const sourceText = row.chainReady && row.source !== 'unknown' + ? t('firstRunGuide.ai.source', { source: row.source }) + : null return (
@@ -953,6 +1050,11 @@ function RuntimeScanTable({ {cliText} {accessText}
+ {sourceText && ( +
+ {sourceText} +
+ )}
{cliText} @@ -963,6 +1065,7 @@ function RuntimeScanTable({
) })} +
) } diff --git a/ui/src/components/first-run-guide-model.ts b/ui/src/components/first-run-guide-model.ts index 448c00a0..f86b3558 100644 --- a/ui/src/components/first-run-guide-model.ts +++ b/ui/src/components/first-run-guide-model.ts @@ -1,7 +1,7 @@ import type { TradingServiceStatus } from '../api/trading' import type { UTAConfig, WireShape } from '../api/types' import type { CredentialSummary } from '../api/config' -import type { AgentInfo } from './workspace/api' +import type { AgentInfo, AgentRuntimeReadinessSnapshot } from './workspace/api' const AGENT_WIRE_PREFERENCE: Record = { claude: ['anthropic'], @@ -54,6 +54,7 @@ export function buildFirstRunGuideAccess(input: { export function buildFirstRunGuideModel(input: { agents: readonly Pick[] + runtimeReadiness: AgentRuntimeReadinessSnapshot | null credentials: readonly Pick[] tradingStatus: TradingServiceStatus | null utas: UTAConfig[] @@ -69,20 +70,30 @@ export function buildFirstRunGuideModel(input: { const hasAgentRuntime = agentsKnown && installedAgents.length > 0 const noCredentials = input.credentials.length === 0 const runtimeRows = agentRuntimes.map((agent) => { - const installed = agent.installed !== false + const readiness = input.runtimeReadiness?.agents[agent.id] ?? null + const installed = readiness?.installed ?? agent.installed !== false const compatibleCredentialCount = input.credentials.filter((credential) => (AGENT_WIRE_PREFERENCE[agent.id] ?? ['openai-chat', 'anthropic', 'openai-responses']) .some((shape) => shape in credential.wires), ).length const loginRuntime = LOGIN_RUNTIME_AGENTS.has(agent.id) - const chainReady = installed && compatibleCredentialCount > 0 - const accessLabel = !installed - ? 'CLI not installed' - : compatibleCredentialCount > 0 - ? 'Ready' - : loginRuntime - ? 'Login check pending' - : 'Needs AI key' + const status = readiness?.status ?? (installed ? 'unknown' : 'not_installed') + const chainReady = readiness?.ready === true + const accessLabel = status === 'ready' + ? 'Ready' + : status === 'checking' + ? 'Checking' + : status === 'not_installed' + ? 'CLI not installed' + : status === 'auth_required' + ? 'CLI login needed' + : status === 'provider_required' + ? 'Provider needed' + : status === 'timeout' + ? 'Probe timed out' + : status === 'failed' + ? 'Probe failed' + : 'Not checked yet' return { id: agent.id, displayName: agent.displayName, @@ -91,6 +102,11 @@ export function buildFirstRunGuideModel(input: { compatibleCredentialCount, chainReady, accessLabel, + readinessStatus: status, + source: readiness?.source ?? 'unknown', + repairTarget: readiness?.repairTarget ?? (installed ? 'retry' : 'runtime-install'), + readinessMessage: readiness?.message ?? null, + checkedAt: readiness?.checkedAt ?? null, } }).sort((a, b) => { if (a.chainReady !== b.chainReady) return a.chainReady ? -1 : 1 @@ -98,6 +114,21 @@ export function buildFirstRunGuideModel(input: { return 0 }) const hasUsableAiChain = runtimeRows.some((row) => row.chainReady) + const runtimeProbeChecking = runtimeRows.some((row) => row.readinessStatus === 'checking') + const runtimeProbeKnown = runtimeRows.some((row) => + row.checkedAt !== null || row.readinessStatus === 'not_installed', + ) + const aiRepairTarget = hasUsableAiChain + ? null + : !hasAgentRuntime + ? 'runtime-install' as const + : runtimeRows.some((row) => row.repairTarget === 'ai-provider') + ? 'ai-provider' as const + : runtimeRows.some((row) => row.repairTarget === 'cli-login') + ? 'cli-login' as const + : runtimeRows.some((row) => row.repairTarget === 'runtime-install') + ? 'runtime-install' as const + : 'retry' as const const mode = input.tradingStatus?.mode ?? 'lite' const modeSource = input.tradingStatus?.modeSource ?? 'auto' const hasUTA = input.utas.length > 0 || input.tradingStatus?.hasUTAConfig === true @@ -111,11 +142,19 @@ export function buildFirstRunGuideModel(input: { : piAgent ? 'Managed Pi runtime not detected' : 'Agent runtime not detected' - const aiAccessLabel = noCredentials - ? hasAgentRuntime && (installedAgent?.id === 'codex' || installedAgent?.id === 'claude') - ? 'CLI login or AI key' - : 'AI key needed' - : 'AI key ready' + const aiAccessLabel = hasUsableAiChain + ? 'Agent runtime ready' + : runtimeProbeChecking + ? 'Testing agent runtime' + : aiRepairTarget === 'cli-login' + ? 'CLI login needed' + : aiRepairTarget === 'ai-provider' + ? 'AI provider needed' + : aiRepairTarget === 'runtime-install' + ? 'Runtime install needed' + : noCredentials + ? 'Runtime test needed' + : 'Retry runtime test' return { shouldShow, @@ -132,5 +171,8 @@ export function buildFirstRunGuideModel(input: { freshLite, runtimeLabel, aiAccessLabel, + runtimeProbeChecking, + runtimeProbeKnown, + aiRepairTarget, } } diff --git a/ui/src/components/workspace/api.ts b/ui/src/components/workspace/api.ts index f0969a86..260dbfa8 100644 --- a/ui/src/components/workspace/api.ts +++ b/ui/src/components/workspace/api.ts @@ -188,6 +188,50 @@ export interface AgentInfo { readonly binPath?: string | null; } +export type AgentRuntimeReadinessStatus = + | 'unknown' + | 'checking' + | 'ready' + | 'not_installed' + | 'auth_required' + | 'provider_required' + | 'timeout' + | 'failed'; + +export type AgentRuntimeReadinessSource = + | 'global-login' + | 'global-config' + | 'launcher-vault' + | 'workspace-override' + | 'managed-runtime' + | 'unknown'; + +export type AgentRuntimeRepairTarget = + | 'runtime-install' + | 'cli-login' + | 'ai-provider' + | 'retry'; + +export interface AgentRuntimeReadinessRow { + readonly agent: string; + readonly displayName: string; + readonly installed: boolean; + readonly binPath: string | null; + readonly status: AgentRuntimeReadinessStatus; + readonly ready: boolean; + readonly source: AgentRuntimeReadinessSource; + readonly checkedAt: string | null; + readonly durationMs: number | null; + readonly repairTarget?: AgentRuntimeRepairTarget; + readonly message?: string; +} + +export interface AgentRuntimeReadinessSnapshot { + readonly agents: Record; + readonly overallReady: boolean; + readonly checkedAt: string | null; +} + export async function listAgents(): Promise { const res = await fetch('/api/workspaces/agents'); if (!res.ok) throw new Error(`list agents failed: ${res.status}`); @@ -195,6 +239,25 @@ export async function listAgents(): Promise { return body.agents; } +export async function getAgentRuntimeReadiness(): Promise { + const res = await fetch('/api/workspaces/agent-runtime-readiness'); + if (!res.ok) throw new Error(`get agent runtime readiness failed: ${res.status}`); + return (await res.json()) as AgentRuntimeReadinessSnapshot; +} + +export async function probeAgentRuntimeReadiness(agent?: string): Promise { + const res = await fetch('/api/workspaces/agent-runtime-readiness/probe', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(agent ? { agent } : {}), + }); + if (!res.ok) { + const msg = await res.text().catch(() => ''); + throw new Error(`probe agent runtime readiness failed: ${res.status} ${msg}`); + } + return (await res.json()) as AgentRuntimeReadinessSnapshot; +} + export async function getWorkspaceDefaultAgent(): Promise { const res = await fetch('/api/config/workspace-default-agent'); if (!res.ok) return null; diff --git a/ui/src/demo/handlers/workspaces.ts b/ui/src/demo/handlers/workspaces.ts index e7890164..da741f19 100644 --- a/ui/src/demo/handlers/workspaces.ts +++ b/ui/src/demo/handlers/workspaces.ts @@ -3,6 +3,61 @@ import { demoChatWorkspace, demoWorkspaces, demoTemplates } from '../fixtures/wo import { demoWorkspaceFiles } from '../fixtures/inbox' import type { WorkspaceMetadataPatch } from '../../components/workspace/api' +const demoAgentRuntimeReadiness = { + agents: { + claude: { + agent: 'claude', + displayName: 'Claude Code', + installed: true, + binPath: '/usr/local/bin/claude', + status: 'ready', + ready: true, + source: 'global-login', + checkedAt: '2026-07-08T00:00:00.000Z', + durationMs: 12, + message: 'Claude Code replied to the readiness probe.', + }, + codex: { + agent: 'codex', + displayName: 'Codex', + installed: true, + binPath: '/usr/local/bin/codex', + status: 'ready', + ready: true, + source: 'global-login', + checkedAt: '2026-07-08T00:00:00.000Z', + durationMs: 14, + message: 'Codex replied to the readiness probe.', + }, + opencode: { + agent: 'opencode', + displayName: 'opencode', + installed: true, + binPath: '/usr/local/bin/opencode', + status: 'ready', + ready: true, + source: 'launcher-vault', + checkedAt: '2026-07-08T00:00:00.000Z', + durationMs: 18, + message: 'opencode replied to the readiness probe.', + }, + pi: { + agent: 'pi', + displayName: 'Pi', + installed: true, + binPath: '/usr/local/bin/pi', + status: 'ready', + ready: true, + source: 'launcher-vault', + checkedAt: '2026-07-08T00:00:00.000Z', + durationMs: 16, + message: 'Pi replied to the readiness probe.', + }, + }, + overallReady: true, + checkedAt: '2026-07-08T00:00:00.000Z', +} + export const workspacesHandlers = [ http.get('/api/workspaces', () => HttpResponse.json({ workspaces: demoWorkspaces })), http.post('/api/workspaces', () => @@ -54,6 +109,12 @@ export const workspacesHandlers = [ ], }), ), + http.get('/api/workspaces/agent-runtime-readiness', () => + HttpResponse.json(demoAgentRuntimeReadiness), + ), + http.post('/api/workspaces/agent-runtime-readiness/probe', () => + HttpResponse.json(demoAgentRuntimeReadiness), + ), // One sample vault credential so the quick-chat runtime picker (opencode/pi) // shows a populated dropdown — a clean showcase, not a "go configure" prompt. // `?agent=` filtering is a no-op here (the sample speaks openai-chat, which diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index 35783744..7dc3038f 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -189,6 +189,7 @@ export const en = { attachSoon: 'Attachments coming soon', launching: 'Launching…', error: "Couldn't start the session. Try again.", + runtimeNotReady: "This runtime isn't ready yet. Check setup and try again.", examplesLabel: 'Try asking', ex1: "What's moving in semiconductors today?", ex2: 'Build a thesis on NVDA', @@ -227,6 +228,7 @@ export const en = { selected: 'Selected', chooseThisOption: 'Choose this option', saving: 'Saving', + checking: 'Checking', }, action: { startSetup: 'Start setup', @@ -240,6 +242,7 @@ export const en = { continueWithMode: 'Continue with {{mode}}', startUsingAlice: 'Start using Alice', openAliceNow: 'Open Alice now', + testRuntime: 'Test agent runtime', }, mode: { lite: 'Lite', @@ -270,6 +273,9 @@ export const en = { bodyReady: 'A workspace agent can now launch with a verified AI key. Broker and portfolio setup can stay off until you choose to enable it.', bodyPiInstalled: 'To run workspace chat, Alice needs an agent runtime and a verified AI key. Pi is already installed here; add one key to continue.', bodyAddKey: 'To run workspace chat, Alice needs an agent runtime and a verified AI key. Add a key for any installed runtime to continue.', + bodyChecking: 'Alice is running a small headless test now. You can continue once any runtime replies successfully.', + bodyCliLogin: 'This runtime is installed, but it needs CLI login before Alice can use it. Open the checklist for the login steps, then test again.', + bodyRetry: 'Alice could not confirm a working runtime yet. Test again, or add an OpenAlice credential if your runtime needs provider config.', bodyMissingRuntime: 'Packaged builds should include a managed Pi runtime. Open the setup checklist to repair the runtime path before continuing.', panelTitle: 'Runtime scan', panelBody: 'Alice is ready when one row has both a runtime and AI access.', @@ -282,10 +288,18 @@ export const en = { runtimeInstalled_other: '{{count}} runtimes installed', managedPiMissing: 'Managed Pi runtime not detected', runtimeMissing: 'Agent runtime not detected', + runtimeReady: 'One runtime replied successfully.', + checkingRuntime: 'Testing runtime now...', + probeFailed: 'Runtime test failed.', + cliLoginNeeded: 'CLI login needed.', + providerNeeded: 'Provider config needed.', + notChecked: 'Not checked yet.', cli: 'CLI', + readyProbe: 'Ready probe', installed: 'Installed', missing: 'Missing', ready: 'Ready', + source: 'Source: {{source}}', loginCheckPending: 'Login check pending', needsAiKey: 'Needs AI key', cliNotInstalled: 'CLI not installed', diff --git a/ui/src/i18n/locales/ja.ts b/ui/src/i18n/locales/ja.ts index 93fb481b..4ee3679e 100644 --- a/ui/src/i18n/locales/ja.ts +++ b/ui/src/i18n/locales/ja.ts @@ -178,6 +178,7 @@ export const ja: Resources = { attachSoon: '添付機能は近日公開', launching: '起動中…', error: 'セッションを開始できませんでした。再試行してください。', + runtimeNotReady: 'このランタイムはまだ使えません。設定を確認して再試行してください。', examplesLabel: '質問例', ex1: '今日の半導体セクターの動きは?', ex2: 'NVDA の強気シナリオを作って', @@ -216,6 +217,7 @@ export const ja: Resources = { selected: '選択済み', chooseThisOption: 'この項目を選択', saving: '保存中', + checking: '確認中', }, action: { startSetup: 'セットアップ開始', @@ -229,6 +231,7 @@ export const ja: Resources = { continueWithMode: '{{mode}} で続ける', startUsingAlice: 'Alice を使い始める', openAliceNow: 'Alice を開く', + testRuntime: 'ランタイムを確認', }, mode: { lite: 'Lite', @@ -259,6 +262,9 @@ export const ja: Resources = { bodyReady: 'ワークスペースエージェントは検証済みの AI キーで起動できます。ブローカーとポートフォリオ設定は必要になるまでオフのままで構いません。', bodyPiInstalled: 'ワークスペースチャットにはエージェントランタイムと検証済み AI キーが必要です。ここでは Pi がすでにインストールされています。キーを 1 つ追加してください。', bodyAddKey: 'ワークスペースチャットにはエージェントランタイムと検証済み AI キーが必要です。インストール済みランタイム向けのキーを 1 つ追加してください。', + bodyChecking: 'Alice が小さなヘッドレス確認を実行中です。どれか 1 つが返信できれば続行できます。', + bodyCliLogin: 'ランタイムはありますが、CLI ログインが必要です。セットアップ一覧でログインしてから再確認してください。', + bodyRetry: '使えるランタイムをまだ確認できません。再確認するか、必要なら OpenAlice 認証情報を追加してください。', bodyMissingRuntime: 'パッケージ版には管理 Pi ランタイムが含まれているはずです。続行前にセットアップ一覧でランタイムパスを修復してください。', panelTitle: 'ランタイムスキャン', panelBody: 'ランタイムと AI アクセスの両方がそろう行が 1 つあれば Alice は使用できます。', @@ -271,10 +277,18 @@ export const ja: Resources = { runtimeInstalled_other: '{{count}} 個のランタイムがインストール済み', managedPiMissing: '管理 Pi ランタイムが見つかりません', runtimeMissing: 'エージェントランタイムが見つかりません', + runtimeReady: '1 つのランタイムが返信しました。', + checkingRuntime: 'ランタイム確認中...', + probeFailed: 'ランタイム確認に失敗。', + cliLoginNeeded: 'CLI ログインが必要。', + providerNeeded: 'Provider 設定が必要。', + notChecked: '未確認。', cli: 'CLI', + readyProbe: '可用性', installed: 'インストール済み', missing: 'なし', ready: '準備完了', + source: 'ソース: {{source}}', loginCheckPending: 'ログイン確認待ち', needsAiKey: 'AI キーが必要', cliNotInstalled: 'CLI 未インストール', diff --git a/ui/src/i18n/locales/zh-Hant.ts b/ui/src/i18n/locales/zh-Hant.ts index cc9d9172..a9bd47e2 100644 --- a/ui/src/i18n/locales/zh-Hant.ts +++ b/ui/src/i18n/locales/zh-Hant.ts @@ -186,6 +186,7 @@ export const zhHant: Resources = { attachSoon: '附件功能即將推出', launching: '正在啟動…', error: '工作階段啟動失敗,請重試。', + runtimeNotReady: '這個執行環境還不可用,請檢查設定後重試。', examplesLabel: '試著問', ex1: '今天半導體類股有什麼異動?', ex2: '幫我做一個輝達的多頭邏輯', @@ -224,6 +225,7 @@ export const zhHant: Resources = { selected: '已選擇', chooseThisOption: '選擇此項', saving: '儲存中', + checking: '檢測中', }, action: { startSetup: '開始設定', @@ -237,6 +239,7 @@ export const zhHant: Resources = { continueWithMode: '以 {{mode}} 繼續', startUsingAlice: '開始使用 Alice', openAliceNow: '現在開啟 Alice', + testRuntime: '檢測 agent 執行環境', }, mode: { lite: 'Lite', @@ -267,6 +270,9 @@ export const zhHant: Resources = { bodyReady: '工作區 agent 現在可以用已驗證的 AI key 啟動。券商和投資組合設定可以先保持關閉,等你需要時再開啟。', bodyPiInstalled: '要執行工作區聊天,Alice 需要一個 agent 執行環境和一個已驗證的 AI key。這裡已經安裝了 Pi;新增一個 key 就能繼續。', bodyAddKey: '要執行工作區聊天,Alice 需要一個 agent 執行環境和一個已驗證的 AI key。給任意已安裝執行環境新增一個 key 即可繼續。', + bodyChecking: 'Alice 正在跑一次輕量無頭檢測。只要任意執行環境能正常回覆,就可以繼續。', + bodyCliLogin: '執行環境已安裝,但需要先完成 CLI 登入。開啟設定清單完成登入後,再回來重新檢測。', + bodyRetry: 'Alice 暫時沒有確認到可用執行環境。可以重新檢測,或在執行環境需要 provider 設定時新增 OpenAlice 憑證。', bodyMissingRuntime: '打包版本應該自帶託管 Pi 執行環境。繼續之前,請開啟設定清單修復執行環境路徑。', panelTitle: '執行環境掃描', panelBody: '只要有一列同時具備執行環境和 AI 存取,Alice 就可用。', @@ -279,10 +285,18 @@ export const zhHant: Resources = { runtimeInstalled_other: '已安裝 {{count}} 個執行環境', managedPiMissing: '未偵測到託管 Pi 執行環境', runtimeMissing: '未偵測到 agent 執行環境', + runtimeReady: '已有一個執行環境成功回覆。', + checkingRuntime: '正在檢測執行環境...', + probeFailed: '執行環境檢測失敗。', + cliLoginNeeded: '需要 CLI 登入。', + providerNeeded: '需要 provider 設定。', + notChecked: '尚未檢測。', cli: 'CLI', + readyProbe: '可用性檢測', installed: '已安裝', missing: '缺少', ready: '可用', + source: '來源:{{source}}', loginCheckPending: '等待登入檢查', needsAiKey: '需要 AI key', cliNotInstalled: 'CLI 未安裝', diff --git a/ui/src/i18n/locales/zh.ts b/ui/src/i18n/locales/zh.ts index d7a1c7cb..ef6548ec 100644 --- a/ui/src/i18n/locales/zh.ts +++ b/ui/src/i18n/locales/zh.ts @@ -178,6 +178,7 @@ export const zh: Resources = { attachSoon: '附件功能即将上线', launching: '正在启动…', error: '会话启动失败,请重试。', + runtimeNotReady: '这个运行时还不可用,请检查设置后重试。', examplesLabel: '试着问', ex1: '今天半导体板块有什么异动?', ex2: '给我做一个英伟达的多头逻辑', @@ -216,6 +217,7 @@ export const zh: Resources = { selected: '已选择', chooseThisOption: '选择此项', saving: '保存中', + checking: '检测中', }, action: { startSetup: '开始设置', @@ -229,6 +231,7 @@ export const zh: Resources = { continueWithMode: '以 {{mode}} 继续', startUsingAlice: '开始使用 Alice', openAliceNow: '现在打开 Alice', + testRuntime: '检测 agent 运行时', }, mode: { lite: 'Lite', @@ -259,6 +262,9 @@ export const zh: Resources = { bodyReady: '工作区 agent 现在可以用已验证的 AI key 启动。券商和投资组合设置可以先保持关闭,等你需要时再开启。', bodyPiInstalled: '要运行工作区聊天,Alice 需要一个 agent 运行时和一个已验证的 AI key。这里已经安装了 Pi;添加一个 key 就能继续。', bodyAddKey: '要运行工作区聊天,Alice 需要一个 agent 运行时和一个已验证的 AI key。给任意已安装运行时添加一个 key 即可继续。', + bodyChecking: 'Alice 正在跑一次轻量无头检测。只要任意运行时能正常回复,就可以继续。', + bodyCliLogin: '运行时已安装,但需要先完成 CLI 登录。打开设置清单完成登录后,再回来重新检测。', + bodyRetry: 'Alice 暂时没有确认到可用运行时。可以重新检测,或在运行时需要 provider 配置时添加 OpenAlice 凭证。', bodyMissingRuntime: '打包版本应该自带托管 Pi 运行时。继续之前,请打开设置清单修复运行时路径。', panelTitle: '运行时扫描', panelBody: '只要有一行同时具备运行时和 AI 访问,Alice 就可用。', @@ -271,10 +277,18 @@ export const zh: Resources = { runtimeInstalled_other: '已安装 {{count}} 个运行时', managedPiMissing: '未检测到托管 Pi 运行时', runtimeMissing: '未检测到 agent 运行时', + runtimeReady: '已有一个运行时成功回复。', + checkingRuntime: '正在检测运行时...', + probeFailed: '运行时检测失败。', + cliLoginNeeded: '需要 CLI 登录。', + providerNeeded: '需要 provider 配置。', + notChecked: '尚未检测。', cli: 'CLI', + readyProbe: '可用性检测', installed: '已安装', missing: '缺失', ready: '可用', + source: '来源:{{source}}', loginCheckPending: '等待登录检查', needsAiKey: '需要 AI key', cliNotInstalled: 'CLI 未安装', diff --git a/ui/src/pages/ChatLandingPage.tsx b/ui/src/pages/ChatLandingPage.tsx index 28f5631f..8d997e32 100644 --- a/ui/src/pages/ChatLandingPage.tsx +++ b/ui/src/pages/ChatLandingPage.tsx @@ -21,10 +21,13 @@ import { useWorkspaces } from '../contexts/workspaces-context' import { installHintFor } from '../components/workspace/agentInstall' import { getAgentReadiness, + getAgentRuntimeReadiness, listAgentCredentials, detectWorkspaceCredential, + probeAgentRuntimeReadiness, QuickChatError, type AgentCredentialReadiness, + type AgentRuntimeReadinessSnapshot, type SavedCredential, } from '../components/workspace/api' import { useWorkspace } from '../tabs/store' @@ -82,6 +85,7 @@ export function ChatLandingPage({ spec }: { spec: { params: { targetWsId?: strin const [value, setValue] = useState('') const [launching, setLaunching] = useState(false) const [error, setError] = useState(null) + const [runtimeReadiness, setRuntimeReadiness] = useState(null) const [selectedAgent, setSelectedAgent] = useState(null) const [agentMenuOpen, setAgentMenuOpen] = useState(false) const textareaRef = useRef(null) @@ -116,6 +120,13 @@ export function ChatLandingPage({ spec }: { spec: { params: { targetWsId?: strin // Surface install guidance when the chosen runtime isn't on PATH. const selectedMissing = selectedInfo != null && !isInstalled(selectedInfo) const installHint = selectedInfo ? installHintFor(selectedInfo.id) : undefined + const selectedRuntimeReadiness = effectiveAgent ? runtimeReadiness?.agents[effectiveAgent] ?? null : null + const selectedRuntimeReady = selectedRuntimeReadiness?.ready === true + const selectedRuntimeUsesGlobalConfig = + selectedRuntimeReady && + (selectedRuntimeReadiness?.source === 'global-config' || + selectedRuntimeReadiness?.source === 'managed-runtime' || + selectedRuntimeReadiness?.source === 'global-login') // ── Loginless-runtime credential picker (opencode/pi) ───────────────────── // opencode/pi have no login of their own, so a quick-chat send must seed them @@ -156,6 +167,14 @@ export function ChatLandingPage({ spec }: { spec: { params: { targetWsId?: strin return () => { live = false } }, []) + useEffect(() => { + let live = true + getAgentRuntimeReadiness() + .then((snapshot) => { if (live) setRuntimeReadiness(snapshot) }) + .catch(() => { if (live) setRuntimeReadiness(null) }) + return () => { live = false } + }, []) + // Detect the target workspace's current cred/readiness for this runtime (for the default // selection + the overwrite notice). Only when the workspace already exists. useEffect(() => { @@ -179,7 +198,12 @@ export function ChatLandingPage({ spec }: { spec: { params: { targetWsId?: strin agentReadiness?.ready === true && agentReadiness.requiresCredential === true && agentReadiness.source === 'workspace-config' - const noCreds = needsCred && !workspaceCredReady && creds !== null && creds.length === 0 + const noCreds = + needsCred && + !workspaceCredReady && + !selectedRuntimeUsesGlobalConfig && + creds !== null && + creds.length === 0 // Effective cred = explicit pick, else what the workspace already uses, else // the first compatible one. Mirrors the backend's resolution order. const effectiveCred = @@ -208,7 +232,7 @@ export function ChatLandingPage({ spec }: { spec: { params: { targetWsId?: strin openOrFocus({ kind: 'settings', params: { category: 'ai-provider' } }) } - const canSend = value.trim().length > 0 && !launching && !noCreds && effectiveAgent !== null + const canSend = value.trim().length > 0 && !launching && effectiveAgent !== null // Close the agent menu on an outside click. useEffect(() => { @@ -229,21 +253,36 @@ export function ChatLandingPage({ spec }: { spec: { params: { targetWsId?: strin setAgentMenuOpen(true) return } - // A loginless runtime with no configured provider can't launch — send the - // user to set one up instead of spawning an agent that'll die immediately. - if (noCreds) { - goConfigureProvider() - return - } setError(null) setLaunching(true) try { + let readiness = runtimeReadiness + let runtimeRow = readiness?.agents[effectiveAgent] ?? null + if (runtimeRow?.ready !== true) { + readiness = await probeAgentRuntimeReadiness(effectiveAgent) + setRuntimeReadiness(readiness) + runtimeRow = readiness.agents[effectiveAgent] ?? null + } + if (runtimeRow?.ready !== true) { + if (runtimeRow?.repairTarget === 'ai-provider' || noCreds) { + goConfigureProvider() + return + } + setError(runtimeRow?.message ?? t('chatLanding.runtimeNotReady')) + return + } + const runtimeUsesGlobalConfig = + runtimeRow.source === 'global-config' || + runtimeRow.source === 'managed-runtime' || + runtimeRow.source === 'global-login' + const credentialSlug = + needsCred && !runtimeUsesGlobalConfig ? (effectiveCred ?? undefined) : undefined // On success this focuses the new session's terminal tab; the landing tab // stays open in the background, so clear it for next time. await quickChat( prompt, effectiveAgent, - needsCred ? (effectiveCred ?? undefined) : undefined, + credentialSlug, targetWsId, ) setValue('')