From 0187c774d3710a4554ccf70a746cd7e213527608 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sun, 2 Aug 2026 13:33:31 +0800 Subject: [PATCH 1/2] fix(desktop): boot after ready to break Electron ESM startup deadlock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Electron ESM emits `ready` only after the main module finishes evaluating, so a top-level `await app.whenReady()` in the startup chain deadlocks: module evaluation waits on ready, ready waits on module evaluation. The storage-root repair dialog hit exactly this — any root-identity conflict hung the process silently with no window. Split the entry: main.ts now does only pre-ready work (setName, E2E userData redirect, single-instance lock with a proper return) and dynamic-imports boot.ts inside the whenReady callback. boot.ts keeps the whole startup chain (root-identity check, stores, IPC, lifecycle) as module-level code after ready, so the check still precedes every store and db write and confirmRepair no longer needs whenReady at all. Also: - fix losing-second-instance exiting without returning, so it never touches shared state (was opening SQLite before exit) - surface fatal boot errors via showErrorBox instead of a silent exit - E2E regression test: conflicting storage root reaches ready with the repair dialog open and writes nothing before the user answers --- .../desktop/e2e/storage-root-conflict.spec.ts | 103 ++ apps/desktop/src/main/boot.ts | 1432 ++++++++++++++++ apps/desktop/src/main/main.ts | 1472 +---------------- apps/desktop/src/main/startup-context.ts | 16 + 4 files changed, 1578 insertions(+), 1445 deletions(-) create mode 100644 apps/desktop/e2e/storage-root-conflict.spec.ts create mode 100644 apps/desktop/src/main/boot.ts create mode 100644 apps/desktop/src/main/startup-context.ts diff --git a/apps/desktop/e2e/storage-root-conflict.spec.ts b/apps/desktop/e2e/storage-root-conflict.spec.ts new file mode 100644 index 0000000000..c261344a4a --- /dev/null +++ b/apps/desktop/e2e/storage-root-conflict.spec.ts @@ -0,0 +1,103 @@ +import { _electron as electron, expect, test } from '@playwright/test'; +import type { ElectronApplication } from '@playwright/test'; +import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { STORAGE_ROOT_MARKER_FILE, resolveStorageRoot } from '@maka/storage/root-authority'; +import { buildFixtureEnv } from '../../../scripts/fixture-env.mjs'; +import { closeElectronApplication } from '../../../scripts/electron-lifecycle.mjs'; + +const DESKTOP_ROOT = process.cwd(); + +/** + * Poll the main process for readiness without hanging on the modal repair + * dialog: once the dialog opens (which can only happen after ready, because + * the boot module runs inside the `whenReady` callback), the dialog's modal + * loop on macOS stops answering CDP evaluation, so an evaluate call that + * never settles is itself proof that ready was reached. A deadlocked main + * process, by contrast, answers every evaluate with `isReady() === false` + * forever. + */ +async function mainProcessReachedReady(app: ElectronApplication): Promise { + for (let attempt = 0; attempt < 40; attempt += 1) { + let settled = false; + const outcome = await Promise.race([ + app + .evaluate(({ app: electronApp }) => electronApp.isReady()) + .then((ready) => { + settled = true; + return ready ? 'ready' : 'not-ready'; + }) + .catch((error: unknown) => { + settled = true; + return `evaluate-error:${error instanceof Error ? error.message : String(error)}`; + }), + new Promise((resolve) => setTimeout(() => resolve('modal-dialog'), 1_000)), + ]); + if (outcome === 'ready' || outcome === 'modal-dialog') return true; + if (outcome === 'not-ready' || outcome.startsWith('evaluate-error:')) { + await new Promise((resolve) => setTimeout(resolve, 250)); + continue; + } + } + return false; +} + +/** + * A conflicting storage root must reach the ready state with the repair + * dialog open, not deadlock in module evaluation, and must not write any + * store/db files before the user answers the dialog. + * + * Regression for the Electron ESM startup deadlock: top-level + * `await app.whenReady()` inside the repair-confirm path never resolves + * because `ready` only fires after the main module finishes evaluating. + * + * The ready signal is the main-process line `[startup] app ready`, which the + * thin entry prints from inside the `whenReady` callback right before + * dynamic-importing the boot module. A modal repair dialog blocks further + * CDP evaluation on macOS, so asserting `app.isReady()` from the test side + * would hang once the dialog opens; the console line is emitted before the + * dialog exists and stays observable. + */ +test('reaches ready with a storage-root repair dialog open and writes nothing before the answer', async () => { + const userDataDir = await mkdtemp(join(tmpdir(), 'maka-root-conflict-')); + const homeDir = join(userDataDir, 'home'); + await mkdir(homeDir, { recursive: true }); + let app; + try { + // Seed a real interactive marker, then corrupt its device id so startup + // must stop at the repair dialog (mirrors the disk-identity drift that + // triggers root_identity_collision). + const workspaceRoot = join(userDataDir, 'workspaces', 'default'); + await resolveStorageRoot({ path: workspaceRoot, kind: 'interactive' }); + const markerPath = join(workspaceRoot, STORAGE_ROOT_MARKER_FILE); + const marker = JSON.parse(await readFile(markerPath, 'utf8')) as { + rootIdentity: { dev: string }; + }; + marker.rootIdentity.dev = (BigInt(marker.rootIdentity.dev) + 1n).toString(); + const conflictingMarker = `${JSON.stringify(marker)}\n`; + await writeFile(markerPath, conflictingMarker); + + // Launch without a fixture so the repair gate is live (fixture mode + // seeds its own workspace and bypasses the dialog). + app = await electron.launch({ + args: ['.'], + cwd: DESKTOP_ROOT, + env: buildFixtureEnv(userDataDir, homeDir, {}), + }); + + // The app must become ready while the repair dialog is open — before + // this fix the main process deadlocked in module evaluation and + // isReady() never turned true. + expect(await mainProcessReachedReady(app)).toBe(true); + + // Before the dialog is answered, no store/db files may be created in + // the workspace: the root-identity gate must precede all storage. + const workspaceEntries = await readdir(workspaceRoot); + expect(workspaceEntries).toEqual([STORAGE_ROOT_MARKER_FILE]); + expect(await readFile(markerPath, 'utf8')).toBe(conflictingMarker); + } finally { + if (app) await closeElectronApplication(app, 5_000); + await rm(userDataDir, { recursive: true, force: true }); + } +}); diff --git a/apps/desktop/src/main/boot.ts b/apps/desktop/src/main/boot.ts new file mode 100644 index 0000000000..a4dc8d8876 --- /dev/null +++ b/apps/desktop/src/main/boot.ts @@ -0,0 +1,1432 @@ +import { app, dialog, ipcMain, powerSaveBlocker, shell } from 'electron'; +import { randomUUID } from 'node:crypto'; +import { join } from 'node:path'; +import { wireAppLifecycle } from './app-lifecycle.js'; +import { + collapseSessionRevisions, + filterModelVisibleTaskLedgerTasks, + isActiveShellRunStatus, + resolveSystemUiLocale, + resolveUiLocale, +} from '@maka/core'; +import type { + AppSettings, + BotProvider, + ConnectionEvent, + SessionChangedEvent, + SessionChangedReason, + SessionEvent, + SessionHeader, + UpdateAppSettingsInput, +} from '@maka/core'; +import { deriveBotStatusPersistenceUpdate } from './bot-status-persistence.js'; +import { runThreadSearch } from './search/thread-search.js'; +import { assembleDesktopTools } from './tool-assembly.js'; +import { createToolArtifactPersistence } from './tool-artifact-persistence.js'; +import { ClaudeSubscriptionService } from './oauth/claude-subscription-service.js'; +import { OpenAiCodexService } from './oauth/openai-codex-service.js'; +import { createOpenAiCodexE2eFixtureService } from './openai-codex-e2e-fixture.js'; +import { GitHubCopilotSubscriptionService } from './oauth/github-copilot-subscription-service.js'; +import { XaiOAuthService } from './oauth/xai-oauth-service.js'; +import { CursorSubscriptionService } from './oauth/cursor-subscription-service.js'; +import { AntigravitySubscriptionService } from './oauth/antigravity-subscription-service.js'; +import type { WorkspacePrivacyContext } from '@maka/core/incognito'; +import { ok } from '@maka/core/result'; +import { + AgentGraphCoordinator, + AgentGraphSupervisorWakeCoordinator, + BackendRegistry, + FakeBackend, + SessionManager, + createLocalContinuationSafetyInspector, + buildDeepResearchTools, + getAIModel, + generateSessionTitle as generateRuntimeSessionTitle, + buildProviderOptions, + buildPricingLookup, + BotRegistry, + ShellRunProcessManager, + SessionActivityRegistry, + listInvocableSkills, + prepareSkillInvocationMessage, + resolveSkillDiscoveryPaths, +} from '@maka/runtime'; +import type { + BotIncomingMessage, + BotStatus, + GoalTurnOutcome, + HostCapabilities, + HostCapabilitiesResolver, +} from '@maka/runtime'; +import type { LlmConnection } from '@maka/core/llm-connections'; +import { + createSqliteArtifactStore, + createSqliteDeepResearchStore, + createReadImageSnapshotter, + createConnectionStore, + createGitWorktreeChildExecutor, + createSqlitePlanReminderStore, + createSqlitePlanStore, + createProjectCatalog, + openRuntimeEventPersistence, + createSessionStore, + createSettingsStore, + createMcpConfigStore, + createSqliteModelCallLedger, + createSqliteTelemetryRepo, +} from '@maka/storage'; +import { createAgentGraphControlStore } from '@maka/storage/agent-graph-control-store'; +import { resolveWorkspaceIdentity } from '@maka/storage/workspace-identity'; +import { McpClientManager } from '@maka/mcp'; +import { registerMcpIpcMain } from './mcp-ipc-main.js'; +import { + ensureSessionCanSendOrRebind, + errorMessage, + requireReadyConnection, +} from './chat-readiness.js'; +import { assertDesktopExecutionBoundary } from './desktop-execution-admission.js'; +import { createFileCredentialStore } from './credential-store.js'; +import { bindOnboardingDeps, createOnboardingService } from './onboarding-service.js'; +import { createDailyReviewArchiveStore } from './daily-review-archive-store.js'; +import { resolveE2eFixture, seedE2eFixture } from './e2e-fixture.js'; +import { resolveBuildInfo } from './build-info.js'; +import { resolveShellEnv } from './shell-env.js'; +import { LocalMemoryService } from './local-memory-service.js'; +import { createAttachmentApprovalRegistry } from './attachment-approval.js'; +import { cleanupLegacyHistoryCompactArtifacts } from '@maka/runtime'; +import { computerUseServiceHealth } from './computer-use-host.js'; +import { createMainWindowController } from './main-window.js'; +import { createDailyReviewMainService } from './daily-review-main.js'; +import { createPlanReminderMainService } from './plan-reminders-main.js'; +import { createBotIncomingMainService } from './bot-incoming-main.js'; +import { createSubscriptionModelFetch } from './subscription-model-fetch.js'; +import { createSystemPromptMainService } from './system-prompt-main.js'; +import { createMainTaskLedgerWiring } from './task-ledger-wiring.js'; +import { createMainAutomationWiring, evaluateAutomationCanFire } from './automation-wiring.js'; +import { createMainGoalWiring } from './goal-wiring.js'; +import { createOAuthModelConnectionsMainService } from './oauth-model-connections-main.js'; +import { registerMemoryIpc } from './memory-ipc-main.js'; +import { registerSubscriptionIpc } from './subscription-ipc-main.js'; +import { registerBrowserIpc } from './browser-ipc-main.js'; +import { registerConnectionsIpc } from './connections-ipc-main.js'; +import { registerConfigIpc } from './config-ipc-main.js'; +import { registerPlanReminderIpc } from './plan-reminders-ipc-main.js'; +import { registerWorkspaceResourcesIpc } from './workspace-resources-ipc-main.js'; +import type { NewSessionSkillContext } from './workspace-resources-ipc-main.js'; +import { registerDailyReviewIpc } from './daily-review-ipc-main.js'; +import { registerUsageIpc } from './usage-ipc-main.js'; +import { registerWebSearchIpc } from './web-search-ipc-main.js'; +import { registerNotificationsIpc } from './notifications-ipc-main.js'; +import { registerAppIpc } from './app-ipc-main.js'; +import { registerGitIpc } from './git-ipc-main.js'; +import { registerWorkspaceSearchIpc } from './workspace-search-ipc-main.js'; +import { registerWorkspaceInstructionsIpc } from './workspace-instructions-ipc-main.js'; +import { registerOnboardingIpc } from './onboarding-ipc-main.js'; +import { registerPermissionsIpc } from './permissions-ipc-main.js'; +import { + createPermissionOverlayMain, + registerPermissionOverlayIpc, +} from './permission-overlay/permission-overlay-main.js'; +import { registerSettingsIpc } from './settings-ipc-main.js'; +import type { SettingsIpcHandle } from './settings-ipc-main.js'; +import { createE2eFixtureBotOnboardingAdapters } from './bot-onboarding-e2e-fixture.js'; +import { createKeepSystemAwakeController } from './keep-system-awake.js'; +import { createSettingsRuntimeEffects } from './settings-runtime-effects.js'; +import { createAiSdkBackendFactory, createSessionStreamer } from './session-stream.js'; +import { + resolveDesktopBackendToolSurface, + resolveDesktopNewSessionSkillHost, + resolveDesktopSessionSkillHost, +} from './desktop-backend-tool-surface.js'; +import { registerSessionsIpc } from './sessions-ipc-main.js'; +import { registerAgentGraphIpc } from './agent-graph-ipc-main.js'; +import { createVoiceIpcService, registerVoiceIpc } from './voice-ipc-main.js'; +import { + assertSessionCanSendFromHeader, + isSessionLifecycleError, + sessionLifecycleErrorFromReadFailure, +} from './session-lifecycle.js'; +import { createProjectRootController } from './project-root-controller.js'; +import { createProjectManagementService } from './project-management-service.js'; +import { + type DesktopCreateSessionInput, + resolveDesktopSessionSelection, + resolveNewSessionProjectInput, +} from './new-session-project.js'; +import { + assertSessionWorkspaceAvailable, + isSessionWorkspaceUnavailableError, + resolveProjectContextRoot, +} from './project-context-root.js'; +import { isComputerUseRealModelE2e, isE2e, isIsolatedE2e } from './startup-context.js'; +import { resolveDesktopStorageRoot } from './storage-root-startup.js'; +import { openDesktopExecutionStoreWiring } from './execution-store-wiring.js'; + +const buildInfo = resolveBuildInfo(app.isPackaged, app.getAppPath()); + +// Resolve the user's login-shell PATH before any stores, tools, or child +// processes are created. On macOS, apps launched from Finder/Dock inherit a +// minimal PATH that lacks /opt/homebrew/bin, ~/.local/bin, etc. Only PATH is +// imported; application-control variables remain owned by this process. +// Skipped on Windows, when MAKA_SKIP_SHELL_ENV=1, and when launched from a +// terminal (TERM/COLORTERM set). +await resolveShellEnv(); + +// PR-VISUAL-SMOKE-HEADLESS: resolve the fixture defensively. An unknown +// scenario (e.g. a stale build, or a typo'd MAKA_E2E_FIXTURE) throws +// here during top-level module evaluation. Left uncaught it surfaces a +// blocking native error dialog. In fixture mode we instead log a parseable +// line and exit fast so the run fails in milliseconds with no dialog. +// Outside fixture mode the throw is rethrown. +let e2eFixture: ReturnType; +try { + e2eFixture = resolveE2eFixture( + process.env.MAKA_E2E_FIXTURE, + app.isPackaged, + process.env.MAKA_E2E_FIXTURE_REDUCED_MOTION, + process.env.MAKA_E2E_FIXTURE_THEME, + process.env.MAKA_E2E_FIXTURE_LOCALE, + process.env.MAKA_E2E_FIXTURE_TIMEZONE, + process.env.MAKA_E2E_FIXTURE_PLATFORM, + ); +} catch (error) { + if (process.env.MAKA_E2E_FIXTURE) { + console.error(`[e2e-fixture] fatal: ${error instanceof Error ? error.message : String(error)}`); + process.exit(1); + } + throw error; +} +const workspaceRoot = join(app.getPath('userData'), 'workspaces', e2eFixture?.workspaceName ?? 'default'); +const credentialStore = createFileCredentialStore(workspaceRoot); +if (e2eFixture) { + console.log(`[e2e-fixture] scenario=${e2eFixture.scenario} workspace=${workspaceRoot}`); + await seedE2eFixture({ workspaceRoot, fixture: e2eFixture, credentialStore }); +} else { + const storageRoot = await resolveDesktopStorageRoot(workspaceRoot, { + confirmRepair: confirmDesktopStorageRootRepair, + }); + if (!storageRoot) { + app.exit(0); + await new Promise(() => {}); + } +} + +async function confirmDesktopStorageRootRepair(): Promise { + if (!app.isReady()) { + throw new Error('storage-root repair dialog requires app ready'); + } + const isChinese = resolveSystemUiLocale(app.getPreferredSystemLanguages()) === 'zh'; + const { response } = await dialog.showMessageBox({ + type: 'warning', + title: isChinese ? 'Maka 工作区需要修复' : 'Maka workspace needs repair', + message: isChinese ? 'Maka 无法验证这个工作区。' : 'Maka cannot verify this workspace.', + detail: isChinese + ? `系统中的磁盘标识可能发生了变化。仅当这是本机原来的 Maka 工作区、而不是复制出的工作区时,才选择修复。\n\n${workspaceRoot}` + : `The disk identity may have changed. Repair only if this is the original Maka workspace on this computer, not a copied workspace.\n\n${workspaceRoot}`, + buttons: isChinese ? ['修复工作区', '退出'] : ['Repair Workspace', 'Exit'], + defaultId: 1, + cancelId: 1, + noLink: true, + }); + return response === 0; +} +// 保持系统唤醒 (settings.system.keepSystemAwake): holds an Electron +// `powerSaveBlocker` so in-process scheduled tasks keep firing while the +// machine would otherwise sleep. Injected with electron's blocker; the +// controller owns the id + double-start guard. The blocker dies with the +// process, so quit needs no special teardown. +const keepSystemAwake = createKeepSystemAwakeController(powerSaveBlocker); +const store = createSessionStore(workspaceRoot); +const agentGraphControlStore = createAgentGraphControlStore(workspaceRoot); +const projectCatalog = createProjectCatalog(workspaceRoot); +const worktreeChildExecutor = createGitWorktreeChildExecutor({ storageRoot: workspaceRoot }); +const planStore = createSqlitePlanStore(workspaceRoot); +const executionStoreWiring = await openDesktopExecutionStoreWiring(workspaceRoot); +const { runStore, shellRunStore } = executionStoreWiring; +const runtimePersistence = await openRuntimeEventPersistence({ + workspaceRoot, +}); +const runtimeEventStore = runtimePersistence.runtimeEventStore; +const connectionStore = createConnectionStore(workspaceRoot); +const settingsStore = createSettingsStore(workspaceRoot); +const mcpConfigStore = createMcpConfigStore(workspaceRoot); +const mcpManager = new McpClientManager({ clientName: 'maka-desktop', clientVersion: app.getVersion() }); +let mcpStartup: Promise | undefined; +function ensureMcpReady(): Promise { + if (!mcpStartup) { + const startup = mcpConfigStore.get().then((config) => mcpManager.sync(config)); + mcpStartup = startup; + void startup.catch(() => { + if (mcpStartup === startup) mcpStartup = undefined; + }); + } + return mcpStartup; +} +const telemetryRepo = createSqliteTelemetryRepo(workspaceRoot); +// Canonical model-call accounting ledger (#1679). Separate store, same +// operational database: `telemetryRepo` is now a frozen historical projection +// for LLM calls, and every model call dispatched from here settles into this. +const modelCallLedger = createSqliteModelCallLedger(workspaceRoot); +const dailyReviewArchiveStore = createDailyReviewArchiveStore(workspaceRoot); +const artifactStore = createSqliteArtifactStore(workspaceRoot); +const deepResearchStore = createSqliteDeepResearchStore(workspaceRoot); +const storeReadImage = createReadImageSnapshotter(artifactStore); +const attachmentApprovals = createAttachmentApprovalRegistry(); +// PR-OAUTH-SUBSCRIPTION-0: Claude subscription OAuth service. +// Lives in main process only; renderer accesses via IPC. Tokens +// never cross the IPC boundary (xuan G-X3). Cloak path is dynamic- +// imported behind MAKA_CLAUDE_SUBSCRIPTION_CLOAK flag (xuan G-X4) +// and lives in a separate module not statically imported here. +const claudeSubscription = new ClaudeSubscriptionService({ + userDataDir: app.getPath('userData'), + openExternal: (url) => shell.openExternal(url), + credentialStore, +}); +// PR-MODEL-OAUTH-ALL-0: Codex / Cursor / Antigravity subscription +// services. Same shape as `claudeSubscription` — main-process only, +// IPC payloads never carry tokens, each gated behind its own +// MAKA_*_EXPERIMENTAL env var. Antigravity is a `preview` placeholder +// until the Google client_id question is resolved. +const openAiCodex = e2eFixture?.scenario === 'oauth-relogin' + ? createOpenAiCodexE2eFixtureService() + : new OpenAiCodexService({ + userDataDir: app.getPath('userData'), + openExternal: (url) => shell.openExternal(url), + credentialStore, + }); +const githubCopilotSubscription = new GitHubCopilotSubscriptionService({ credentialStore }); +const xaiOAuth = new XaiOAuthService({ + credentialStore, + openExternal: (url) => shell.openExternal(url), +}); +const buildSubscriptionModelFetch = createSubscriptionModelFetch({ + claudeSubscription, +}); +const oauthModelConnections = createOAuthModelConnectionsMainService({ + connectionStore, + credentialStore, + claudeSubscription, + openAiCodex, + githubCopilotSubscription, + xaiOAuth, + ...(e2eFixture?.scenario === 'oauth-relogin' + ? { fetchModels: async () => [{ id: 'gpt-5.6-sol' }] } + : {}), +}); +const isClaudeSubscriptionAuthenticatedState = oauthModelConnections.isClaudeSubscriptionAuthenticatedState; + +function syncClaudeSubscriptionConnection(): Promise { + return oauthModelConnections.syncClaudeSubscriptionConnection(); +} +function activateXaiOAuthConnection(): Promise { + return oauthModelConnections.activateXaiOAuthConnection(); +} +function syncXaiOAuthConnection(): Promise { + return oauthModelConnections.syncXaiOAuthConnection(); +} + +function syncOpenAiCodexConnection(): Promise { + return oauthModelConnections.syncOpenAiCodexConnection(); +} + +function activateOpenAiCodexConnection(): Promise { + return oauthModelConnections.activateOpenAiCodexConnection(); +} + +function syncGitHubCopilotConnection(): Promise { + return oauthModelConnections.syncGitHubCopilotConnection(); +} + +function syncOAuthModelConnections(): Promise { + return oauthModelConnections.syncOAuthModelConnections(); +} + +function disconnectManagedOAuthConnection(connection: LlmConnection): Promise { + return oauthModelConnections.disconnectManagedOAuthConnection(connection); +} + +function resolveConnectionSecret(slug: string): Promise { + return oauthModelConnections.resolveConnectionSecret(slug); +} + +const voiceIpcService = createVoiceIpcService({ + settingsStore, + connectionStore, + resolveConnectionSecret, +}); + +/** + * Read-only credential-presence check for status paths (onboarding's + * `getSnapshot`) that must not trigger `resolveConnectionSecret`'s + * OAuth near-expiry refresh — that refresh hits the network and + * mutates local token state, which a read-only status read must never + * do just by being observed. Send/test/fetch-models paths keep using + * `resolveConnectionSecret` so they still benefit from the refresh. + * + * Takes the `LlmConnection` directly rather than a slug: callers that + * already hold the connection list (onboarding does) skip the extra + * `connectionStore.get()` round trip and derive state from one + * consistent snapshot. + */ +function hasConnectionSecret(connection: LlmConnection): Promise { + return oauthModelConnections.hasConnectionSecret(connection); +} +const cursorSubscription = new CursorSubscriptionService({ + userDataDir: app.getPath('userData'), + openExternal: (url) => shell.openExternal(url), + credentialStore, +}); +const antigravitySubscription = new AntigravitySubscriptionService({ + userDataDir: app.getPath('userData'), + openExternal: (url) => shell.openExternal(url), + credentialStore, +}); + +const planReminderStore = createSqlitePlanReminderStore(workspaceRoot); +const taskLedgerWiring = createMainTaskLedgerWiring(workspaceRoot); +const taskLedgerStore = taskLedgerWiring.store; + +async function closeWorkflowStores(): Promise { + const stores = [ + planStore, + deepResearchStore, + planReminderStore, + taskLedgerStore, + ]; + const errors: unknown[] = []; + for (const result of await Promise.allSettled(stores.map((workflowStore) => workflowStore.ready()))) { + if (result.status === 'rejected') errors.push(result.reason); + } + for (const workflowStore of stores) { + try { + workflowStore.close(); + } catch (error) { + errors.push(error); + } + } + if (errors.length > 0) throw new AggregateError(errors, 'Unable to close workflow stores'); +} + +const sessionActivities = new SessionActivityRegistry(); + +// Unified Automation — single "Automation" tool for heartbeat + cron. +// Deps are resolved lazily since runtime/store aren't ready at this point. +const automationWiring = createMainAutomationWiring({ + workspaceRoot, + async canFire(automation): Promise { + // Kind-aware fire gate (see evaluateAutomationCanFire): incognito blocks all; + // cron is never gated on its creator session; heartbeat needs an idle session. + return evaluateAutomationCanFire(automation, { + isIncognitoActive: async () => (await getWorkspacePrivacyContext()).incognitoActive, + readSessionHeader: (sessionId) => store.readHeader(sessionId), + }); + }, + // Heartbeat: inject into the automation's own session; resolve after the stream. + async injectTurn(sessionId: string, prompt: string, automationId: string) { + await ensureSessionCanSend(sessionId); + const turnId = randomUUID(); + const iterator = runtime.sendMessage(sessionId, { + turnId, text: prompt, origin: { kind: 'automation', automationId }, + }); + const r = await streamEvents(sessionId, iterator, { + turnId, + goalBoundary: 'external', + }); + return { runId: turnId, ok: r.ok, ...(r.error ? { error: r.error } : {}) }; + }, + // Cron: spawn a FRESH session (explore mode — no unapproved side effects) and + // run the prompt there, so each fire is a first-class session + run. + async createFreshRun(prompt: string, automationId: string) { + const slug = await connectionStore.getDefault(); + const { connection, model } = await getReadyConnection(slug, undefined); + const session = await createDesktopSession({ + backend: 'ai-sdk', + llmConnectionSlug: connection.slug, + model, + permissionMode: 'explore', + name: `Automation: ${prompt.slice(0, 32)}`, + labels: ['automation', 'cron'], + }); + emitSessionsChanged('created', session.id); + await ensureSessionCanSend(session.id); + const turnId = randomUUID(); + const iterator = runtime.sendMessage(session.id, { + turnId, text: prompt, origin: { kind: 'automation', automationId }, + }); + const r = await streamEvents(session.id, iterator, { + turnId, + goalBoundary: 'external', + }); + // Archive the fresh cron session after its run finalizes so recurring crons + // do not accumulate an unbounded pile of active sessions. The session (with + // its run/trace) is preserved under the archive, labelled automation/cron. + try { + await agentGraphCoordinator.stop(session.id); + await goalWiring.archiveSession(session.id, () => runtime.archive(session.id)); + desktopSessionSkillHosts.delete(session.id); + emitSessionsChanged('archived', session.id); + } catch {} + return { runId: turnId, ok: r.ok, ...(r.error ? { error: r.error } : {}) }; + }, +}); + +// Load durable automations from disk on startup (fire-and-forget; errors are logged inside). +void automationWiring.loadDurableAutomations(); + +// Goal execution — autonomous turn-boundary continuation with an external +// evaluator (CC-style). Self-contained: no automation coupling (a goal is +// bounded by its own caps; a waiting goal re-checks via normal continuation). +const goalWiring = createMainGoalWiring({ + getDefaultConnectionSlug: () => connectionStore.getDefault(), + getConnection: (slug) => connectionStore.get(slug), + getSessionModel: async (sessionId) => { + const header = await store.readHeader(sessionId); + if (!header) return null; + return { connectionSlug: header.llmConnectionSlug, model: header.model }; + }, + resolveConnectionSecret, + buildSubscriptionModelFetch, + getAIModel: (input) => getAIModel(input), + buildProviderOptions: (connection, modelId) => buildProviderOptions(connection, modelId), + getRecentMessages: async (sessionId) => { + const messages = await runtime.getMessages(sessionId); + return messages.slice(-10).map((m) => ({ + type: m.type, + text: m.type === 'user' || m.type === 'assistant' ? m.text : undefined, + })); + }, + getTokenCount: async (sessionId) => { + const messages = await runtime.getMessages(sessionId); + let total = 0; + for (const m of messages) { + if (m.type === 'token_usage') total += (m.total ?? (m.input + m.output)); + } + return total; + }, + admitTurn: (sessionId, text) => { + const whenIdle = sessionActivities.whenIdle(sessionId); + if (whenIdle) return { kind: 'busy', whenIdle }; + const reservation = sessionActivities.reserve(sessionId); + const turnId = randomUUID(); + return { + kind: 'prepared', + turnId, + start: async (): Promise => { + try { + await ensureSessionCanSend(sessionId); + const iterator = runtime.sendMessage(sessionId, { turnId, text }); + return (await streamEvents(sessionId, iterator, { + turnId, + goalBoundary: 'coordinator', + activity: reservation, + })).outcome; + } catch (error) { + reservation.release(); + return { + kind: 'errored', + turnId, + reason: `Goal continuation could not start: ${errorMessage(error)}`, + }; + } + }, + }; + }, + // Surface every goal transition to the renderer so an active autonomous loop + // is visible (badge + clear affordance) — never a silent token burn. + onGoalChange: (goal) => emitSessionsChanged('goal-change', goal.sessionId), + listActionableTaskKeys: async (sessionId) => { + const tasks = await taskLedgerStore.list(sessionId, { + includeTerminal: false, + includeArchived: false, + }); + return filterModelVisibleTaskLedgerTasks(tasks) + .filter((task) => task.status === 'pending' || task.status === 'in_progress') + .map((task) => task.key); + }, + recordTaskGateDecision: async (trace) => { + const runs = await runStore.listSessionRuns(trace.sessionId); + const run = runs.find((candidate) => candidate.turnId === trace.turnId); + if (!run) return; + await runStore.appendEvent(trace.sessionId, run.runId, { + type: 'task_gate_decided', + id: randomUUID(), + runId: run.runId, + sessionId: trace.sessionId, + turnId: trace.turnId, + ts: Date.now(), + message: `Task gate: ${trace.decision}`, + data: { + goalId: trace.goalId, + decision: trace.decision, + taskKeys: trace.taskKeys, + }, + }); + }, +}); + +async function getWorkspacePrivacyContext(): Promise { + const settings = await settingsStore.get(); + return { incognitoActive: settings.privacy.incognitoActive === true }; +} + +const localMemory = new LocalMemoryService({ + workspaceRoot, + getSettings: () => settingsStore.get(), + updateSettings: (patch) => settingsStore.update(patch), + getPrivacyContext: getWorkspacePrivacyContext, +}); +// The synchronous Runtime Skill tools execute inside an already-built backend. +// Their resolver uses the exact host cached by that backend. Pre-send +// invocation and slash discovery derive a fresh surface from the persisted +// session header instead; see resolveDesktopSkillHostForSession below. +const desktopSessionSkillHosts = new Map(); +const resolveDesktopSkillHost: HostCapabilitiesResolver = ({ sessionId }) => + desktopSessionSkillHosts.get(sessionId) ?? desktopProductToolSurface.hostCapabilities; +// Window is created hidden for E2E and e2e-fixture runs so it never steals +// focus. Derived from the same isE2e gate as userData/fake-backend so the +// hidden-window switch stays in lockstep with the rest of the E2E isolation. +// MAKA_E2E_SHOW_WINDOW opts back into a visible window where there is no +// focus to steal (CI under xvfb): hidden windows only get ~1fps compositor +// BeginFrames on Linux, which stalls content-visibility inflation and any +// frame-paced E2E protocol (measured in the scroll-geometry climb: 38 frames +// over 31s). The E2E harness sets it, not the workflow — see fixtures.ts. +// This value is also what hides the macOS dock icon (see app-lifecycle.ts): +// staying out of sight and staying out of the Dock are one decision, so a run +// that opts into a visible window also opts back into Dock and Cmd+Tab. +const startHidden = (Boolean(e2eFixture) || isIsolatedE2e) + && process.env.MAKA_E2E_SHOW_WINDOW !== '1'; +let onMainWindowClose = (): void => {}; +const mainWindowController = createMainWindowController({ + workspaceRoot, + e2eFixture, + settingsStore, + startHidden, + onClose: () => onMainWindowClose(), +}); +// Shared by 'second-instance' and 'activate': focus the existing window, or +// create one if all windows were closed while the app (macOS: still in the +// dock) stayed running -- a second launch attempt must not be a silent no-op. +function focusOrCreateMainWindow(signal: AbortSignal): void { + if (mainWindowController.hasOpenWindows()) { + mainWindowController.focus(); + } else { + void mainWindowController + .createWindow(signal) + .catch((error) => console.error('[window] failed to create:', error)); + } +} +const safeSendToRenderer = mainWindowController.send; +taskLedgerStore.subscribe((event) => safeSendToRenderer('tasks:changed', event)); +deepResearchStore.subscribe((event) => safeSendToRenderer('deepResearch:changed', event)); +const deepResearchTools = buildDeepResearchTools({ + store: deepResearchStore, + artifactStore, + onArtifactCreated: (event) => safeSendToRenderer('artifacts:changed', event), +}); +const backends = new BackendRegistry(); +const shellRuns = new ShellRunProcessManager({ + store: shellRunStore, + newId: randomUUID, + now: Date.now, + onShellRunUpdate: (update) => { + safeSendToRenderer('shell-runs:update', update); + }, +}); +const { + persistToolArtifacts, + snapshotReadImage, + persistArchivedToolResult, + readArchivedToolResult, + readArchivedToolResultResource, +} = createToolArtifactPersistence({ artifactStore, storeReadImage, safeSendToRenderer }); + +const { + riveTools, + browserTools, + computerUse, + computerUseOverlay, + computerUseTools, + desktopProductToolSurface, + builtinTools, + childAgentTools, + sandboxDiagnosticsProvider, +} = assembleDesktopTools({ + isComputerUseRealModelE2e, + workspaceRoot, + taskLedgerStore, + taskLedgerWiring, + automationWiring, + goalWiring, + settingsStore, + updateAgentSettings, + shellRuns, + snapshotReadImage, + readArchivedToolResultResource, + getWorkspacePrivacyContext, + resolveDesktopSkillHost, +}); +let agentGraphCoordinator: AgentGraphCoordinator; +let agentGraphSupervisorWakeCoordinator: AgentGraphSupervisorWakeCoordinator; +const desktopBackendToolSurfaceDeps = { + isComputerUseRealModelE2e, + ensureMcpReady, + getReadyConnection, + mcpManager, + deepResearchTools, + computerUseTools, + builtinTools, + toolEconomy: desktopProductToolSurface.identity.policy.economy, + planStore, + getAgentGraphSupervisorTools: (sessionId: string) => + agentGraphCoordinator.toolsForSession(sessionId), +}; +// Cursor-overlay teardown assigns a module-scoped `let`, so it stays in main.ts. +onMainWindowClose = () => computerUseOverlay.destroyAll(); +const systemPromptService = createSystemPromptMainService({ + settingsStore, + workspaceRoot, + localMemory, + taskLedger: taskLedgerStore, + goalManager: goalWiring.manager, + hostCapabilities: desktopProductToolSurface.hostCapabilities, +}); +let lookupPricing = buildPricingLookup(); +let usageReadiness: Promise | undefined; +function ensureUsageReady(): Promise { + if (!usageReadiness) { + const readiness = telemetryRepo.load().then(() => { + lookupPricing = buildPricingLookup(telemetryRepo.listPricingOverrides()); + }); + usageReadiness = readiness; + void readiness.catch(() => { + if (usageReadiness === readiness) usageReadiness = undefined; + }); + } + return usageReadiness; +} +// Track the last status fields that affect persisted diagnostics. The reason +// is part of the key because a running bridge can remain degraded while a +// newer, more useful failure replaces the previous one. +const previousBotStatus = new Map>(); +let botIncoming: ReturnType; +// Single authority for the "current project root" selection, shared across the +// app/window, git, workspace-search, workspace-instructions, and session-entry +// IPC surfaces. botIncoming and automation cron runs read the current +// selection through the thin `resolveCurrentProjectRoot` adapter below. +const projectRootController = createProjectRootController({ + lastProjectPathFile: join(workspaceRoot, 'last-project-path.json'), + fallbackRoots: () => [process.cwd(), app.getAppPath()], +}); +const projectManagement = createProjectManagementService({ + catalog: projectCatalog, + sessions: store, + chooseDirectory: async () => { + const result = await mainWindowController.showOpenDialog({ + title: '添加项目', + properties: ['openDirectory'], + }); + return result.canceled ? undefined : result.filePaths[0]; + }, + selection: projectRootController, +}); +const resolveCurrentProjectRoot: () => Promise = () => projectRootController.current(); +const resolveProjectRootForContext = (sessionId: unknown): Promise => + resolveProjectContextRoot(sessionId, { + currentProjectRoot: resolveCurrentProjectRoot, + readSessionCwd: async (id) => (await store.readHeader(id)).cwd, + }); +const botRegistry = new BotRegistry({ + onIncomingMessage: (message: BotIncomingMessage) => { + // Only log incoming bot messages in dev — production stdout leaking + // platform + chatId is operational noise at best and a small privacy + // signal at worst (which bridges are connected, with what frequency). + if (process.env.VITE_DEV_SERVER_URL || process.env.NODE_ENV === 'development') { + console.log('[bot] incoming message', message.platform, message.chatId); + } + void botIncoming.handleBotIncomingMessage(message); + }, + onStatusChange: (status: BotStatus) => { + safeSendToRenderer('settings:bots:statusChanged', status); + // PR-BOT-LASTERROR-FROM-SEND-0: persist send-path failure reasons + // to settings so they survive a Settings page close/reopen. The + // existing connection-test path writes `lastError` only on test + // failures; without this hook, a runtime 429 / timeout would + // disappear the moment the renderer status panel closed. + const previous = previousBotStatus.get(status.platform); + previousBotStatus.set(status.platform, { + readiness: status.readiness, + reason: status.reason, + }); + const update = deriveBotStatusPersistenceUpdate(previous, status); + if (update) { + void settingsStore.update({ + botChat: { + channels: { + [status.platform]: { + ...update, + readinessUpdatedAt: Date.now(), + }, + }, + }, + }).catch(() => {}); + } + }, +}); +const planReminders = createPlanReminderMainService({ + store: planReminderStore, + getPrivacyContext: getWorkspacePrivacyContext, + sendBotMessage: (platform, chatId, text) => + botRegistry.sendMessage(platform, chatId, text), + emitChanged: (reason, reminder) => { + safeSendToRenderer('plans:changed', { + type: 'plans_changed', + reason, + reminderId: reminder.id, + ts: Date.now(), + }); + }, + emitDue: (reminder) => { + safeSendToRenderer('plans:due', reminder); + }, +}); + + +backends.register('ai-sdk', createAiSdkBackendFactory({ + ...desktopBackendToolSurfaceDeps, + buildSubscriptionModelFetch, + systemPromptService, + telemetryRepo, + modelCallLedger, + ensureUsageReady, + artifactStore, + desktopSessionSkillHosts, + sandboxDiagnosticsProvider, + persistToolArtifacts, + persistArchivedToolResult, + readArchivedToolResult, + runtimeCommitStore: runtimePersistence.runtimeCommitStore, + safeSendToRenderer, + emitSessionsChanged, + getRuntime: () => runtime, + getLookupPricing: () => lookupPricing, +})); + +backends.register('fake', (ctx) => + new FakeBackend({ sessionId: ctx.sessionId, header: ctx.header, store: ctx.store, appendMessage: ctx.appendMessage }), +); + +// E2E: also route 'ai-sdk' (requested by sessions:create, the single +// session-creation IPC) through the deterministic fake backend, so no +// session-creation path can escape the E2E seam and hit a real provider. +// Registered after the real ai-sdk factory to override it (BackendRegistry +// uses last-write-wins). +// Production builds never set MAKA_E2E. +if (isE2e) { + backends.register('ai-sdk', (ctx) => + new FakeBackend({ sessionId: ctx.sessionId, header: ctx.header, store: ctx.store, appendMessage: ctx.appendMessage }), + ); +} + +const runtime = new SessionManager({ + store, + planStore, + runStore, + runtimeEventStore, + ...(runtimePersistence.runtimeCommitStore + ? { toolBoundaryProtocol: runtimePersistence.runtimeCommitStore.toolBoundaryProtocol } + : {}), + shellRuns, + backends, + childTools: childAgentTools, + worktreeChildExecutor, + safeBoundaryResumeEnabled: process.env.MAKA_RUNTIME_SAFE_BOUNDARY_RESUME === '1', + onContinuationLifecycleEvent: (event) => { + console.info('[runtime-resume]', JSON.stringify(event)); + }, + inspectContinuationSafety: createLocalContinuationSafetyInspector({ + readSessionCwd: async (sessionId) => (await store.readHeader(sessionId)).cwd, + resolveWorkspaceIdentity: async (cwd) => resolveWorkspaceIdentity({ path: cwd }), + listAvailableToolNames: async () => builtinTools.map((tool) => tool.name), + hasPendingBackgroundOperations: async (sessionId) => { + const [shellUpdates, runs] = await Promise.all([ + shellRuns.listSessionUpdates(sessionId), + runStore.listSessionRuns(sessionId), + ]); + return ( + shellUpdates.some((update) => isActiveShellRunStatus(update.result.status)) || + runs.some( + (run) => + run.parentRunId !== undefined && + ['created', 'running', 'waiting_for_user'].includes(run.status), + ) + ); + }, + }), + listArtifactsForTurn: async (sessionId, turnId) => + (await artifactStore.list(sessionId)).filter((artifact) => + artifact.turnId === turnId && artifact.status !== 'deleted' + ), + cleanupHistoryCompactArtifacts: async (input) => { + await cleanupLegacyHistoryCompactArtifacts({ + ...input, + artifactStore, + onDiagnostic: (diagnostic) => console.warn('[history-compact-cleanup]', diagnostic), + }); + }, + generateSessionTitle: async ({ sessionId, header, sourceText }) => { + const { connection, apiKey, model } = await getReadyConnection(header.llmConnectionSlug, header.model); + return generateRuntimeSessionTitle({ + model: getAIModel({ + connection, + apiKey: apiKey ?? '', + modelId: model, + fetch: buildSubscriptionModelFetch(connection, sessionId, model), + }), + providerOptions: buildProviderOptions(connection, model), + sourceText, + }); + }, + onSessionTitleChanged: (sessionId) => emitSessionsChanged('renamed', sessionId), + newId: randomUUID, + now: Date.now, +}); +agentGraphSupervisorWakeCoordinator = new AgentGraphSupervisorWakeCoordinator({ + activityRegistry: sessionActivities, + wakeStore: agentGraphControlStore, + readSnapshot: (rootSessionId) => agentGraphCoordinator.getSnapshot(rootSessionId), + startTurn: async (sessionId, input, activity, abortSignal) => { + let stopPromise: Promise | undefined; + const stop = () => { + stopPromise ??= runtime.stopSession(sessionId, { source: 'graph_supervisor' }); + }; + abortSignal.addEventListener('abort', stop, { once: true }); + if (abortSignal.aborted) stop(); + try { + await ensureSessionCanSend(sessionId); + if (abortSignal.aborted) { + return { kind: 'aborted', turnId: input.turnId }; + } + const iterator = runtime.sendMessage(sessionId, input); + return ( + await streamEvents(sessionId, iterator, { + turnId: input.turnId, + goalBoundary: 'none', + activity, + }) + ).outcome; + } finally { + abortSignal.removeEventListener('abort', stop); + await stopPromise; + } + }, + inspectAttempt: async (rootSessionId, attemptId, turnId) => { + const runs = (await runStore.listSessionRuns(rootSessionId)).filter( + (run) => run.agentGraphWakeAttemptId === attemptId && run.turnId === turnId, + ); + if (runs.length > 1) { + throw new Error( + `Agent graph supervisor wake attempt ${attemptId} has multiple AgentRuns`, + ); + } + return runs[0]?.status ?? 'missing'; + }, + newId: randomUUID, + onError: (rootSessionId) => { + emitSessionsChanged('status-change', rootSessionId); + }, +}); +agentGraphCoordinator = new AgentGraphCoordinator({ + sessionStore: store, + runStore, + runtimeEventStore, + controlStore: agentGraphControlStore, + runtime, + newId: randomUUID, + onReconciliation: (rootSessionId, result) => { + agentGraphSupervisorWakeCoordinator.notify(rootSessionId, result); + }, +}); +let settingsIpc: SettingsIpcHandle | undefined; +let mcpToolSnapshot = JSON.stringify(mcpManager.tools()); +mcpManager.onChange(() => { + safeSendToRenderer('mcp:changed', mcpManager.statuses()); + const nextSnapshot = JSON.stringify(mcpManager.tools()); + if (nextSnapshot === mcpToolSnapshot) return; + mcpToolSnapshot = nextSnapshot; + void runtime.refreshIdleBackends().catch((error) => { + console.warn('[mcp] failed to refresh backend tool snapshots:', error); + }); +}); +const dailyReview = createDailyReviewMainService({ + archiveStore: dailyReviewArchiveStore, + connectionStore, + telemetryRepo, + modelCallLedger, + ensureUsageReady, + listSessions: async () => collapseSessionRevisions(await runtime.listSessions()), + resolveConnectionSecret, + buildSubscriptionModelFetch, +}); +botIncoming = createBotIncomingMainService({ + runtime, + createSession: createDesktopSession, + botRegistry, + getDefaultConnectionSlug: () => connectionStore.getDefault(), + getReadyConnection, + readSessionHeader: async (sessionId) => { + try { + return await store.readHeader(sessionId); + } catch (error) { + throw sessionLifecycleErrorFromReadFailure(error) ?? error; + } + }, + ensureSessionCanSend, + emitSessionsChanged, + runAgentTurn: ({ sessionId, iterator, turnId, onEvent }) => streamEvents(sessionId, iterator, { + turnId, + goalBoundary: 'external', + observeEvent: onEvent, + }), +}); + +// PR110b: onboarding service composes existing stores + runtime to +// derive `OnboardingState` and manage `OnboardingMilestone[]`. +// Constructed AFTER `runtime` so `listSessions()` is bindable. The +// service checks credential presence through `hasConnectionSecret` +// (read-only — recognizes OAuth-subscription connections like the +// send-path's `resolveConnectionSecret` does, but never refreshes), +// so simply opening onboarding can't hit the network or mutate token +// state. +const onboardingService = createOnboardingService( + bindOnboardingDeps({ + settingsStore, + connectionStore, + hasCredential: hasConnectionSecret, + listSessions: () => runtime.listSessions(), + }), +); + +function registerIpc(): void { + const currentProjectRoot = resolveCurrentProjectRoot; + ipcMain.handle('deepResearch:get', (_event, sessionId: string) => + deepResearchStore.read(sessionId)); + registerMcpIpcMain({ + ipcMain, + store: mcpConfigStore, + manager: mcpManager, + ensureReady: ensureMcpReady, + refreshIdleBackends: () => runtime.refreshIdleBackends(), + emitChanged: (statuses) => safeSendToRenderer('mcp:changed', statuses), + }); + + registerAppIpc({ + mainWindowController, + projectRoot: projectRootController, + getSessionProjectRoot: async (sessionId) => (await store.readHeader(sessionId)).cwd, + getProjectRoot: resolveProjectRootForContext, + workspaceRoot, + buildInfo, + e2eFixture, + projectManagement, + }); + registerMemoryIpc({ localMemory }); + registerConfigIpc({ connectionStore, settingsStore, credentialStore, workspaceRoot }); + registerNotificationsIpc({ settingsStore, mainWindowController, e2e: isE2e }); + registerWorkspaceInstructionsIpc({ getCurrentProjectRoot: currentProjectRoot }); + registerWorkspaceResourcesIpc({ + workspaceRoot, + artifactStore, + mainWindowController, + sendToRenderer: safeSendToRenderer, + listInvocableSkills: listDesktopInvocableSkills, + skillHost: desktopProductToolSurface.hostCapabilities, + getCurrentProjectRoot: currentProjectRoot, + getSkillSelectionReport: systemPromptService.getLastSkillSelectionReport, + invalidateSkillSelectionReport: systemPromptService.invalidateSkillSelectionReport, + }); + registerWorkspaceSearchIpc({ getProjectRoot: resolveProjectRootForContext }); + registerGitIpc({ getProjectRoot: resolveProjectRootForContext }); + registerPlanReminderIpc({ planReminders, getWorkspacePrivacyContext }); + registerAgentGraphIpc({ + coordinator: agentGraphCoordinator, + sendToRenderer: safeSendToRenderer, + }); + registerVoiceIpc({ ipcMain, service: voiceIpcService }); + registerSessionsIpc({ + workspaceRoot, + runtime, + store, + taskLedgerStore, + goalWiring, + automationManager: automationWiring.manager, + computerUseOverlay, + computerUseTools, + artifactStore, + attachmentApprovals, + settingsStore, + connectionStore, + mainWindowController, + e2eFixture, + emitSessionsChanged, + ensureSessionCanSend, + prepareSkillInvocation: prepareDesktopSkillInvocation, + invalidateSessionBindings: (sessionId) => botIncoming.invalidateSessionBindings(sessionId), + clearSkillHost: (sessionId) => desktopSessionSkillHosts.delete(sessionId), + stopAgentGraph: async (sessionId) => { + const header = await store.readHeader(sessionId); + if (!header.subagentParent) await agentGraphCoordinator.stop(sessionId); + }, + notifyAgentGraphPermissionResponse: (sessionId) => { + agentGraphSupervisorWakeCoordinator.notifyPermissionResponse(sessionId); + }, + ensureSessionWorkspaceAvailable, + createSession: createDesktopSession, + getReadyConnection, + streamEvents, + getWorkspacePrivacyContext, + canCreateFakeSession: canCreateFakeSessionFromRenderer, + consumeNativeAudioOperation: (input) => + voiceIpcService.consumeNativeAudioOperation(input), + }); + registerSubscriptionIpc({ + ipcMain, + connectionStore, + claudeSubscription, + openAiCodex, + githubCopilotSubscription, + xaiOAuth, + cursorSubscription, + antigravitySubscription, + isClaudeSubscriptionAuthenticatedState, + syncClaudeSubscriptionConnection, + activateOpenAiCodexConnection, + syncOpenAiCodexConnection, + syncGitHubCopilotConnection, + activateXaiOAuthConnection, + syncXaiOAuthConnection, + emitConnectionListChanged, + }); + registerWebSearchIpc({ settingsStore, getWorkspacePrivacyContext }); + registerBrowserIpc({ mainWindowController }); + registerConnectionsIpc({ + ipcMain, + connectionStore, + credentialStore, + syncOAuthModelConnections, + resolveConnectionSecret, + hasConnectionSecret, + disconnectManagedOAuthConnection, + emitConnectionListChanged, + // Same seam as the fake-backend override above, for the other IPC that can + // leave the machine: adding a catalog provider runs remote model discovery + // against the provider's real endpoint. In E2E the key is a placeholder, so + // discovery can only fail — but it fails at whatever speed the network + // answers, and the add dialog stays open for the whole round trip. The + // provider-side budget (10s) is exactly the suite's expect timeout (10s), + // so a slow answer flips `await expect(dialog).toBeHidden()` from pass to + // fail with no code change. Fail deterministically and offline instead, + // which is the outcome a placeholder key produces anyway. + ...(isE2e + ? { + fetchModels: async () => { + throw new Error('E2E: remote model discovery is disabled'); + }, + } + : {}), + }); + registerOnboardingIpc({ onboardingService }); + registerPermissionsIpc({ + settingsStore, + connectionStore, + telemetryRepo, + modelCallLedger, + ensureUsageReady, + botRegistry, + getComputerUseCapabilityInput: computerUseCapabilityInput, + }); + // Drag-to-grant onboarding for the two TCC permissions macOS offers no + // programmatic consent dialog for. See docs/permission-onboarding-plan.md. + const permissionOverlay = createPermissionOverlayMain({ + resolveLocale: async () => { + const settings = await settingsStore.get(); + return resolveUiLocale( + settings.personalization.uiLocale, + resolveSystemUiLocale(app.getPreferredSystemLanguages()), + ); + }, + }); + registerPermissionOverlayIpc({ controller: permissionOverlay }); + // A screen-saver-level panel pinned to every Space is visible to the + // user if it outlives a slow quit; close it explicitly rather than + // relying on process teardown to race it away. + app.on('before-quit', () => permissionOverlay.dismiss()); + settingsIpc = registerSettingsIpc({ + settingsStore, + botRegistry, + normalizeSettingsPatch, + applySettingsRuntimeEffects, + ...(e2eFixture?.scenario === 'settings-bots' + ? { + botOnboardingAdapters: createE2eFixtureBotOnboardingAdapters(), + botOnboardingApplySettingsRuntimeEffects: async () => undefined, + // The fixture no-ops runtime effects, so no real bridge starts. + // Report the onboarded channel as running to demonstrate the + // successful "connected" path (the P0-3 warning path is covered by + // bot-onboarding-main.test.ts). + botOnboardingReadChannelStatus: () => ({ running: true }), + } + : {}), + }); + registerDailyReviewIpc({ dailyReview, dailyReviewArchiveStore, mainWindowController }); + registerUsageIpc({ + ipcMain, + settingsStore, + telemetryRepo, + modelCallLedger, + readRunEvents: (sessionId, runId) => runStore.readEvents(sessionId, runId), + ensureUsageReady, + refreshPricingLookup: () => { + lookupPricing = buildPricingLookup(telemetryRepo.listPricingOverrides()); + }, + sendToRenderer: safeSendToRenderer, + }); +} + +function canCreateFakeSessionFromRenderer(): boolean { + return !app.isPackaged && ( + Boolean(e2eFixture) || + Boolean(process.env.VITE_DEV_SERVER_URL) || + process.env.NODE_ENV === 'development' + ); +} + +const { normalizeSettingsPatch, applySettingsRuntimeEffects, handleExternalSettingsChange } = + createSettingsRuntimeEffects({ + settingsStore, + botRegistry, + keepSystemAwake, + safeSendToRenderer, + }); + +async function updateAgentSettings(patch: UpdateAppSettingsInput): Promise { + const normalizedPatch = await normalizeSettingsPatch(patch); + const next = await settingsStore.update(normalizedPatch); + await applySettingsRuntimeEffects(next, patch); + safeSendToRenderer('settings:externalChanged', { ts: Date.now() }); + return next; +} + +const streamEvents = createSessionStreamer({ + sessionActivities, + goalWiring, + computerUseOverlay, + computerUseTools, + safeSendToRenderer, + emitSessionsChanged, + interruptActivePlanExecution: (sessionId, reason) => + runtime.interruptActivePlanExecution(sessionId, reason), +}); + +async function ensureSessionCanSend(sessionId: string): Promise { + const boundary = await runtime.readExecutionBoundary(sessionId); + assertDesktopExecutionBoundary(sessionId, boundary); + const header = await readAvailableSessionHeader(sessionId); + let result: Awaited>; + try { + result = await ensureSessionCanSendOrRebind(sessionId, header, { + readyConnectionDeps, + getDefaultSlug: () => connectionStore.getDefault(), + listConnectionSlugs: async () => (await connectionStore.list()).map((connection) => connection.slug), + updateSession: (_sessionId, patch) => runtime.updateSession(_sessionId, { + ...patch, + status: 'active', + blockedReason: undefined, + statusUpdatedAt: Date.now(), + }), + }); + } catch (error) { + if (isSessionLifecycleError(error)) throw error; + await runtime.setSessionStatus(sessionId, 'blocked', 'NO_REAL_CONNECTION').catch(() => {}); + emitSessionsChanged('status-change', sessionId); + throw error; + } + if (result.rebound) { + emitSessionsChanged('rebound', sessionId, { + connectionSlug: result.connectionSlug, + modelId: result.modelId, + }); + } +} + +async function readAvailableSessionHeader(sessionId: string) { + let header; + try { + header = await store.readHeader(sessionId); + } catch (error) { + const lifecycleError = sessionLifecycleErrorFromReadFailure(error); + if (lifecycleError) throw lifecycleError; + throw error; + } + assertSessionCanSendFromHeader(header); + await assertSessionWorkspaceAvailable(header.cwd); + return header; +} + +async function ensureSessionWorkspaceAvailable(sessionId: string): Promise { + await readAvailableSessionHeader(sessionId); +} + +async function createDesktopSession(input: DesktopCreateSessionInput) { + const selected = await resolveDesktopSessionSelection(input, projectManagement); + await assertSessionWorkspaceAvailable(selected.cwd); + return runtime.createSession(await resolveNewSessionProjectInput(selected, projectCatalog)); +} + +const readyConnectionDeps = { + getConnection: (slug: string) => connectionStore.get(slug), + getApiKey: (slug: string) => resolveConnectionSecret(slug), +}; + +function getReadyConnection(slug: string | null | undefined, model?: string) { + return requireReadyConnection(slug, readyConnectionDeps, model); +} + +async function resolveDesktopSkillHostForSession( + sessionId: string, +): Promise { + const header = await store.readHeader(sessionId); + return resolveDesktopSessionSkillHost(desktopBackendToolSurfaceDeps, { + sessionId, + header, + childTools: childAgentTools, + }); +} + +async function resolveDesktopSkillHostForNewSession( + projectRoot: string, + context?: NewSessionSkillContext, +): Promise { + const ready = await getReadyConnection( + context?.llmConnectionSlug ?? (await connectionStore.getDefault()), + context?.model, + ); + return resolveDesktopNewSessionSkillHost(desktopBackendToolSurfaceDeps, { + projectRoot, + workspaceRoot, + readyConnection: ready, + context, + }); +} + +async function prepareDesktopSkillInvocation( + sessionId: string, + text: string, + skillIds?: readonly string[], +) { + const [projectRoot, host] = await Promise.all([ + resolveProjectRootForContext(sessionId), + resolveDesktopSkillHostForSession(sessionId), + ]); + return prepareSkillInvocationMessage({ + text, + ...(skillIds ? { skillIds } : {}), + source: resolveSkillDiscoveryPaths(projectRoot, workspaceRoot), + host, + }); +} + +async function listDesktopInvocableSkills( + sessionId?: string, + newSessionContext?: NewSessionSkillContext, +) { + try { + const projectRoot = await resolveProjectRootForContext(sessionId); + const host = sessionId + ? await resolveDesktopSkillHostForSession(sessionId) + : await resolveDesktopSkillHostForNewSession(projectRoot, newSessionContext); + return await listInvocableSkills( + resolveSkillDiscoveryPaths(projectRoot, workspaceRoot), + host, + ); + } catch (error) { + // Stale sessions with a removed working directory remain browseable, but + // cannot offer project-aware Skill suggestions. Treat that expected state + // as an empty projection instead of generating a rejected IPC/log entry. + if (sessionId && isSessionWorkspaceUnavailableError(error)) return []; + throw error; + } +} + +function emitConnectionListChanged(): void { + const event: ConnectionEvent = { + type: 'connection_list_changed', + id: randomUUID(), + ts: Date.now(), + }; + safeSendToRenderer('connections:event', event); +} + +function emitSessionsChanged( + reason: SessionChangedReason, + sessionId?: string, + extra?: Pick, +): void { + const event: SessionChangedEvent = { + type: 'sessions_changed', + reason, + ts: Date.now(), + }; + if (sessionId) event.sessionId = sessionId; + if (extra?.connectionSlug) event.connectionSlug = extra.connectionSlug; + if (extra?.modelId) event.modelId = extra.modelId; + safeSendToRenderer('sessions:changed', event); +} + +registerIpc(); + +wireAppLifecycle({ + startHidden, + e2eFixture, + workspaceRoot, + sessionStore: store, + projectCatalog, + credentialStore, + connectionStore, + settingsStore, + telemetryRepo, + artifactStore, + modelCallLedger, + ensureUsageReady, + keepSystemAwake, + botRegistry, + planReminders, + dailyReview, + automationWiring, + goalWiring, + computerUse, + computerUseOverlay, + shellRuns, + mcpManager, + runtimePersistence, + executionStoreWiring, + closeWorkflowStores, + mainWindowController, + runtime, + agentGraphCoordinator, + agentGraphSupervisorWakeCoordinator, + agentGraphControlStore, + streamEvents, + focusOrCreateMainWindow, + emitConnectionListChanged, + emitSessionsChanged, + handleExternalSettingsChange, + getSettingsIpc: () => settingsIpc, +}); + +function computerUseCapabilityInput() { + const serviceState = computerUse.backend?.serviceState?.(); + return { + backendId: computerUse.backendId, + health: computerUseServiceHealth(computerUse.backendId, serviceState), + }; +} diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index bab390236a..5c7985913b 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -1,1461 +1,43 @@ -import { app, dialog, ipcMain, powerSaveBlocker, shell } from 'electron'; -import { randomUUID } from 'node:crypto'; -import { join } from 'node:path'; -import { wireAppLifecycle } from './app-lifecycle.js'; -import { - collapseSessionRevisions, - filterModelVisibleTaskLedgerTasks, - isActiveShellRunStatus, - resolveSystemUiLocale, - resolveUiLocale, -} from '@maka/core'; -import type { - AppSettings, - BotProvider, - ConnectionEvent, - SessionChangedEvent, - SessionChangedReason, - SessionEvent, - SessionHeader, - UpdateAppSettingsInput, -} from '@maka/core'; -import { deriveBotStatusPersistenceUpdate } from './bot-status-persistence.js'; -import { runThreadSearch } from './search/thread-search.js'; -import { assembleDesktopTools } from './tool-assembly.js'; -import { createToolArtifactPersistence } from './tool-artifact-persistence.js'; -import { ClaudeSubscriptionService } from './oauth/claude-subscription-service.js'; -import { OpenAiCodexService } from './oauth/openai-codex-service.js'; -import { createOpenAiCodexE2eFixtureService } from './openai-codex-e2e-fixture.js'; -import { GitHubCopilotSubscriptionService } from './oauth/github-copilot-subscription-service.js'; -import { XaiOAuthService } from './oauth/xai-oauth-service.js'; -import { CursorSubscriptionService } from './oauth/cursor-subscription-service.js'; -import { AntigravitySubscriptionService } from './oauth/antigravity-subscription-service.js'; -import type { WorkspacePrivacyContext } from '@maka/core/incognito'; -import { ok } from '@maka/core/result'; -import { - AgentGraphCoordinator, - AgentGraphSupervisorWakeCoordinator, - BackendRegistry, - FakeBackend, - SessionManager, - createLocalContinuationSafetyInspector, - buildDeepResearchTools, - getAIModel, - generateSessionTitle as generateRuntimeSessionTitle, - buildProviderOptions, - buildPricingLookup, - BotRegistry, - ShellRunProcessManager, - SessionActivityRegistry, - listInvocableSkills, - prepareSkillInvocationMessage, - resolveSkillDiscoveryPaths, -} from '@maka/runtime'; -import type { - BotIncomingMessage, - BotStatus, - GoalTurnOutcome, - HostCapabilities, - HostCapabilitiesResolver, -} from '@maka/runtime'; -import type { LlmConnection } from '@maka/core/llm-connections'; -import { - createSqliteArtifactStore, - createSqliteDeepResearchStore, - createReadImageSnapshotter, - createConnectionStore, - createGitWorktreeChildExecutor, - createSqlitePlanReminderStore, - createSqlitePlanStore, - createProjectCatalog, - openRuntimeEventPersistence, - createSessionStore, - createSettingsStore, - createMcpConfigStore, - createSqliteModelCallLedger, - createSqliteTelemetryRepo, -} from '@maka/storage'; -import { createAgentGraphControlStore } from '@maka/storage/agent-graph-control-store'; -import { resolveWorkspaceIdentity } from '@maka/storage/workspace-identity'; -import { McpClientManager } from '@maka/mcp'; -import { registerMcpIpcMain } from './mcp-ipc-main.js'; -import { - ensureSessionCanSendOrRebind, - errorMessage, - requireReadyConnection, -} from './chat-readiness.js'; -import { assertDesktopExecutionBoundary } from './desktop-execution-admission.js'; -import { createFileCredentialStore } from './credential-store.js'; -import { bindOnboardingDeps, createOnboardingService } from './onboarding-service.js'; -import { createDailyReviewArchiveStore } from './daily-review-archive-store.js'; -import { resolveE2eFixture, seedE2eFixture } from './e2e-fixture.js'; -import { resolveBuildInfo } from './build-info.js'; -import { resolveShellEnv } from './shell-env.js'; -import { LocalMemoryService } from './local-memory-service.js'; -import { createAttachmentApprovalRegistry } from './attachment-approval.js'; -import { cleanupLegacyHistoryCompactArtifacts } from '@maka/runtime'; -import { computerUseServiceHealth } from './computer-use-host.js'; -import { createMainWindowController } from './main-window.js'; -import { createDailyReviewMainService } from './daily-review-main.js'; -import { createPlanReminderMainService } from './plan-reminders-main.js'; -import { createBotIncomingMainService } from './bot-incoming-main.js'; -import { createSubscriptionModelFetch } from './subscription-model-fetch.js'; -import { createSystemPromptMainService } from './system-prompt-main.js'; -import { createMainTaskLedgerWiring } from './task-ledger-wiring.js'; -import { createMainAutomationWiring, evaluateAutomationCanFire } from './automation-wiring.js'; -import { createMainGoalWiring } from './goal-wiring.js'; -import { createOAuthModelConnectionsMainService } from './oauth-model-connections-main.js'; -import { registerMemoryIpc } from './memory-ipc-main.js'; -import { registerSubscriptionIpc } from './subscription-ipc-main.js'; -import { registerBrowserIpc } from './browser-ipc-main.js'; -import { registerConnectionsIpc } from './connections-ipc-main.js'; -import { registerConfigIpc } from './config-ipc-main.js'; -import { registerPlanReminderIpc } from './plan-reminders-ipc-main.js'; -import { registerWorkspaceResourcesIpc } from './workspace-resources-ipc-main.js'; -import type { NewSessionSkillContext } from './workspace-resources-ipc-main.js'; -import { registerDailyReviewIpc } from './daily-review-ipc-main.js'; -import { registerUsageIpc } from './usage-ipc-main.js'; -import { registerWebSearchIpc } from './web-search-ipc-main.js'; -import { registerNotificationsIpc } from './notifications-ipc-main.js'; -import { registerAppIpc } from './app-ipc-main.js'; -import { registerGitIpc } from './git-ipc-main.js'; -import { registerWorkspaceSearchIpc } from './workspace-search-ipc-main.js'; -import { registerWorkspaceInstructionsIpc } from './workspace-instructions-ipc-main.js'; -import { registerOnboardingIpc } from './onboarding-ipc-main.js'; -import { registerPermissionsIpc } from './permissions-ipc-main.js'; -import { - createPermissionOverlayMain, - registerPermissionOverlayIpc, -} from './permission-overlay/permission-overlay-main.js'; -import { registerSettingsIpc } from './settings-ipc-main.js'; -import type { SettingsIpcHandle } from './settings-ipc-main.js'; -import { createE2eFixtureBotOnboardingAdapters } from './bot-onboarding-e2e-fixture.js'; -import { createKeepSystemAwakeController } from './keep-system-awake.js'; -import { createSettingsRuntimeEffects } from './settings-runtime-effects.js'; -import { createAiSdkBackendFactory, createSessionStreamer } from './session-stream.js'; -import { - resolveDesktopBackendToolSurface, - resolveDesktopNewSessionSkillHost, - resolveDesktopSessionSkillHost, -} from './desktop-backend-tool-surface.js'; -import { registerSessionsIpc } from './sessions-ipc-main.js'; -import { registerAgentGraphIpc } from './agent-graph-ipc-main.js'; -import { createVoiceIpcService, registerVoiceIpc } from './voice-ipc-main.js'; -import { - assertSessionCanSendFromHeader, - isSessionLifecycleError, - sessionLifecycleErrorFromReadFailure, -} from './session-lifecycle.js'; -import { createProjectRootController } from './project-root-controller.js'; -import { createProjectManagementService } from './project-management-service.js'; -import { - type DesktopCreateSessionInput, - resolveDesktopSessionSelection, - resolveNewSessionProjectInput, -} from './new-session-project.js'; -import { - assertSessionWorkspaceAvailable, - isSessionWorkspaceUnavailableError, - resolveProjectContextRoot, -} from './project-context-root.js'; -import { resolveDesktopStorageRoot } from './storage-root-startup.js'; -import { openDesktopExecutionStoreWiring } from './execution-store-wiring.js'; +import { app, dialog } from 'electron'; +import { isIsolatedE2e } from './startup-context.js'; -// E2E switches must never fire in a packaged build, and must never run against -// the real user data: a stray MAKA_E2E on a build/dev machine would otherwise -// swap in the fake backend or hide the window. app.isPackaged is true for -// asar-packaged builds; MAKA_E2E_USER_DATA_DIR must also be set, so the fake -// backend can't write test sessions into a real profile if someone sets only -// MAKA_E2E. -const hasIsolatedE2eProfile = - !app.isPackaged && - !!process.env.MAKA_E2E_USER_DATA_DIR; -const isE2e = hasIsolatedE2eProfile && process.env.MAKA_E2E === '1'; -const isComputerUseRealModelE2e = - hasIsolatedE2eProfile && - process.env.MAKA_CU_REAL_MODEL_E2E === '1'; -const isIsolatedE2e = isE2e || isComputerUseRealModelE2e; +// The macOS app menu title and app.getName() consumers read this name. Set it +// before ready, unchanged from its historical pre-ready position. +app.setName('Maka'); // E2E isolation: redirect userData BEFORE the single-instance lock so the // lock judges the throwaway dir, not the real user data — otherwise a // developer with Maka open makes the E2E process exit as a "second instance". -// Gated by isE2e (not just the dir env) so a packaged build ignores it. +// Gated by isIsolatedE2e (not just the dir env) so a packaged build ignores +// it. Also before ready: userData must be pinned before any store opens. if (isIsolatedE2e && process.env.MAKA_E2E_USER_DATA_DIR) { app.setPath('userData', process.env.MAKA_E2E_USER_DATA_DIR); } // Electron does not enforce single-instance by default. Must run before any // workspace/store setup below -- a losing second process exits immediately, -// before touching shared state. See the 'second-instance' listener below for -// what the surviving process does about it. +// before touching shared state. See the 'second-instance' listener in +// boot.ts for what the surviving process does about it. if (!app.requestSingleInstanceLock()) { app.exit(0); -} - -const buildInfo = resolveBuildInfo(app.isPackaged, app.getAppPath()); - -// Resolve the user's login-shell PATH before any stores, tools, or child -// processes are created. On macOS, apps launched from Finder/Dock inherit a -// minimal PATH that lacks /opt/homebrew/bin, ~/.local/bin, etc. Only PATH is -// imported; application-control variables remain owned by this process. -// Skipped on Windows, when MAKA_SKIP_SHELL_ENV=1, and when launched from a -// terminal (TERM/COLORTERM set). -await resolveShellEnv(); - -// PR-VISUAL-SMOKE-HEADLESS: resolve the fixture defensively. An unknown -// scenario (e.g. a stale build, or a typo'd MAKA_E2E_FIXTURE) throws -// here during top-level module evaluation. Left uncaught it surfaces a -// blocking native error dialog. In fixture mode we instead log a parseable -// line and exit fast so the run fails in milliseconds with no dialog. -// Outside fixture mode the throw is rethrown. -let e2eFixture: ReturnType; -try { - e2eFixture = resolveE2eFixture( - process.env.MAKA_E2E_FIXTURE, - app.isPackaged, - process.env.MAKA_E2E_FIXTURE_REDUCED_MOTION, - process.env.MAKA_E2E_FIXTURE_THEME, - process.env.MAKA_E2E_FIXTURE_LOCALE, - process.env.MAKA_E2E_FIXTURE_TIMEZONE, - process.env.MAKA_E2E_FIXTURE_PLATFORM, - ); -} catch (error) { - if (process.env.MAKA_E2E_FIXTURE) { - console.error(`[e2e-fixture] fatal: ${error instanceof Error ? error.message : String(error)}`); - process.exit(1); - } - throw error; -} -const workspaceRoot = join(app.getPath('userData'), 'workspaces', e2eFixture?.workspaceName ?? 'default'); -const credentialStore = createFileCredentialStore(workspaceRoot); -if (e2eFixture) { - console.log(`[e2e-fixture] scenario=${e2eFixture.scenario} workspace=${workspaceRoot}`); - await seedE2eFixture({ workspaceRoot, fixture: e2eFixture, credentialStore }); } else { - const storageRoot = await resolveDesktopStorageRoot(workspaceRoot, { - confirmRepair: confirmDesktopStorageRootRepair, - }); - if (!storageRoot) { - app.exit(0); - await new Promise(() => {}); - } -} - -async function confirmDesktopStorageRootRepair(): Promise { - await app.whenReady(); - const isChinese = resolveSystemUiLocale(app.getPreferredSystemLanguages()) === 'zh'; - const { response } = await dialog.showMessageBox({ - type: 'warning', - title: isChinese ? 'Maka 工作区需要修复' : 'Maka workspace needs repair', - message: isChinese ? 'Maka 无法验证这个工作区。' : 'Maka cannot verify this workspace.', - detail: isChinese - ? `系统中的磁盘标识可能发生了变化。仅当这是本机原来的 Maka 工作区、而不是复制出的工作区时,才选择修复。\n\n${workspaceRoot}` - : `The disk identity may have changed. Repair only if this is the original Maka workspace on this computer, not a copied workspace.\n\n${workspaceRoot}`, - buttons: isChinese ? ['修复工作区', '退出'] : ['Repair Workspace', 'Exit'], - defaultId: 1, - cancelId: 1, - noLink: true, - }); - return response === 0; -} -// 保持系统唤醒 (settings.system.keepSystemAwake): holds an Electron -// `powerSaveBlocker` so in-process scheduled tasks keep firing while the -// machine would otherwise sleep. Injected with electron's blocker; the -// controller owns the id + double-start guard. The blocker dies with the -// process, so quit needs no special teardown. -const keepSystemAwake = createKeepSystemAwakeController(powerSaveBlocker); -const store = createSessionStore(workspaceRoot); -const agentGraphControlStore = createAgentGraphControlStore(workspaceRoot); -const projectCatalog = createProjectCatalog(workspaceRoot); -const worktreeChildExecutor = createGitWorktreeChildExecutor({ storageRoot: workspaceRoot }); -const planStore = createSqlitePlanStore(workspaceRoot); -const executionStoreWiring = await openDesktopExecutionStoreWiring(workspaceRoot); -const { runStore, shellRunStore } = executionStoreWiring; -const runtimePersistence = await openRuntimeEventPersistence({ - workspaceRoot, -}); -const runtimeEventStore = runtimePersistence.runtimeEventStore; -const connectionStore = createConnectionStore(workspaceRoot); -const settingsStore = createSettingsStore(workspaceRoot); -const mcpConfigStore = createMcpConfigStore(workspaceRoot); -const mcpManager = new McpClientManager({ clientName: 'maka-desktop', clientVersion: app.getVersion() }); -let mcpStartup: Promise | undefined; -function ensureMcpReady(): Promise { - if (!mcpStartup) { - const startup = mcpConfigStore.get().then((config) => mcpManager.sync(config)); - mcpStartup = startup; - void startup.catch(() => { - if (mcpStartup === startup) mcpStartup = undefined; - }); - } - return mcpStartup; -} -const telemetryRepo = createSqliteTelemetryRepo(workspaceRoot); -// Canonical model-call accounting ledger (#1679). Separate store, same -// operational database: `telemetryRepo` is now a frozen historical projection -// for LLM calls, and every model call dispatched from here settles into this. -const modelCallLedger = createSqliteModelCallLedger(workspaceRoot); -const dailyReviewArchiveStore = createDailyReviewArchiveStore(workspaceRoot); -const artifactStore = createSqliteArtifactStore(workspaceRoot); -const deepResearchStore = createSqliteDeepResearchStore(workspaceRoot); -const storeReadImage = createReadImageSnapshotter(artifactStore); -const attachmentApprovals = createAttachmentApprovalRegistry(); -// PR-OAUTH-SUBSCRIPTION-0: Claude subscription OAuth service. -// Lives in main process only; renderer accesses via IPC. Tokens -// never cross the IPC boundary (xuan G-X3). Cloak path is dynamic- -// imported behind MAKA_CLAUDE_SUBSCRIPTION_CLOAK flag (xuan G-X4) -// and lives in a separate module not statically imported here. -const claudeSubscription = new ClaudeSubscriptionService({ - userDataDir: app.getPath('userData'), - openExternal: (url) => shell.openExternal(url), - credentialStore, -}); -// PR-MODEL-OAUTH-ALL-0: Codex / Cursor / Antigravity subscription -// services. Same shape as `claudeSubscription` — main-process only, -// IPC payloads never carry tokens, each gated behind its own -// MAKA_*_EXPERIMENTAL env var. Antigravity is a `preview` placeholder -// until the Google client_id question is resolved. -const openAiCodex = e2eFixture?.scenario === 'oauth-relogin' - ? createOpenAiCodexE2eFixtureService() - : new OpenAiCodexService({ - userDataDir: app.getPath('userData'), - openExternal: (url) => shell.openExternal(url), - credentialStore, - }); -const githubCopilotSubscription = new GitHubCopilotSubscriptionService({ credentialStore }); -const xaiOAuth = new XaiOAuthService({ - credentialStore, - openExternal: (url) => shell.openExternal(url), -}); -const buildSubscriptionModelFetch = createSubscriptionModelFetch({ - claudeSubscription, -}); -const oauthModelConnections = createOAuthModelConnectionsMainService({ - connectionStore, - credentialStore, - claudeSubscription, - openAiCodex, - githubCopilotSubscription, - xaiOAuth, - ...(e2eFixture?.scenario === 'oauth-relogin' - ? { fetchModels: async () => [{ id: 'gpt-5.6-sol' }] } - : {}), -}); -const isClaudeSubscriptionAuthenticatedState = oauthModelConnections.isClaudeSubscriptionAuthenticatedState; - -function syncClaudeSubscriptionConnection(): Promise { - return oauthModelConnections.syncClaudeSubscriptionConnection(); -} -function activateXaiOAuthConnection(): Promise { - return oauthModelConnections.activateXaiOAuthConnection(); -} -function syncXaiOAuthConnection(): Promise { - return oauthModelConnections.syncXaiOAuthConnection(); -} - -function syncOpenAiCodexConnection(): Promise { - return oauthModelConnections.syncOpenAiCodexConnection(); -} - -function activateOpenAiCodexConnection(): Promise { - return oauthModelConnections.activateOpenAiCodexConnection(); -} - -function syncGitHubCopilotConnection(): Promise { - return oauthModelConnections.syncGitHubCopilotConnection(); -} - -function syncOAuthModelConnections(): Promise { - return oauthModelConnections.syncOAuthModelConnections(); -} - -function disconnectManagedOAuthConnection(connection: LlmConnection): Promise { - return oauthModelConnections.disconnectManagedOAuthConnection(connection); -} - -function resolveConnectionSecret(slug: string): Promise { - return oauthModelConnections.resolveConnectionSecret(slug); -} - -const voiceIpcService = createVoiceIpcService({ - settingsStore, - connectionStore, - resolveConnectionSecret, -}); - -/** - * Read-only credential-presence check for status paths (onboarding's - * `getSnapshot`) that must not trigger `resolveConnectionSecret`'s - * OAuth near-expiry refresh — that refresh hits the network and - * mutates local token state, which a read-only status read must never - * do just by being observed. Send/test/fetch-models paths keep using - * `resolveConnectionSecret` so they still benefit from the refresh. - * - * Takes the `LlmConnection` directly rather than a slug: callers that - * already hold the connection list (onboarding does) skip the extra - * `connectionStore.get()` round trip and derive state from one - * consistent snapshot. - */ -function hasConnectionSecret(connection: LlmConnection): Promise { - return oauthModelConnections.hasConnectionSecret(connection); -} -const cursorSubscription = new CursorSubscriptionService({ - userDataDir: app.getPath('userData'), - openExternal: (url) => shell.openExternal(url), - credentialStore, -}); -const antigravitySubscription = new AntigravitySubscriptionService({ - userDataDir: app.getPath('userData'), - openExternal: (url) => shell.openExternal(url), - credentialStore, -}); - -const planReminderStore = createSqlitePlanReminderStore(workspaceRoot); -const taskLedgerWiring = createMainTaskLedgerWiring(workspaceRoot); -const taskLedgerStore = taskLedgerWiring.store; - -async function closeWorkflowStores(): Promise { - const stores = [ - planStore, - deepResearchStore, - planReminderStore, - taskLedgerStore, - ]; - const errors: unknown[] = []; - for (const result of await Promise.allSettled(stores.map((workflowStore) => workflowStore.ready()))) { - if (result.status === 'rejected') errors.push(result.reason); - } - for (const workflowStore of stores) { - try { - workflowStore.close(); - } catch (error) { - errors.push(error); - } - } - if (errors.length > 0) throw new AggregateError(errors, 'Unable to close workflow stores'); -} - -const sessionActivities = new SessionActivityRegistry(); - -// Unified Automation — single "Automation" tool for heartbeat + cron. -// Deps are resolved lazily since runtime/store aren't ready at this point. -const automationWiring = createMainAutomationWiring({ - workspaceRoot, - async canFire(automation): Promise { - // Kind-aware fire gate (see evaluateAutomationCanFire): incognito blocks all; - // cron is never gated on its creator session; heartbeat needs an idle session. - return evaluateAutomationCanFire(automation, { - isIncognitoActive: async () => (await getWorkspacePrivacyContext()).incognitoActive, - readSessionHeader: (sessionId) => store.readHeader(sessionId), - }); - }, - // Heartbeat: inject into the automation's own session; resolve after the stream. - async injectTurn(sessionId: string, prompt: string, automationId: string) { - await ensureSessionCanSend(sessionId); - const turnId = randomUUID(); - const iterator = runtime.sendMessage(sessionId, { - turnId, text: prompt, origin: { kind: 'automation', automationId }, - }); - const r = await streamEvents(sessionId, iterator, { - turnId, - goalBoundary: 'external', - }); - return { runId: turnId, ok: r.ok, ...(r.error ? { error: r.error } : {}) }; - }, - // Cron: spawn a FRESH session (explore mode — no unapproved side effects) and - // run the prompt there, so each fire is a first-class session + run. - async createFreshRun(prompt: string, automationId: string) { - const slug = await connectionStore.getDefault(); - const { connection, model } = await getReadyConnection(slug, undefined); - const session = await createDesktopSession({ - backend: 'ai-sdk', - llmConnectionSlug: connection.slug, - model, - permissionMode: 'explore', - name: `Automation: ${prompt.slice(0, 32)}`, - labels: ['automation', 'cron'], - }); - emitSessionsChanged('created', session.id); - await ensureSessionCanSend(session.id); - const turnId = randomUUID(); - const iterator = runtime.sendMessage(session.id, { - turnId, text: prompt, origin: { kind: 'automation', automationId }, - }); - const r = await streamEvents(session.id, iterator, { - turnId, - goalBoundary: 'external', - }); - // Archive the fresh cron session after its run finalizes so recurring crons - // do not accumulate an unbounded pile of active sessions. The session (with - // its run/trace) is preserved under the archive, labelled automation/cron. - try { - await agentGraphCoordinator.stop(session.id); - await goalWiring.archiveSession(session.id, () => runtime.archive(session.id)); - desktopSessionSkillHosts.delete(session.id); - emitSessionsChanged('archived', session.id); - } catch {} - return { runId: turnId, ok: r.ok, ...(r.error ? { error: r.error } : {}) }; - }, -}); - -// Load durable automations from disk on startup (fire-and-forget; errors are logged inside). -void automationWiring.loadDurableAutomations(); - -// Goal execution — autonomous turn-boundary continuation with an external -// evaluator (CC-style). Self-contained: no automation coupling (a goal is -// bounded by its own caps; a waiting goal re-checks via normal continuation). -const goalWiring = createMainGoalWiring({ - getDefaultConnectionSlug: () => connectionStore.getDefault(), - getConnection: (slug) => connectionStore.get(slug), - getSessionModel: async (sessionId) => { - const header = await store.readHeader(sessionId); - if (!header) return null; - return { connectionSlug: header.llmConnectionSlug, model: header.model }; - }, - resolveConnectionSecret, - buildSubscriptionModelFetch, - getAIModel: (input) => getAIModel(input), - buildProviderOptions: (connection, modelId) => buildProviderOptions(connection, modelId), - getRecentMessages: async (sessionId) => { - const messages = await runtime.getMessages(sessionId); - return messages.slice(-10).map((m) => ({ - type: m.type, - text: m.type === 'user' || m.type === 'assistant' ? m.text : undefined, - })); - }, - getTokenCount: async (sessionId) => { - const messages = await runtime.getMessages(sessionId); - let total = 0; - for (const m of messages) { - if (m.type === 'token_usage') total += (m.total ?? (m.input + m.output)); - } - return total; - }, - admitTurn: (sessionId, text) => { - const whenIdle = sessionActivities.whenIdle(sessionId); - if (whenIdle) return { kind: 'busy', whenIdle }; - const reservation = sessionActivities.reserve(sessionId); - const turnId = randomUUID(); - return { - kind: 'prepared', - turnId, - start: async (): Promise => { - try { - await ensureSessionCanSend(sessionId); - const iterator = runtime.sendMessage(sessionId, { turnId, text }); - return (await streamEvents(sessionId, iterator, { - turnId, - goalBoundary: 'coordinator', - activity: reservation, - })).outcome; - } catch (error) { - reservation.release(); - return { - kind: 'errored', - turnId, - reason: `Goal continuation could not start: ${errorMessage(error)}`, - }; - } - }, - }; - }, - // Surface every goal transition to the renderer so an active autonomous loop - // is visible (badge + clear affordance) — never a silent token burn. - onGoalChange: (goal) => emitSessionsChanged('goal-change', goal.sessionId), - listActionableTaskKeys: async (sessionId) => { - const tasks = await taskLedgerStore.list(sessionId, { - includeTerminal: false, - includeArchived: false, - }); - return filterModelVisibleTaskLedgerTasks(tasks) - .filter((task) => task.status === 'pending' || task.status === 'in_progress') - .map((task) => task.key); - }, - recordTaskGateDecision: async (trace) => { - const runs = await runStore.listSessionRuns(trace.sessionId); - const run = runs.find((candidate) => candidate.turnId === trace.turnId); - if (!run) return; - await runStore.appendEvent(trace.sessionId, run.runId, { - type: 'task_gate_decided', - id: randomUUID(), - runId: run.runId, - sessionId: trace.sessionId, - turnId: trace.turnId, - ts: Date.now(), - message: `Task gate: ${trace.decision}`, - data: { - goalId: trace.goalId, - decision: trace.decision, - taskKeys: trace.taskKeys, - }, - }); - }, -}); - -async function getWorkspacePrivacyContext(): Promise { - const settings = await settingsStore.get(); - return { incognitoActive: settings.privacy.incognitoActive === true }; -} - -const localMemory = new LocalMemoryService({ - workspaceRoot, - getSettings: () => settingsStore.get(), - updateSettings: (patch) => settingsStore.update(patch), - getPrivacyContext: getWorkspacePrivacyContext, -}); -// The synchronous Runtime Skill tools execute inside an already-built backend. -// Their resolver uses the exact host cached by that backend. Pre-send -// invocation and slash discovery derive a fresh surface from the persisted -// session header instead; see resolveDesktopSkillHostForSession below. -const desktopSessionSkillHosts = new Map(); -const resolveDesktopSkillHost: HostCapabilitiesResolver = ({ sessionId }) => - desktopSessionSkillHosts.get(sessionId) ?? desktopProductToolSurface.hostCapabilities; -// Window is created hidden for E2E and e2e-fixture runs so it never steals -// focus. Derived from the same isE2e gate as userData/fake-backend so the -// hidden-window switch stays in lockstep with the rest of the E2E isolation. -// MAKA_E2E_SHOW_WINDOW opts back into a visible window where there is no -// focus to steal (CI under xvfb): hidden windows only get ~1fps compositor -// BeginFrames on Linux, which stalls content-visibility inflation and any -// frame-paced E2E protocol (measured in the scroll-geometry climb: 38 frames -// over 31s). The E2E harness sets it, not the workflow — see fixtures.ts. -// This value is also what hides the macOS dock icon (see app-lifecycle.ts): -// staying out of sight and staying out of the Dock are one decision, so a run -// that opts into a visible window also opts back into Dock and Cmd+Tab. -const startHidden = (Boolean(e2eFixture) || isIsolatedE2e) - && process.env.MAKA_E2E_SHOW_WINDOW !== '1'; -let onMainWindowClose = (): void => {}; -const mainWindowController = createMainWindowController({ - workspaceRoot, - e2eFixture, - settingsStore, - startHidden, - onClose: () => onMainWindowClose(), -}); -// Shared by 'second-instance' and 'activate': focus the existing window, or -// create one if all windows were closed while the app (macOS: still in the -// dock) stayed running -- a second launch attempt must not be a silent no-op. -function focusOrCreateMainWindow(signal: AbortSignal): void { - if (mainWindowController.hasOpenWindows()) { - mainWindowController.focus(); - } else { - void mainWindowController - .createWindow(signal) - .catch((error) => console.error('[window] failed to create:', error)); - } -} -const safeSendToRenderer = mainWindowController.send; -taskLedgerStore.subscribe((event) => safeSendToRenderer('tasks:changed', event)); -deepResearchStore.subscribe((event) => safeSendToRenderer('deepResearch:changed', event)); -const deepResearchTools = buildDeepResearchTools({ - store: deepResearchStore, - artifactStore, - onArtifactCreated: (event) => safeSendToRenderer('artifacts:changed', event), -}); -const backends = new BackendRegistry(); -const shellRuns = new ShellRunProcessManager({ - store: shellRunStore, - newId: randomUUID, - now: Date.now, - onShellRunUpdate: (update) => { - safeSendToRenderer('shell-runs:update', update); - }, -}); -const { - persistToolArtifacts, - snapshotReadImage, - persistArchivedToolResult, - readArchivedToolResult, - readArchivedToolResultResource, -} = createToolArtifactPersistence({ artifactStore, storeReadImage, safeSendToRenderer }); - -const { - riveTools, - browserTools, - computerUse, - computerUseOverlay, - computerUseTools, - desktopProductToolSurface, - builtinTools, - childAgentTools, - sandboxDiagnosticsProvider, -} = assembleDesktopTools({ - isComputerUseRealModelE2e, - workspaceRoot, - taskLedgerStore, - taskLedgerWiring, - automationWiring, - goalWiring, - settingsStore, - updateAgentSettings, - shellRuns, - snapshotReadImage, - readArchivedToolResultResource, - getWorkspacePrivacyContext, - resolveDesktopSkillHost, -}); -let agentGraphCoordinator: AgentGraphCoordinator; -let agentGraphSupervisorWakeCoordinator: AgentGraphSupervisorWakeCoordinator; -const desktopBackendToolSurfaceDeps = { - isComputerUseRealModelE2e, - ensureMcpReady, - getReadyConnection, - mcpManager, - deepResearchTools, - computerUseTools, - builtinTools, - toolEconomy: desktopProductToolSurface.identity.policy.economy, - planStore, - getAgentGraphSupervisorTools: (sessionId: string) => - agentGraphCoordinator.toolsForSession(sessionId), -}; -// Cursor-overlay teardown assigns a module-scoped `let`, so it stays in main.ts. -onMainWindowClose = () => computerUseOverlay.destroyAll(); -const systemPromptService = createSystemPromptMainService({ - settingsStore, - workspaceRoot, - localMemory, - taskLedger: taskLedgerStore, - goalManager: goalWiring.manager, - hostCapabilities: desktopProductToolSurface.hostCapabilities, -}); -let lookupPricing = buildPricingLookup(); -let usageReadiness: Promise | undefined; -function ensureUsageReady(): Promise { - if (!usageReadiness) { - const readiness = telemetryRepo.load().then(() => { - lookupPricing = buildPricingLookup(telemetryRepo.listPricingOverrides()); - }); - usageReadiness = readiness; - void readiness.catch(() => { - if (usageReadiness === readiness) usageReadiness = undefined; + // The full boot must not run in the top-level module-evaluation chain: + // Electron ESM emits `ready` only after the entry module finishes + // evaluating, so a top-level `await app.whenReady()` (which the + // storage-root repair dialog needs) would deadlock. Boot therefore runs + // after ready via a dynamic import, keeping the startup chain out of + // module evaluation and preserving "root-identity check before any + // store/db write". + app + .whenReady() + .then(() => { + console.log('[startup] app ready'); + return import('./boot.js'); + }) + .catch((error: unknown) => { + console.error('[startup] fatal:', error); + const message = error instanceof Error ? error.message : String(error); + dialog.showErrorBox('Maka failed to start', message); + app.exit(1); }); - } - return usageReadiness; -} -// Track the last status fields that affect persisted diagnostics. The reason -// is part of the key because a running bridge can remain degraded while a -// newer, more useful failure replaces the previous one. -const previousBotStatus = new Map>(); -let botIncoming: ReturnType; -// Single authority for the "current project root" selection, shared across the -// app/window, git, workspace-search, workspace-instructions, and session-entry -// IPC surfaces. botIncoming and automation cron runs read the current -// selection through the thin `resolveCurrentProjectRoot` adapter below. -const projectRootController = createProjectRootController({ - lastProjectPathFile: join(workspaceRoot, 'last-project-path.json'), - fallbackRoots: () => [process.cwd(), app.getAppPath()], -}); -const projectManagement = createProjectManagementService({ - catalog: projectCatalog, - sessions: store, - chooseDirectory: async () => { - const result = await mainWindowController.showOpenDialog({ - title: '添加项目', - properties: ['openDirectory'], - }); - return result.canceled ? undefined : result.filePaths[0]; - }, - selection: projectRootController, -}); -const resolveCurrentProjectRoot: () => Promise = () => projectRootController.current(); -const resolveProjectRootForContext = (sessionId: unknown): Promise => - resolveProjectContextRoot(sessionId, { - currentProjectRoot: resolveCurrentProjectRoot, - readSessionCwd: async (id) => (await store.readHeader(id)).cwd, - }); -const botRegistry = new BotRegistry({ - onIncomingMessage: (message: BotIncomingMessage) => { - // Only log incoming bot messages in dev — production stdout leaking - // platform + chatId is operational noise at best and a small privacy - // signal at worst (which bridges are connected, with what frequency). - if (process.env.VITE_DEV_SERVER_URL || process.env.NODE_ENV === 'development') { - console.log('[bot] incoming message', message.platform, message.chatId); - } - void botIncoming.handleBotIncomingMessage(message); - }, - onStatusChange: (status: BotStatus) => { - safeSendToRenderer('settings:bots:statusChanged', status); - // PR-BOT-LASTERROR-FROM-SEND-0: persist send-path failure reasons - // to settings so they survive a Settings page close/reopen. The - // existing connection-test path writes `lastError` only on test - // failures; without this hook, a runtime 429 / timeout would - // disappear the moment the renderer status panel closed. - const previous = previousBotStatus.get(status.platform); - previousBotStatus.set(status.platform, { - readiness: status.readiness, - reason: status.reason, - }); - const update = deriveBotStatusPersistenceUpdate(previous, status); - if (update) { - void settingsStore.update({ - botChat: { - channels: { - [status.platform]: { - ...update, - readinessUpdatedAt: Date.now(), - }, - }, - }, - }).catch(() => {}); - } - }, -}); -const planReminders = createPlanReminderMainService({ - store: planReminderStore, - getPrivacyContext: getWorkspacePrivacyContext, - sendBotMessage: (platform, chatId, text) => - botRegistry.sendMessage(platform, chatId, text), - emitChanged: (reason, reminder) => { - safeSendToRenderer('plans:changed', { - type: 'plans_changed', - reason, - reminderId: reminder.id, - ts: Date.now(), - }); - }, - emitDue: (reminder) => { - safeSendToRenderer('plans:due', reminder); - }, -}); - -app.setName('Maka'); - -backends.register('ai-sdk', createAiSdkBackendFactory({ - ...desktopBackendToolSurfaceDeps, - buildSubscriptionModelFetch, - systemPromptService, - telemetryRepo, - modelCallLedger, - ensureUsageReady, - artifactStore, - desktopSessionSkillHosts, - sandboxDiagnosticsProvider, - persistToolArtifacts, - persistArchivedToolResult, - readArchivedToolResult, - runtimeCommitStore: runtimePersistence.runtimeCommitStore, - safeSendToRenderer, - emitSessionsChanged, - getRuntime: () => runtime, - getLookupPricing: () => lookupPricing, -})); - -backends.register('fake', (ctx) => - new FakeBackend({ sessionId: ctx.sessionId, header: ctx.header, store: ctx.store, appendMessage: ctx.appendMessage }), -); - -// E2E: also route 'ai-sdk' (requested by sessions:create, the single -// session-creation IPC) through the deterministic fake backend, so no -// session-creation path can escape the E2E seam and hit a real provider. -// Registered after the real ai-sdk factory to override it (BackendRegistry -// uses last-write-wins). -// Production builds never set MAKA_E2E. -if (isE2e) { - backends.register('ai-sdk', (ctx) => - new FakeBackend({ sessionId: ctx.sessionId, header: ctx.header, store: ctx.store, appendMessage: ctx.appendMessage }), - ); -} - -const runtime = new SessionManager({ - store, - planStore, - runStore, - runtimeEventStore, - ...(runtimePersistence.runtimeCommitStore - ? { toolBoundaryProtocol: runtimePersistence.runtimeCommitStore.toolBoundaryProtocol } - : {}), - shellRuns, - backends, - childTools: childAgentTools, - worktreeChildExecutor, - safeBoundaryResumeEnabled: process.env.MAKA_RUNTIME_SAFE_BOUNDARY_RESUME === '1', - onContinuationLifecycleEvent: (event) => { - console.info('[runtime-resume]', JSON.stringify(event)); - }, - inspectContinuationSafety: createLocalContinuationSafetyInspector({ - readSessionCwd: async (sessionId) => (await store.readHeader(sessionId)).cwd, - resolveWorkspaceIdentity: async (cwd) => resolveWorkspaceIdentity({ path: cwd }), - listAvailableToolNames: async () => builtinTools.map((tool) => tool.name), - hasPendingBackgroundOperations: async (sessionId) => { - const [shellUpdates, runs] = await Promise.all([ - shellRuns.listSessionUpdates(sessionId), - runStore.listSessionRuns(sessionId), - ]); - return ( - shellUpdates.some((update) => isActiveShellRunStatus(update.result.status)) || - runs.some( - (run) => - run.parentRunId !== undefined && - ['created', 'running', 'waiting_for_user'].includes(run.status), - ) - ); - }, - }), - listArtifactsForTurn: async (sessionId, turnId) => - (await artifactStore.list(sessionId)).filter((artifact) => - artifact.turnId === turnId && artifact.status !== 'deleted' - ), - cleanupHistoryCompactArtifacts: async (input) => { - await cleanupLegacyHistoryCompactArtifacts({ - ...input, - artifactStore, - onDiagnostic: (diagnostic) => console.warn('[history-compact-cleanup]', diagnostic), - }); - }, - generateSessionTitle: async ({ sessionId, header, sourceText }) => { - const { connection, apiKey, model } = await getReadyConnection(header.llmConnectionSlug, header.model); - return generateRuntimeSessionTitle({ - model: getAIModel({ - connection, - apiKey: apiKey ?? '', - modelId: model, - fetch: buildSubscriptionModelFetch(connection, sessionId, model), - }), - providerOptions: buildProviderOptions(connection, model), - sourceText, - }); - }, - onSessionTitleChanged: (sessionId) => emitSessionsChanged('renamed', sessionId), - newId: randomUUID, - now: Date.now, -}); -agentGraphSupervisorWakeCoordinator = new AgentGraphSupervisorWakeCoordinator({ - activityRegistry: sessionActivities, - wakeStore: agentGraphControlStore, - readSnapshot: (rootSessionId) => agentGraphCoordinator.getSnapshot(rootSessionId), - startTurn: async (sessionId, input, activity, abortSignal) => { - let stopPromise: Promise | undefined; - const stop = () => { - stopPromise ??= runtime.stopSession(sessionId, { source: 'graph_supervisor' }); - }; - abortSignal.addEventListener('abort', stop, { once: true }); - if (abortSignal.aborted) stop(); - try { - await ensureSessionCanSend(sessionId); - if (abortSignal.aborted) { - return { kind: 'aborted', turnId: input.turnId }; - } - const iterator = runtime.sendMessage(sessionId, input); - return ( - await streamEvents(sessionId, iterator, { - turnId: input.turnId, - goalBoundary: 'none', - activity, - }) - ).outcome; - } finally { - abortSignal.removeEventListener('abort', stop); - await stopPromise; - } - }, - inspectAttempt: async (rootSessionId, attemptId, turnId) => { - const runs = (await runStore.listSessionRuns(rootSessionId)).filter( - (run) => run.agentGraphWakeAttemptId === attemptId && run.turnId === turnId, - ); - if (runs.length > 1) { - throw new Error( - `Agent graph supervisor wake attempt ${attemptId} has multiple AgentRuns`, - ); - } - return runs[0]?.status ?? 'missing'; - }, - newId: randomUUID, - onError: (rootSessionId) => { - emitSessionsChanged('status-change', rootSessionId); - }, -}); -agentGraphCoordinator = new AgentGraphCoordinator({ - sessionStore: store, - runStore, - runtimeEventStore, - controlStore: agentGraphControlStore, - runtime, - newId: randomUUID, - onReconciliation: (rootSessionId, result) => { - agentGraphSupervisorWakeCoordinator.notify(rootSessionId, result); - }, -}); -let settingsIpc: SettingsIpcHandle | undefined; -let mcpToolSnapshot = JSON.stringify(mcpManager.tools()); -mcpManager.onChange(() => { - safeSendToRenderer('mcp:changed', mcpManager.statuses()); - const nextSnapshot = JSON.stringify(mcpManager.tools()); - if (nextSnapshot === mcpToolSnapshot) return; - mcpToolSnapshot = nextSnapshot; - void runtime.refreshIdleBackends().catch((error) => { - console.warn('[mcp] failed to refresh backend tool snapshots:', error); - }); -}); -const dailyReview = createDailyReviewMainService({ - archiveStore: dailyReviewArchiveStore, - connectionStore, - telemetryRepo, - modelCallLedger, - ensureUsageReady, - listSessions: async () => collapseSessionRevisions(await runtime.listSessions()), - resolveConnectionSecret, - buildSubscriptionModelFetch, -}); -botIncoming = createBotIncomingMainService({ - runtime, - createSession: createDesktopSession, - botRegistry, - getDefaultConnectionSlug: () => connectionStore.getDefault(), - getReadyConnection, - readSessionHeader: async (sessionId) => { - try { - return await store.readHeader(sessionId); - } catch (error) { - throw sessionLifecycleErrorFromReadFailure(error) ?? error; - } - }, - ensureSessionCanSend, - emitSessionsChanged, - runAgentTurn: ({ sessionId, iterator, turnId, onEvent }) => streamEvents(sessionId, iterator, { - turnId, - goalBoundary: 'external', - observeEvent: onEvent, - }), -}); - -// PR110b: onboarding service composes existing stores + runtime to -// derive `OnboardingState` and manage `OnboardingMilestone[]`. -// Constructed AFTER `runtime` so `listSessions()` is bindable. The -// service checks credential presence through `hasConnectionSecret` -// (read-only — recognizes OAuth-subscription connections like the -// send-path's `resolveConnectionSecret` does, but never refreshes), -// so simply opening onboarding can't hit the network or mutate token -// state. -const onboardingService = createOnboardingService( - bindOnboardingDeps({ - settingsStore, - connectionStore, - hasCredential: hasConnectionSecret, - listSessions: () => runtime.listSessions(), - }), -); - -function registerIpc(): void { - const currentProjectRoot = resolveCurrentProjectRoot; - ipcMain.handle('deepResearch:get', (_event, sessionId: string) => - deepResearchStore.read(sessionId)); - registerMcpIpcMain({ - ipcMain, - store: mcpConfigStore, - manager: mcpManager, - ensureReady: ensureMcpReady, - refreshIdleBackends: () => runtime.refreshIdleBackends(), - emitChanged: (statuses) => safeSendToRenderer('mcp:changed', statuses), - }); - - registerAppIpc({ - mainWindowController, - projectRoot: projectRootController, - getSessionProjectRoot: async (sessionId) => (await store.readHeader(sessionId)).cwd, - getProjectRoot: resolveProjectRootForContext, - workspaceRoot, - buildInfo, - e2eFixture, - projectManagement, - }); - registerMemoryIpc({ localMemory }); - registerConfigIpc({ connectionStore, settingsStore, credentialStore, workspaceRoot }); - registerNotificationsIpc({ settingsStore, mainWindowController, e2e: isE2e }); - registerWorkspaceInstructionsIpc({ getCurrentProjectRoot: currentProjectRoot }); - registerWorkspaceResourcesIpc({ - workspaceRoot, - artifactStore, - mainWindowController, - sendToRenderer: safeSendToRenderer, - listInvocableSkills: listDesktopInvocableSkills, - skillHost: desktopProductToolSurface.hostCapabilities, - getCurrentProjectRoot: currentProjectRoot, - getSkillSelectionReport: systemPromptService.getLastSkillSelectionReport, - invalidateSkillSelectionReport: systemPromptService.invalidateSkillSelectionReport, - }); - registerWorkspaceSearchIpc({ getProjectRoot: resolveProjectRootForContext }); - registerGitIpc({ getProjectRoot: resolveProjectRootForContext }); - registerPlanReminderIpc({ planReminders, getWorkspacePrivacyContext }); - registerAgentGraphIpc({ - coordinator: agentGraphCoordinator, - sendToRenderer: safeSendToRenderer, - }); - registerVoiceIpc({ ipcMain, service: voiceIpcService }); - registerSessionsIpc({ - workspaceRoot, - runtime, - store, - taskLedgerStore, - goalWiring, - automationManager: automationWiring.manager, - computerUseOverlay, - computerUseTools, - artifactStore, - attachmentApprovals, - settingsStore, - connectionStore, - mainWindowController, - e2eFixture, - emitSessionsChanged, - ensureSessionCanSend, - prepareSkillInvocation: prepareDesktopSkillInvocation, - invalidateSessionBindings: (sessionId) => botIncoming.invalidateSessionBindings(sessionId), - clearSkillHost: (sessionId) => desktopSessionSkillHosts.delete(sessionId), - stopAgentGraph: async (sessionId) => { - const header = await store.readHeader(sessionId); - if (!header.subagentParent) await agentGraphCoordinator.stop(sessionId); - }, - notifyAgentGraphPermissionResponse: (sessionId) => { - agentGraphSupervisorWakeCoordinator.notifyPermissionResponse(sessionId); - }, - ensureSessionWorkspaceAvailable, - createSession: createDesktopSession, - getReadyConnection, - streamEvents, - getWorkspacePrivacyContext, - canCreateFakeSession: canCreateFakeSessionFromRenderer, - consumeNativeAudioOperation: (input) => - voiceIpcService.consumeNativeAudioOperation(input), - }); - registerSubscriptionIpc({ - ipcMain, - connectionStore, - claudeSubscription, - openAiCodex, - githubCopilotSubscription, - xaiOAuth, - cursorSubscription, - antigravitySubscription, - isClaudeSubscriptionAuthenticatedState, - syncClaudeSubscriptionConnection, - activateOpenAiCodexConnection, - syncOpenAiCodexConnection, - syncGitHubCopilotConnection, - activateXaiOAuthConnection, - syncXaiOAuthConnection, - emitConnectionListChanged, - }); - registerWebSearchIpc({ settingsStore, getWorkspacePrivacyContext }); - registerBrowserIpc({ mainWindowController }); - registerConnectionsIpc({ - ipcMain, - connectionStore, - credentialStore, - syncOAuthModelConnections, - resolveConnectionSecret, - hasConnectionSecret, - disconnectManagedOAuthConnection, - emitConnectionListChanged, - // Same seam as the fake-backend override above, for the other IPC that can - // leave the machine: adding a catalog provider runs remote model discovery - // against the provider's real endpoint. In E2E the key is a placeholder, so - // discovery can only fail — but it fails at whatever speed the network - // answers, and the add dialog stays open for the whole round trip. The - // provider-side budget (10s) is exactly the suite's expect timeout (10s), - // so a slow answer flips `await expect(dialog).toBeHidden()` from pass to - // fail with no code change. Fail deterministically and offline instead, - // which is the outcome a placeholder key produces anyway. - ...(isE2e - ? { - fetchModels: async () => { - throw new Error('E2E: remote model discovery is disabled'); - }, - } - : {}), - }); - registerOnboardingIpc({ onboardingService }); - registerPermissionsIpc({ - settingsStore, - connectionStore, - telemetryRepo, - modelCallLedger, - ensureUsageReady, - botRegistry, - getComputerUseCapabilityInput: computerUseCapabilityInput, - }); - // Drag-to-grant onboarding for the two TCC permissions macOS offers no - // programmatic consent dialog for. See docs/permission-onboarding-plan.md. - const permissionOverlay = createPermissionOverlayMain({ - resolveLocale: async () => { - const settings = await settingsStore.get(); - return resolveUiLocale( - settings.personalization.uiLocale, - resolveSystemUiLocale(app.getPreferredSystemLanguages()), - ); - }, - }); - registerPermissionOverlayIpc({ controller: permissionOverlay }); - // A screen-saver-level panel pinned to every Space is visible to the - // user if it outlives a slow quit; close it explicitly rather than - // relying on process teardown to race it away. - app.on('before-quit', () => permissionOverlay.dismiss()); - settingsIpc = registerSettingsIpc({ - settingsStore, - botRegistry, - normalizeSettingsPatch, - applySettingsRuntimeEffects, - ...(e2eFixture?.scenario === 'settings-bots' - ? { - botOnboardingAdapters: createE2eFixtureBotOnboardingAdapters(), - botOnboardingApplySettingsRuntimeEffects: async () => undefined, - // The fixture no-ops runtime effects, so no real bridge starts. - // Report the onboarded channel as running to demonstrate the - // successful "connected" path (the P0-3 warning path is covered by - // bot-onboarding-main.test.ts). - botOnboardingReadChannelStatus: () => ({ running: true }), - } - : {}), - }); - registerDailyReviewIpc({ dailyReview, dailyReviewArchiveStore, mainWindowController }); - registerUsageIpc({ - ipcMain, - settingsStore, - telemetryRepo, - modelCallLedger, - readRunEvents: (sessionId, runId) => runStore.readEvents(sessionId, runId), - ensureUsageReady, - refreshPricingLookup: () => { - lookupPricing = buildPricingLookup(telemetryRepo.listPricingOverrides()); - }, - sendToRenderer: safeSendToRenderer, - }); -} - -function canCreateFakeSessionFromRenderer(): boolean { - return !app.isPackaged && ( - Boolean(e2eFixture) || - Boolean(process.env.VITE_DEV_SERVER_URL) || - process.env.NODE_ENV === 'development' - ); -} - -const { normalizeSettingsPatch, applySettingsRuntimeEffects, handleExternalSettingsChange } = - createSettingsRuntimeEffects({ - settingsStore, - botRegistry, - keepSystemAwake, - safeSendToRenderer, - }); - -async function updateAgentSettings(patch: UpdateAppSettingsInput): Promise { - const normalizedPatch = await normalizeSettingsPatch(patch); - const next = await settingsStore.update(normalizedPatch); - await applySettingsRuntimeEffects(next, patch); - safeSendToRenderer('settings:externalChanged', { ts: Date.now() }); - return next; -} - -const streamEvents = createSessionStreamer({ - sessionActivities, - goalWiring, - computerUseOverlay, - computerUseTools, - safeSendToRenderer, - emitSessionsChanged, - interruptActivePlanExecution: (sessionId, reason) => - runtime.interruptActivePlanExecution(sessionId, reason), -}); - -async function ensureSessionCanSend(sessionId: string): Promise { - const boundary = await runtime.readExecutionBoundary(sessionId); - assertDesktopExecutionBoundary(sessionId, boundary); - const header = await readAvailableSessionHeader(sessionId); - let result: Awaited>; - try { - result = await ensureSessionCanSendOrRebind(sessionId, header, { - readyConnectionDeps, - getDefaultSlug: () => connectionStore.getDefault(), - listConnectionSlugs: async () => (await connectionStore.list()).map((connection) => connection.slug), - updateSession: (_sessionId, patch) => runtime.updateSession(_sessionId, { - ...patch, - status: 'active', - blockedReason: undefined, - statusUpdatedAt: Date.now(), - }), - }); - } catch (error) { - if (isSessionLifecycleError(error)) throw error; - await runtime.setSessionStatus(sessionId, 'blocked', 'NO_REAL_CONNECTION').catch(() => {}); - emitSessionsChanged('status-change', sessionId); - throw error; - } - if (result.rebound) { - emitSessionsChanged('rebound', sessionId, { - connectionSlug: result.connectionSlug, - modelId: result.modelId, - }); - } -} - -async function readAvailableSessionHeader(sessionId: string) { - let header; - try { - header = await store.readHeader(sessionId); - } catch (error) { - const lifecycleError = sessionLifecycleErrorFromReadFailure(error); - if (lifecycleError) throw lifecycleError; - throw error; - } - assertSessionCanSendFromHeader(header); - await assertSessionWorkspaceAvailable(header.cwd); - return header; -} - -async function ensureSessionWorkspaceAvailable(sessionId: string): Promise { - await readAvailableSessionHeader(sessionId); -} - -async function createDesktopSession(input: DesktopCreateSessionInput) { - const selected = await resolveDesktopSessionSelection(input, projectManagement); - await assertSessionWorkspaceAvailable(selected.cwd); - return runtime.createSession(await resolveNewSessionProjectInput(selected, projectCatalog)); -} - -const readyConnectionDeps = { - getConnection: (slug: string) => connectionStore.get(slug), - getApiKey: (slug: string) => resolveConnectionSecret(slug), -}; - -function getReadyConnection(slug: string | null | undefined, model?: string) { - return requireReadyConnection(slug, readyConnectionDeps, model); -} - -async function resolveDesktopSkillHostForSession( - sessionId: string, -): Promise { - const header = await store.readHeader(sessionId); - return resolveDesktopSessionSkillHost(desktopBackendToolSurfaceDeps, { - sessionId, - header, - childTools: childAgentTools, - }); -} - -async function resolveDesktopSkillHostForNewSession( - projectRoot: string, - context?: NewSessionSkillContext, -): Promise { - const ready = await getReadyConnection( - context?.llmConnectionSlug ?? (await connectionStore.getDefault()), - context?.model, - ); - return resolveDesktopNewSessionSkillHost(desktopBackendToolSurfaceDeps, { - projectRoot, - workspaceRoot, - readyConnection: ready, - context, - }); -} - -async function prepareDesktopSkillInvocation( - sessionId: string, - text: string, - skillIds?: readonly string[], -) { - const [projectRoot, host] = await Promise.all([ - resolveProjectRootForContext(sessionId), - resolveDesktopSkillHostForSession(sessionId), - ]); - return prepareSkillInvocationMessage({ - text, - ...(skillIds ? { skillIds } : {}), - source: resolveSkillDiscoveryPaths(projectRoot, workspaceRoot), - host, - }); -} - -async function listDesktopInvocableSkills( - sessionId?: string, - newSessionContext?: NewSessionSkillContext, -) { - try { - const projectRoot = await resolveProjectRootForContext(sessionId); - const host = sessionId - ? await resolveDesktopSkillHostForSession(sessionId) - : await resolveDesktopSkillHostForNewSession(projectRoot, newSessionContext); - return await listInvocableSkills( - resolveSkillDiscoveryPaths(projectRoot, workspaceRoot), - host, - ); - } catch (error) { - // Stale sessions with a removed working directory remain browseable, but - // cannot offer project-aware Skill suggestions. Treat that expected state - // as an empty projection instead of generating a rejected IPC/log entry. - if (sessionId && isSessionWorkspaceUnavailableError(error)) return []; - throw error; - } -} - -function emitConnectionListChanged(): void { - const event: ConnectionEvent = { - type: 'connection_list_changed', - id: randomUUID(), - ts: Date.now(), - }; - safeSendToRenderer('connections:event', event); -} - -function emitSessionsChanged( - reason: SessionChangedReason, - sessionId?: string, - extra?: Pick, -): void { - const event: SessionChangedEvent = { - type: 'sessions_changed', - reason, - ts: Date.now(), - }; - if (sessionId) event.sessionId = sessionId; - if (extra?.connectionSlug) event.connectionSlug = extra.connectionSlug; - if (extra?.modelId) event.modelId = extra.modelId; - safeSendToRenderer('sessions:changed', event); -} - -registerIpc(); - -wireAppLifecycle({ - startHidden, - e2eFixture, - workspaceRoot, - sessionStore: store, - projectCatalog, - credentialStore, - connectionStore, - settingsStore, - telemetryRepo, - artifactStore, - modelCallLedger, - ensureUsageReady, - keepSystemAwake, - botRegistry, - planReminders, - dailyReview, - automationWiring, - goalWiring, - computerUse, - computerUseOverlay, - shellRuns, - mcpManager, - runtimePersistence, - executionStoreWiring, - closeWorkflowStores, - mainWindowController, - runtime, - agentGraphCoordinator, - agentGraphSupervisorWakeCoordinator, - agentGraphControlStore, - streamEvents, - focusOrCreateMainWindow, - emitConnectionListChanged, - emitSessionsChanged, - handleExternalSettingsChange, - getSettingsIpc: () => settingsIpc, -}); - -function computerUseCapabilityInput() { - const serviceState = computerUse.backend?.serviceState?.(); - return { - backendId: computerUse.backendId, - health: computerUseServiceHealth(computerUse.backendId, serviceState), - }; } diff --git a/apps/desktop/src/main/startup-context.ts b/apps/desktop/src/main/startup-context.ts new file mode 100644 index 0000000000..c39b7b4dc3 --- /dev/null +++ b/apps/desktop/src/main/startup-context.ts @@ -0,0 +1,16 @@ +import { app } from 'electron'; + +// E2E switches must never fire in a packaged build, and must never run against +// the real user data: a stray MAKA_E2E on a build/dev machine would otherwise +// swap in the fake backend or hide the window. app.isPackaged is true for +// asar-packaged builds; MAKA_E2E_USER_DATA_DIR must also be set, so the fake +// backend can't write test sessions into a real profile if someone sets only +// MAKA_E2E. +export const hasIsolatedE2eProfile = + !app.isPackaged && + !!process.env.MAKA_E2E_USER_DATA_DIR; +export const isE2e = hasIsolatedE2eProfile && process.env.MAKA_E2E === '1'; +export const isComputerUseRealModelE2e = + hasIsolatedE2eProfile && + process.env.MAKA_CU_REAL_MODEL_E2E === '1'; +export const isIsolatedE2e = isE2e || isComputerUseRealModelE2e; From 877ca9ce2c78de0acd7254042dd3ed18036a7a8a Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sun, 2 Aug 2026 13:54:41 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix(desktop):=20address=20review=20?= =?UTF-8?q?=E2=80=94=20console=20gate,=20E2E=20signal,=20e2e=20fatal=20pat?= =?UTF-8?q?h?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review (Claude Opus + GPT-5.6-sol, independent) findings: - CI gate: check-console allow-list only covered main.ts; the 7 console sites moved into boot.ts with the startup chain, so 'test:dist' failed the audit. Allow boot.ts and refresh the stale main.ts reason. - E2E: the regression test claimed a '[startup] app ready' console signal that nothing consumed; what actually passed was a 1s CDP-timeout heuristic, which could false-positive on any slow/stuck main process. Tighten to accept ONLY 'parked at the modal repair dialog' as success: the dialog can only appear after ready (whole boot module runs inside the whenReady callback), so it simultaneously proves ready + gate holding, and a deadlocked process or a removed gate both fail. - Fatal path: suppress showErrorBox under isolated E2E (same reasoning as the fixture-fatal path in boot.ts) so a boot failure exits fast instead of hanging on a modal until test timeout. - Comments: update stale 'main.ts' references to boot.ts where they name the startup chain's home. --- .../desktop/e2e/storage-root-conflict.spec.ts | 63 ++++++++----------- apps/desktop/src/main/app-lifecycle.ts | 15 ++--- apps/desktop/src/main/boot.ts | 2 +- apps/desktop/src/main/main.ts | 8 ++- scripts/check-console.mjs | 6 +- 5 files changed, 45 insertions(+), 49 deletions(-) diff --git a/apps/desktop/e2e/storage-root-conflict.spec.ts b/apps/desktop/e2e/storage-root-conflict.spec.ts index c261344a4a..1bbe022ed3 100644 --- a/apps/desktop/e2e/storage-root-conflict.spec.ts +++ b/apps/desktop/e2e/storage-root-conflict.spec.ts @@ -10,56 +10,44 @@ import { closeElectronApplication } from '../../../scripts/electron-lifecycle.mj const DESKTOP_ROOT = process.cwd(); /** - * Poll the main process for readiness without hanging on the modal repair - * dialog: once the dialog opens (which can only happen after ready, because - * the boot module runs inside the `whenReady` callback), the dialog's modal - * loop on macOS stops answering CDP evaluation, so an evaluate call that - * never settles is itself proof that ready was reached. A deadlocked main - * process, by contrast, answers every evaluate with `isReady() === false` - * forever. + * Prove the app parked at the modal repair dialog — the only success signal + * this test accepts. + * + * The dialog can only appear after ready: the whole boot module runs inside + * the `whenReady` callback, so a modal dialog being up simultaneously proves + * (a) ready was reached and (b) the root-identity gate fired and is holding + * before any store/db write. On macOS the dialog's modal loop stops answering + * CDP evaluation, so an evaluate that never settles within the deadline is the + * observable form of "dialog is open". A deadlocked main process (the + * regression this test guards) answers every evaluate with `isReady() === + * false` forever, and a future removal of the gate would make evaluate answer + * `true` — both must fail, only the parked dialog may pass. */ -async function mainProcessReachedReady(app: ElectronApplication): Promise { +async function appParkedAtRepairDialog(app: ElectronApplication): Promise { for (let attempt = 0; attempt < 40; attempt += 1) { - let settled = false; const outcome = await Promise.race([ app .evaluate(({ app: electronApp }) => electronApp.isReady()) - .then((ready) => { - settled = true; - return ready ? 'ready' : 'not-ready'; - }) - .catch((error: unknown) => { - settled = true; - return `evaluate-error:${error instanceof Error ? error.message : String(error)}`; - }), + .then((ready) => (ready ? 'ready' : 'not-ready')) + .catch(() => 'evaluate-error'), new Promise((resolve) => setTimeout(() => resolve('modal-dialog'), 1_000)), ]); - if (outcome === 'ready' || outcome === 'modal-dialog') return true; - if (outcome === 'not-ready' || outcome.startsWith('evaluate-error:')) { - await new Promise((resolve) => setTimeout(resolve, 250)); - continue; - } + if (outcome === 'modal-dialog') return true; + await new Promise((resolve) => setTimeout(resolve, 250)); } return false; } /** - * A conflicting storage root must reach the ready state with the repair - * dialog open, not deadlock in module evaluation, and must not write any - * store/db files before the user answers the dialog. + * A conflicting storage root must park at the repair dialog — never deadlock + * in module evaluation — and must not write any store/db files before the + * user answers. * * Regression for the Electron ESM startup deadlock: top-level * `await app.whenReady()` inside the repair-confirm path never resolves * because `ready` only fires after the main module finishes evaluating. - * - * The ready signal is the main-process line `[startup] app ready`, which the - * thin entry prints from inside the `whenReady` callback right before - * dynamic-importing the boot module. A modal repair dialog blocks further - * CDP evaluation on macOS, so asserting `app.isReady()` from the test side - * would hang once the dialog opens; the console line is emitted before the - * dialog exists and stays observable. */ -test('reaches ready with a storage-root repair dialog open and writes nothing before the answer', async () => { +test('parks at the storage-root repair dialog and writes nothing before the answer', async () => { const userDataDir = await mkdtemp(join(tmpdir(), 'maka-root-conflict-')); const homeDir = join(userDataDir, 'home'); await mkdir(homeDir, { recursive: true }); @@ -86,12 +74,11 @@ test('reaches ready with a storage-root repair dialog open and writes nothing be env: buildFixtureEnv(userDataDir, homeDir, {}), }); - // The app must become ready while the repair dialog is open — before - // this fix the main process deadlocked in module evaluation and - // isReady() never turned true. - expect(await mainProcessReachedReady(app)).toBe(true); + // The app must park at the dialog — before this fix the main process + // deadlocked in module evaluation and no dialog ever appeared. + expect(await appParkedAtRepairDialog(app)).toBe(true); - // Before the dialog is answered, no store/db files may be created in + // While the dialog is unanswered, no store/db files may be created in // the workspace: the root-identity gate must precede all storage. const workspaceEntries = await readdir(workspaceRoot); expect(workspaceEntries).toEqual([STORAGE_ROOT_MARKER_FILE]); diff --git a/apps/desktop/src/main/app-lifecycle.ts b/apps/desktop/src/main/app-lifecycle.ts index 4e94f138aa..6bf5cb60c1 100644 --- a/apps/desktop/src/main/app-lifecycle.ts +++ b/apps/desktop/src/main/app-lifecycle.ts @@ -45,7 +45,7 @@ import { resumeSafeBoundaryContinuationsOnStartup } from './startup-safe-boundar type AssembledTools = ReturnType; export interface AppLifecycleDeps { - // Whether this run stays out of the developer's way. main.ts owns the + // Whether this run stays out of the developer's way. boot.ts owns the // condition; the dock icon follows it so window visibility and dock // presence can never drift apart. A fixture window someone asked to see // (MAKA_E2E_SHOW_WINDOW) opts out of both together: as an accessory app it @@ -82,14 +82,14 @@ export interface AppLifecycleDeps { agentGraphSupervisorWakeCoordinator: AgentGraphSupervisorWakeCoordinator; agentGraphControlStore: ReturnType; streamEvents: StreamEvents; - /** Focus-or-create for the main window; stays in main.ts next to the + /** Focus-or-create for the main window; stays in boot.ts next to the * controller and is registered here on `second-instance` / `activate`. */ focusOrCreateMainWindow: (signal: AbortSignal) => void; emitConnectionListChanged: () => void; emitSessionsChanged: (reason: 'migrated') => void; handleExternalSettingsChange: () => Promise; /** Accessor for the settings IPC handle, which is assigned inside - * main.ts's `registerIpc()`; teardown disposes it if present. */ + * boot.ts's `registerIpc()`; teardown disposes it if present. */ getSettingsIpc: () => SettingsIpcHandle | undefined; } @@ -101,9 +101,10 @@ export interface AppLifecycleDeps { * `recoverInterruptedSessionsOnStartup`, the `window-all-closed` and `before-quit` * handlers, and `runBeforeQuitCleanup`. Startup ORDER is the product, so the * bodies stay behaviorally identical to their in-main.ts originals; every - * process-scoped collaborator is injected. The single-instance lock and - * `registerIpc()` anchor stay in main.ts. Call this once, at the same point the - * inline `app.whenReady()` used to sit (immediately after `registerIpc()`). + * process-scoped collaborator is injected. The single-instance lock stays in + * main.ts; the `registerIpc()` anchor stays in boot.ts. Call this once, at + * the same point the inline `app.whenReady()` used to sit (immediately after + * `registerIpc()`). */ export function wireAppLifecycle(deps: AppLifecycleDeps): void { const { @@ -239,7 +240,7 @@ export function wireAppLifecycle(deps: AppLifecycleDeps): void { // renderer via the existing `sessions:changed` / `connections:event` // / `settings:bots:statusChanged` channels, so the UI converges lazily. // E2E fixture workspaces are wiped and seeded before stores open in - // main.ts. SQLite keeps live file handles, so resetting the workspace + // boot.ts. SQLite keeps live file handles, so resetting the workspace // here after store construction would detach the canonical database. await runCredentialStartup(); const initialWindowSignal = quitCoordinator.getWindowCreationSignal(); diff --git a/apps/desktop/src/main/boot.ts b/apps/desktop/src/main/boot.ts index a4dc8d8876..a4afbe1f5b 100644 --- a/apps/desktop/src/main/boot.ts +++ b/apps/desktop/src/main/boot.ts @@ -680,7 +680,7 @@ const desktopBackendToolSurfaceDeps = { getAgentGraphSupervisorTools: (sessionId: string) => agentGraphCoordinator.toolsForSession(sessionId), }; -// Cursor-overlay teardown assigns a module-scoped `let`, so it stays in main.ts. +// Cursor-overlay teardown assigns a module-scoped `let`, so it stays in boot.ts. onMainWindowClose = () => computerUseOverlay.destroyAll(); const systemPromptService = createSystemPromptMainService({ settingsStore, diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index 5c7985913b..f491475f6b 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -36,8 +36,12 @@ if (!app.requestSingleInstanceLock()) { }) .catch((error: unknown) => { console.error('[startup] fatal:', error); - const message = error instanceof Error ? error.message : String(error); - dialog.showErrorBox('Maka failed to start', message); + // E2E runs must not hang on a modal error box (same reasoning as the + // fixture-fatal path in boot.ts: print a parseable line and exit fast). + if (!isIsolatedE2e) { + const message = error instanceof Error ? error.message : String(error); + dialog.showErrorBox('Maka failed to start', message); + } app.exit(1); }); } diff --git a/scripts/check-console.mjs b/scripts/check-console.mjs index 20af191452..c68f237f94 100644 --- a/scripts/check-console.mjs +++ b/scripts/check-console.mjs @@ -34,7 +34,11 @@ const ALLOW = new Map([ 'apps/desktop/src/renderer/error-boundary.tsx', 'React error boundary; DevTools-only, surfaces uncaught render errors.', ], - ['apps/desktop/src/main/main.ts', 'dev-gated by VITE_DEV_SERVER_URL / NODE_ENV (PR100).'], + ['apps/desktop/src/main/main.ts', 'thin ESM entry; pre-ready config, [startup] ready/fatal diagnostics (moved from boot, PR1880).'], + [ + 'apps/desktop/src/main/boot.ts', + 'startup chain diagnostics (e2e-fixture fatal/scenario, window create failure, repair/cleanup paths); no secrets (moved from main.ts, PR1880).', + ], [ 'apps/desktop/src/main/app-lifecycle.ts', 'startup/shutdown diagnostics (dock icon, credential migration, e2e-fixture marker, cleanup failures); no secrets (moved from main.ts, arch R6).',