From d15aeaa00d4c373a59cd6824edb3b83f45c0c901 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Sun, 24 May 2026 17:22:46 -0400 Subject: [PATCH 1/9] ship: prepare launch cleanup lane --- .gitignore | 1 + AGENTS.md | 8 +- README.md | 10 +- apps/ade-cli/src/adeRpcServer.ts | 97 +- apps/ade-cli/src/cli.test.ts | 12 + apps/ade-cli/src/cli.ts | 319 ++-- apps/ade-cli/src/multiProjectRpcServer.ts | 34 +- apps/ade-cli/src/runtimeRoles.ts | 24 + .../services/sync/syncRemoteCommandService.ts | 4 - apps/ade-cli/src/stdioRpcDaemon.test.ts | 122 ++ .../tuiClient/__tests__/connection.test.ts | 114 +- apps/ade-cli/src/tuiClient/connection.ts | 140 +- apps/desktop/package.json | 6 +- apps/desktop/src/main/main.ts | 16 - .../main/services/ai/tools/universalTools.ts | 2 - .../appControl/appControlService.test.ts | 26 +- .../services/appControl/appControlService.ts | 8 +- .../agentChatService.suggestLaneName.test.ts | 727 --------- .../services/chat/agentChatService.test.ts | 294 ++++ ...ojectConfigService.aiModeMigration.test.ts | 222 --- ...tConfigService.automationExecution.test.ts | 255 ---- .../projectConfigService.laneEnvInit.test.ts | 300 ---- .../projectConfigService.linearSync.test.ts | 98 -- ...projectConfigService.notifications.test.ts | 104 -- ...projectConfigService.processGroups.test.ts | 437 ------ .../projectConfigService.providers.test.ts | 151 -- .../config/projectConfigService.test.ts | 1307 +++++++++++++++++ .../main/services/cto/workerAgentService.ts | 1 - .../gitOperationsService.branchSwitch.test.ts | 341 ----- .../services/git/gitOperationsService.test.ts | 320 ++++ .../src/main/services/ipc/registerIpc.ts | 26 - .../lanes/laneService.branchSwitch.test.ts | 634 -------- .../main/services/lanes/laneService.test.ts | 603 +++++++- .../localRuntimeConnectionPool.test.ts | 115 ++ .../localRuntimeConnectionPool.ts | 20 +- .../services/macosVm/macosVmService.test.ts | 6 +- .../main/services/macosVm/macosVmService.ts | 2 +- .../missions/missionPreflightService.ts | 1 - .../main/services/missions/missionService.ts | 1 - .../services/onboarding/onboardingService.ts | 29 - .../aiOrchestratorService.test.ts | 184 ++- .../orchestrator/aiOrchestratorService.ts | 77 +- .../services/orchestrator/coordinatorAgent.ts | 1 - .../services/orchestrator/coordinatorTools.ts | 24 - .../orchestrator/delegationContracts.ts | 2 +- .../services/orchestrator/executionPolicy.ts | 2 +- .../orchestrator/orchestratorContext.ts | 3 +- .../orchestrator/orchestratorQueries.ts | 1 - .../orchestrator/orchestratorService.ts | 4 - .../kvDb.laneWorktreeLockMigration.test.ts | 123 -- .../services/state/kvDb.migrations.test.ts | 802 ++++++++++ .../state/kvDb.missionsMigration.test.ts | 110 -- .../state/kvDb.orchestratorMigration.test.ts | 392 ----- .../kvDb.pipelineSettingsMigration.test.ts | 128 -- apps/desktop/src/main/services/state/kvDb.ts | 11 - .../state/kvDb.workerAgentsMigration.test.ts | 154 -- .../services/usage/usageTrackingService.ts | 1 - .../src/renderer/components/app/AppShell.tsx | 2 - .../components/chat/AgentChatComposer.tsx | 41 +- .../chat/AgentChatPane.submit.test.tsx | 155 ++ .../components/chat/AgentChatPane.tsx | 102 +- .../src/renderer/components/cto/CtoPage.tsx | 6 - .../components/cto/LinearSyncPanel.tsx | 7 +- .../src/renderer/components/cto/TeamPanel.tsx | 3 - .../cto/pipeline/config/CloseoutConfig.tsx | 1 - .../components/history/eventTaxonomy.ts | 2 - .../components/history/useTimelineLayout.ts | 1 - .../components/lanes/LaneGitActionsPane.tsx | 15 - .../renderer/components/lanes/LanesPage.tsx | 4 - .../missions/missionThreadEventAdapter.ts | 23 - .../settings/AiFeaturesSection.test.tsx | 131 ++ .../components/settings/AiFeaturesSection.tsx | 2 + .../settings/ChatAppearancePreview.tsx | 5 - .../settings/LaneTemplatesSection.tsx | 1 - .../components/settings/ProvidersSection.tsx | 13 - .../settings/ProxyAndPreviewSection.tsx | 23 - .../shared/ModelPicker/ModelPicker.tsx | 26 +- .../shared/ModelPicker/ModelPickerContent.tsx | 6 +- .../src/renderer/onboarding/tours/ctoTour.ts | 13 +- .../onboarding/tours/firstJourneyTour.test.ts | 45 +- .../onboarding/tours/firstJourneyTour.ts | 168 --- .../src/shared/chatModelSwitching.test.ts | 11 + apps/desktop/src/shared/chatModelSwitching.ts | 3 +- apps/desktop/src/shared/types/orchestrator.ts | 1 - docs/ARCHITECTURE.md | 6 +- docs/features/ade-code/README.md | 32 +- docs/features/agents/tool-registration.md | 39 +- docs/features/chat/composer-and-ui.md | 19 +- docs/features/cto/README.md | 5 + docs/features/missions/README.md | 49 +- docs/features/missions/orchestration.md | 22 +- .../onboarding-and-settings/README.md | 20 +- .../remote-runtime/internal-architecture.md | 2 +- goal.md | 978 ++++-------- package.json | 6 +- scripts/dev-all.mjs | 4 +- scripts/dev-code.mjs | 4 +- scripts/dev-desktop.mjs | 7 + scripts/dev-runtime.mjs | 2 +- scripts/dev-shared.mjs | 47 +- scripts/run-desktop-test-shards.mjs | 27 + scripts/tui-web.mjs | 4 +- scripts/validate-docs.mjs | 10 +- 103 files changed, 5379 insertions(+), 5670 deletions(-) create mode 100644 apps/ade-cli/src/runtimeRoles.ts delete mode 100644 apps/desktop/src/main/services/chat/agentChatService.suggestLaneName.test.ts delete mode 100644 apps/desktop/src/main/services/config/projectConfigService.aiModeMigration.test.ts delete mode 100644 apps/desktop/src/main/services/config/projectConfigService.automationExecution.test.ts delete mode 100644 apps/desktop/src/main/services/config/projectConfigService.laneEnvInit.test.ts delete mode 100644 apps/desktop/src/main/services/config/projectConfigService.linearSync.test.ts delete mode 100644 apps/desktop/src/main/services/config/projectConfigService.notifications.test.ts delete mode 100644 apps/desktop/src/main/services/config/projectConfigService.processGroups.test.ts delete mode 100644 apps/desktop/src/main/services/config/projectConfigService.providers.test.ts create mode 100644 apps/desktop/src/main/services/config/projectConfigService.test.ts delete mode 100644 apps/desktop/src/main/services/git/gitOperationsService.branchSwitch.test.ts delete mode 100644 apps/desktop/src/main/services/lanes/laneService.branchSwitch.test.ts delete mode 100644 apps/desktop/src/main/services/state/kvDb.laneWorktreeLockMigration.test.ts create mode 100644 apps/desktop/src/main/services/state/kvDb.migrations.test.ts delete mode 100644 apps/desktop/src/main/services/state/kvDb.missionsMigration.test.ts delete mode 100644 apps/desktop/src/main/services/state/kvDb.orchestratorMigration.test.ts delete mode 100644 apps/desktop/src/main/services/state/kvDb.pipelineSettingsMigration.test.ts delete mode 100644 apps/desktop/src/main/services/state/kvDb.workerAgentsMigration.test.ts create mode 100644 apps/desktop/src/renderer/components/settings/AiFeaturesSection.test.tsx create mode 100644 scripts/run-desktop-test-shards.mjs diff --git a/.gitignore b/.gitignore index fe3b30972..ecd5b29ef 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,7 @@ __pycache__/ # Build outputs /apps/ade-cli/dist/ +/apps/ade-cli/dist-static/ /apps/ade-code/dist/ /apps/desktop/release/ /apps/desktop/dist/ diff --git a/AGENTS.md b/AGENTS.md index 346cfc6a0..050dc37c0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,7 +23,7 @@ - Desktop checks: - `npm --prefix apps/desktop run typecheck` - - `npm --prefix apps/desktop run test` + - `npm run test:desktop:sharded` - `npm --prefix apps/desktop run build` - `npm --prefix apps/desktop run lint` - ADE CLI checks: @@ -31,7 +31,7 @@ - `npm --prefix apps/ade-cli run test` - `npm --prefix apps/ade-cli run build` - Run the smallest relevant subset first when iterating, then finish with the broader checks that cover the touched surfaces. -- If even running the full desktop test suit, u ahve to shard like ci +- Run full desktop tests with the root `npm run test:desktop:sharded` command; use single-file or single-shard Vitest commands for iteration. ## Terminology @@ -81,7 +81,7 @@ Desktop release: - **Node.js 22.x** is required (`node:sqlite` is used as the primary database engine). - Each app under `apps/` has its own independent `node_modules` and `package-lock.json` (no npm workspaces). - Validation commands are documented in the "Validation" section above. -- The desktop test suite (265 test files) is large; CI shards it. For local iteration, run targeted tests (e.g. `npm --prefix apps/desktop run test:unit`) or a single file rather than the full suite. +- The desktop test suite is large; CI shards it. For local iteration, run a single file or one CI-style shard rather than the full suite. ### Inspecting the local Electron desktop app with Codex Computer Use on macOS @@ -111,7 +111,7 @@ Desktop release: - The `ADE_PROJECT_ROOT=/workspace` env var tells the main process to auto-open a project at startup. However, there is a timing race: the renderer's initial `getProject()` call may return null before the async project switch completes, causing the welcome screen to appear even though the backend loaded the project. A workaround is to open the project manually via the "Open a project" button in the top bar. - Computer-use features (screenshot, video capture, GUI automation) are macOS-only (`screencapture`, `osascript`). On Linux these gracefully degrade — the app returns `blocked_by_capability`. - `electron-builder` config only defines a `mac` target. Distributable Linux builds (deb/AppImage) are not configured, but dev mode works fine. -- The `test:unit` script (`npm --prefix apps/desktop run test:unit`) uses `--project unit` which the pinned vitest 0.34.6 doesn't support. Use `npx vitest run ` in `apps/desktop` for targeted tests, or `npm --prefix apps/desktop run test` for the full suite. +- The pinned Vitest 0.34.6 does not support `--project`. Use `npx vitest run ` in `apps/desktop` for targeted tests, `npx vitest run --shard=/8` for a CI-style shard, or `npm run test:desktop:sharded` from the repo root for the full desktop unit workspace. - In the Cursor Cloud VM the active X display is `:1`, not `:99`. When launching Electron set `DISPLAY=:1`. - To launch the desktop dev app quickly when the CLI is already built: `npm run dev:desktop -- --skip-runtime-build`. - To launch the TUI against an already-running dev runtime: `npm run dev:code -- --skip-runtime-build --attach --project-root --workspace-root `. diff --git a/README.md b/README.md index 81e22cd1d..33298fbc7 100644 --- a/README.md +++ b/README.md @@ -154,7 +154,7 @@ Daily desktop dev: npm run dev ``` -That aliases to `npm run dev:desktop`: it rebuilds `apps/ade-cli`, launches the Electron desktop app, and points it at the dev runtime socket `/tmp/ade-runtime-dev.sock`. If no dev runtime is listening, desktop is allowed to create it. This is the normal desktop-dev flow. +That aliases to `npm run dev:desktop`: it rebuilds `apps/ade-cli`, refreshes the shared dev runtime at `/tmp/ade-runtime-dev.sock` when needed, launches the Electron desktop app, and points desktop at that runtime. This is the normal desktop-dev flow. When these commands are run from an ADE lane worktree under `.ade/worktrees/`, they still run code from that lane checkout, but they open the primary checkout's @@ -165,7 +165,7 @@ and uses the lane path as the workspace root for `dev:code`. Dev command matrix: ```bash -npm run dev:desktop # desktop only; dev socket; desktop may auto-create runtime +npm run dev:desktop # refresh shared dev runtime, then launch desktop npm run dev:desktop:attach # desktop only; fail if dev runtime is not already running npm run dev:desktop:clean # desktop only; clear Vite cache before launch npm run dev:code:web # `ade code` in the browser (PTY + inspector WebSocket) @@ -192,11 +192,11 @@ ADE_DEV_RUNTIME_SOCKET_PATH=/tmp/my-ade-dev.sock npm run dev:runtime ADE_DESKTOP_BRIDGE_SOCKET_PATH=/tmp/my-bridge.sock npm run dev:desktop ``` -To test auto-runtime creation, use the `:auto`/default commands after stopping the dev runtime: +To test auto-runtime creation, use the default dev commands after stopping the dev runtime: ```bash npm run dev:stop -npm run dev:desktop # tests desktop creating the dev runtime +npm run dev:desktop # tests the desktop wrapper creating the dev runtime npm run dev:stop npm run dev:code # tests TUI wrapper creating the dev runtime ``` @@ -211,7 +211,7 @@ npm run package:beta # origin/main -> ADE Beta.app, ade-beta, ~/.ade-bet These are unsigned local macOS app builds under `apps/desktop/release-alpha` and `apps/desktop/release-beta`. Beta fetches `origin/main`, fast-forwards the local `main` checkout when possible, and builds that checkout as `ADE Beta`. It does not create a packaging worktree. These builds do not replace the production `ADE.app`, production `ade`, or `~/.ade` runtime/state. Alpha and Beta also use separate Electron profile directories (`ade-desktop-alpha` / `ade-desktop-beta`) so their browser storage and window state do not collide with dev or stable. Local channel packages include the host runtime binary for this Mac. Release builds still require the full cross-platform runtime artifact set used by remote runtime bootstrap. -Validate with `npm --prefix apps/desktop run typecheck` and `run test`. The desktop test suite is large — run the smallest relevant subset first. +Validate with `npm --prefix apps/desktop run typecheck` and `npm run test:desktop:sharded` for the full desktop suite. The desktop test suite is large, so run the smallest relevant subset first. ## Links diff --git a/apps/ade-cli/src/adeRpcServer.ts b/apps/ade-cli/src/adeRpcServer.ts index df757be85..47d871440 100644 --- a/apps/ade-cli/src/adeRpcServer.ts +++ b/apps/ade-cli/src/adeRpcServer.ts @@ -15,7 +15,6 @@ import { } from "../../desktop/src/main/services/computerUse/localComputerUse"; import { loadAgentBrowserArtifactPayloadFromFile, parseAgentBrowserArtifactPayload } from "../../desktop/src/main/services/proof/agentBrowserArtifactAdapter"; import { - ADE_ACTION_ALLOWLIST, ADE_ACTION_DOMAIN_NAMES, type AdeActionDomain, callerHasRoleAtLeast, @@ -44,8 +43,6 @@ import { import { type LinearWorkflowConfig, type ComputerUseArtifactOwner, - type DockLayout, - type GraphPersistedState, type LaneLinearIssue, type MergeMethod, type AppNavigationRequest, @@ -69,6 +66,7 @@ import { import type { AgentChatPermissionMode, TerminalSessionSummary } from "../../desktop/src/shared/types"; import type { AdeRuntime } from "./bootstrap"; import { JsonRpcError, JsonRpcErrorCode, type JsonRpcHandler, type JsonRpcRequest } from "./jsonrpc"; +import { normalizeAdeRuntimeRole } from "./runtimeRoles"; import { getSharedModelPickerStore } from "./services/modelPickerStore"; // Cross-surface (desktop + TUI + iOS) model picker favorites & recents. @@ -2101,12 +2099,6 @@ const MACOS_VM_TOOL_NAMES = new Set([ "macos_vm_type", ]); -const ALL_TOOL_SPECS: ToolSpec[] = [ - ...TOOL_SPECS, - ...CTO_OPERATOR_TOOL_SPECS, - ...CTO_LINEAR_SYNC_TOOL_SPECS, - ...COORDINATOR_TOOL_SPECS, -]; const COORDINATOR_TOOL_NAMES = new Set(COORDINATOR_TOOL_SPECS.map((tool) => tool.name)); const READ_ONLY_TOOLS = new Set([ "check_conflicts", @@ -2323,13 +2315,6 @@ function asOptionalTrimmedString(value: unknown): string | null { return text.length ? text : null; } -function parseEnvBoolean(value: string | undefined): boolean | null { - const normalized = value?.trim().toLowerCase() ?? ""; - if (normalized === "1" || normalized === "true" || normalized === "yes") return true; - if (normalized === "0" || normalized === "false" || normalized === "no") return false; - return null; -} - function asBoolean(value: unknown, fallback = false): boolean { return typeof value === "boolean" ? value : fallback; } @@ -2924,52 +2909,6 @@ function resolveRunContextLaneId(runtime: AdeRuntime, callerCtx: CallerContext): return asOptionalTrimmedString(mission?.laneId) ?? asOptionalTrimmedString(mission?.lane_id); } -function resolveAuthorizedWorkspaceRoot( - runtime: AdeRuntime, - session: SessionState, - toolArgs?: Record, -): string { - const requestedLaneId = toolArgs ? extractLaneId(toolArgs) : null; - if (requestedLaneId) { - const laneWorktreePath = resolveLaneWorktreePath(runtime, requestedLaneId); - if (!laneWorktreePath) { - throw new JsonRpcError( - JsonRpcErrorCode.invalidParams, - `Requested lane '${requestedLaneId}' does not have an available worktree.`, - ); - } - return laneWorktreePath; - } - - const sessionLaneId = resolveChatSessionLaneId(runtime, session); - if (sessionLaneId) { - const laneWorktreePath = resolveLaneWorktreePath(runtime, sessionLaneId); - if (!laneWorktreePath) { - throw new JsonRpcError( - JsonRpcErrorCode.invalidParams, - `Chat session lane '${sessionLaneId}' does not have an available worktree.`, - ); - } - return laneWorktreePath; - } - - const runContextLaneId = resolveRunContextLaneId(runtime, resolveCallerContext(session)); - if (runContextLaneId) { - const laneWorktreePath = resolveLaneWorktreePath(runtime, runContextLaneId); - if (!laneWorktreePath) { - throw new JsonRpcError( - JsonRpcErrorCode.invalidParams, - `Run context lane '${runContextLaneId}' does not have an available worktree.`, - ); - } - return laneWorktreePath; - } - - const fallbackWorkspaceRoot = typeof runtime.workspaceRoot === "string" ? runtime.workspaceRoot.trim() : ""; - if (fallbackWorkspaceRoot.length > 0) return fallbackWorkspaceRoot; - return runtime.projectRoot; -} - function resolveRequestedOrSessionLaneId( runtime: AdeRuntime, session: SessionState, @@ -3364,15 +3303,7 @@ type CallerContext = { }; function resolveEnvCallerContext(): CallerContext { - const envRoleRaw = process.env.ADE_DEFAULT_ROLE?.trim() ?? ""; - const envRole: SessionIdentity["role"] | null = - envRoleRaw === "cto" - || envRoleRaw === "orchestrator" - || envRoleRaw === "agent" - || envRoleRaw === "external" - || envRoleRaw === "evaluator" - ? envRoleRaw - : null; + const envRole = normalizeAdeRuntimeRole(process.env.ADE_DEFAULT_ROLE); const envChatSessionId = process.env.ADE_CHAT_SESSION_ID?.trim() || null; const envMissionId = process.env.ADE_MISSION_ID?.trim() || null; const envRunId = process.env.ADE_RUN_ID?.trim() || null; @@ -3498,11 +3429,6 @@ function parseInitializeIdentity(runtime: AdeRuntime, params: unknown): SessionI const data = safeObject(params); const identity = safeObject(data.identity); const envContext = resolveEnvCallerContext(); - const identityRole = asOptionalTrimmedString(identity.role); - const parsedIdentityRole: SessionIdentity["role"] | null = - identityRole === "cto" || identityRole === "orchestrator" || identityRole === "agent" || identityRole === "external" || identityRole === "evaluator" - ? identityRole - : null; const validRole: SessionIdentity["role"] = envContext.role ?? "external"; const requestedChatSessionId = asOptionalTrimmedString(identity.chatSessionId); const resolvedChatSessionId = envContext.chatSessionId ?? requestedChatSessionId; @@ -5968,7 +5894,7 @@ async function runTool(args: { } if (name === "ingest_computer_use_artifacts") { - const backendStyle = assertNonEmptyString(toolArgs.backendStyle, "backendStyle") as "external_cli" | "manual" | "local_fallback"; + assertNonEmptyString(toolArgs.backendStyle, "backendStyle"); const backendName = assertNonEmptyString(toolArgs.backendName, "backendName"); const manifestPath = asOptionalTrimmedString(toolArgs.manifestPath); let inputs = Array.isArray(toolArgs.inputs) ? toolArgs.inputs.map((entry) => safeObject(entry)) : []; @@ -7402,9 +7328,8 @@ const APP_NAVIGATE_SUPPORTED_KINDS = new Set([ export function createAdeRpcRequestHandler(args: { runtime: AdeRuntime; serverVersion: string; - onActionsListChanged?: (() => void) | null; }): JsonRpcHandler & { dispose: () => void } { - const { runtime, serverVersion, onActionsListChanged } = args; + const { runtime, serverVersion } = args; const session: SessionState = { initialized: false, @@ -7518,11 +7443,21 @@ export function createAdeRpcRequestHandler(args: { protocolVersion: session.protocolVersion, runtimeInfo: { name: "ade-rpc", - version: serverVersion + version: serverVersion, + buildHash: + typeof process.env.ADE_RUNTIME_BUILD_HASH === "string" && + process.env.ADE_RUNTIME_BUILD_HASH.trim() + ? process.env.ADE_RUNTIME_BUILD_HASH.trim() + : null, + defaultRole: + normalizeAdeRuntimeRole(process.env.ADE_DEFAULT_ROLE), + projectRoot: runtime.projectRoot, + workspaceRoot: runtime.workspaceRoot ?? null, + pid: process.pid }, capabilities: { actions: { - listChanged: true + listChanged: false }, ...(resourcesEnabled ? { diff --git a/apps/ade-cli/src/cli.test.ts b/apps/ade-cli/src/cli.test.ts index 7134b5679..fcc9fef6b 100644 --- a/apps/ade-cli/src/cli.test.ts +++ b/apps/ade-cli/src/cli.test.ts @@ -59,6 +59,18 @@ describe("ADE CLI", () => { ]); }); + it("defaults ordinary CLI calls to the CTO runtime role", () => { + const previousRole = process.env.ADE_DEFAULT_ROLE; + delete process.env.ADE_DEFAULT_ROLE; + try { + const parsed = parseCliArgs(["lanes", "list"]); + expect(parsed.options.role).toBe("cto"); + } finally { + if (previousRole === undefined) delete process.env.ADE_DEFAULT_ROLE; + else process.env.ADE_DEFAULT_ROLE = previousRole; + } + }); + it("maps ade code to the terminal Work chat launcher", () => { const parsed = parseCliArgs([ "--project-root", diff --git a/apps/ade-cli/src/cli.ts b/apps/ade-cli/src/cli.ts index 73eb5878c..8a69dc22c 100644 --- a/apps/ade-cli/src/cli.ts +++ b/apps/ade-cli/src/cli.ts @@ -43,6 +43,9 @@ import type { SyncProjectSwitchResultPayload, } from "../../desktop/src/shared/types/sync"; import { MACOS_VM_PHASES } from "../../desktop/src/shared/types/macosVm"; +import type { AdeServiceCommand } from "./serviceManager/common"; +import { normalizeAdeRuntimeRole, resolveAdeDefaultRole } from "./runtimeRoles"; +import type { AdeRuntime } from "./bootstrap"; type JsonObject = Record; @@ -2055,10 +2058,7 @@ function parseCliArgs(argv: string[]): ParsedCli { const options: GlobalOptions = { projectRoot: null, workspaceRoot: null, - role: - (asString(process.env.ADE_DEFAULT_ROLE) as - | GlobalOptions["role"] - | null) ?? "agent", + role: resolveAdeDefaultRole(process.env.ADE_DEFAULT_ROLE, "cto"), headless: parseBooleanEnv(process.env.ADE_CLI_HEADLESS), requireSocket: false, pretty: true, @@ -2172,15 +2172,8 @@ function parseCliArgs(argv: string[]): ParsedCli { } function parseRole(value: string): GlobalOptions["role"] { - if ( - value === "cto" || - value === "orchestrator" || - value === "agent" || - value === "external" || - value === "evaluator" - ) { - return value; - } + const role = normalizeAdeRuntimeRole(value); + if (role) return role; throw new CliUsageError( "--role must be one of cto, orchestrator, agent, external, or evaluator.", ); @@ -10609,61 +10602,95 @@ async function createConnection( } const previousRole = process.env.ADE_DEFAULT_ROLE; - process.env.ADE_DEFAULT_ROLE = options.role; - const [{ createAdeRuntime }, { createAdeRpcRequestHandler }] = - await Promise.all([import("./bootstrap"), import("./adeRpcServer")]); - const runtime = await createAdeRuntime({ - projectRoot: roots.projectRoot, - workspaceRoot: roots.workspaceRoot, - }); - const createHandler = () => - createAdeRpcRequestHandler({ - runtime, - serverVersion: VERSION, - onActionsListChanged: () => {}, - }); - const handler = createHandler(); const previousRpcUrl = process.env.ADE_RPC_URL; + let roleOwnedByConnection = false; + process.env.ADE_DEFAULT_ROLE = options.role; + const restoreRole = () => { + if (roleOwnedByConnection) return; + if (previousRole == null) delete process.env.ADE_DEFAULT_ROLE; + else process.env.ADE_DEFAULT_ROLE = previousRole; + }; + const restoreRpcUrl = () => { + if (previousRpcUrl == null) delete process.env.ADE_RPC_URL; + else process.env.ADE_RPC_URL = previousRpcUrl; + }; + let runtime: AdeRuntime | null = null; + let handler: (JsonRpcHandler & { dispose?: () => void }) | null = null; let stopHeadlessSocket: (() => void) | null = null; let stopHeadlessTcp: (() => void) | null = null; try { - const tcp = await startHeadlessRpcTcpServer({ createHandler }); - process.env.ADE_RPC_URL = tcp.url; - stopHeadlessTcp = tcp.stop; - } catch { - stopHeadlessTcp = null; - } - try { - stopHeadlessSocket = await startHeadlessRpcSocketServers({ + const [{ createAdeRuntime }, { createAdeRpcRequestHandler }] = + await Promise.all([import("./bootstrap"), import("./adeRpcServer")]); + runtime = await createAdeRuntime({ projectRoot: roots.projectRoot, - socketPath: legacySocketPath, - createHandler, + workspaceRoot: roots.workspaceRoot, }); - } catch { - stopHeadlessSocket = null; - } + const createHandler = () => + createAdeRpcRequestHandler({ + runtime: runtime!, + serverVersion: VERSION, + }); + handler = createHandler(); + try { + const tcp = await startHeadlessRpcTcpServer({ createHandler }); + process.env.ADE_RPC_URL = tcp.url; + stopHeadlessTcp = tcp.stop; + } catch { + stopHeadlessTcp = null; + } + try { + stopHeadlessSocket = await startHeadlessRpcSocketServers({ + projectRoot: roots.projectRoot, + socketPath: legacySocketPath, + createHandler, + }); + } catch { + stopHeadlessSocket = null; + } - const inProcess = new InProcessJsonRpcClient(handler, runtime, previousRole); - const connection: CliConnection = { - mode: "headless", - projectRoot: roots.projectRoot, - workspaceRoot: roots.workspaceRoot, - socketPath: legacySocketPath, - request: (method, params) => inProcess.request(method, params), - close: () => { - try { - stopHeadlessSocket?.(); - } catch {} - try { - stopHeadlessTcp?.(); - } catch {} - if (previousRpcUrl == null) delete process.env.ADE_RPC_URL; - else process.env.ADE_RPC_URL = previousRpcUrl; - inProcess.close(); - }, - }; - await initializeConnection(connection, options); - return connection; + const inProcess = new InProcessJsonRpcClient(handler, runtime, previousRole); + const connection: CliConnection = { + mode: "headless", + projectRoot: roots.projectRoot, + workspaceRoot: roots.workspaceRoot, + socketPath: legacySocketPath, + request: (method, params) => inProcess.request(method, params), + close: () => { + try { + stopHeadlessSocket?.(); + } catch {} + try { + stopHeadlessTcp?.(); + } catch {} + restoreRpcUrl(); + inProcess.close(); + }, + }; + roleOwnedByConnection = true; + try { + await initializeConnection(connection, options); + return connection; + } catch (error) { + connection.close(); + throw error; + } + } catch (error) { + try { + stopHeadlessSocket?.(); + } catch {} + try { + stopHeadlessTcp?.(); + } catch {} + try { + handler?.dispose?.(); + } catch {} + try { + runtime?.dispose(); + } catch {} + restoreRpcUrl(); + restoreRole(); + throw error; + } } function buildInitializeParams( @@ -10726,14 +10753,63 @@ async function resolveMachineRuntimeSocketPath( return normalizeRuntimeSocketPath(rawSocketPath); } -function readRuntimeInfoVersion(value: unknown): string | null { - if (!isRecord(value) || !isRecord(value.runtimeInfo)) return null; - return asString(value.runtimeInfo.version); +type MachineRuntimeInfo = { + version: string | null; + buildHash: string | null; + defaultRole: string | null; + projectRoot: string | null; + pid: number | null; +}; + +function readMachineRuntimeInfo(value: unknown): MachineRuntimeInfo { + if (!isRecord(value) || !isRecord(value.runtimeInfo)) { + return { + version: null, + buildHash: null, + defaultRole: null, + projectRoot: null, + pid: null, + }; + } + const pid = value.runtimeInfo.pid; + return { + version: asString(value.runtimeInfo.version)?.trim() || null, + buildHash: asString(value.runtimeInfo.buildHash)?.trim() || null, + defaultRole: normalizeAdeRuntimeRole(value.runtimeInfo.defaultRole), + projectRoot: asString(value.runtimeInfo.projectRoot)?.trim() || null, + pid: + typeof pid === "number" && Number.isFinite(pid) && pid > 0 + ? Math.floor(pid) + : null, + }; } -function shouldReplaceMachineRuntimeVersion(runtimeVersion: string | null): boolean { - if (VERSION === PLACEHOLDER_VERSION) return false; - return runtimeVersion == null || runtimeVersion !== VERSION; +function machineRuntimeMismatchReason( + runtimeInfo: MachineRuntimeInfo, + expectedBuildHash: string | null, + expectedDefaultRole: GlobalOptions["role"], +): string | null { + const runtimeVersion = runtimeInfo.version; + if (VERSION !== PLACEHOLDER_VERSION) { + const versionMatches = runtimeVersion === VERSION; + const placeholderBuildMatches = + runtimeVersion === PLACEHOLDER_VERSION && + expectedBuildHash != null && + runtimeInfo.buildHash === expectedBuildHash; + if (!versionMatches && !placeholderBuildMatches) { + return `version ${runtimeVersion ?? "missing"} does not match CLI version ${VERSION}`; + } + } else if (runtimeVersion && runtimeVersion !== PLACEHOLDER_VERSION) { + return null; + } + + if (expectedBuildHash && runtimeInfo.buildHash !== expectedBuildHash) { + return runtimeInfo.buildHash ? "build hash changed" : "build hash missing"; + } + if (runtimeInfo.defaultRole !== expectedDefaultRole) { + return `default role ${runtimeInfo.defaultRole ?? "missing"} does not match CLI role ${expectedDefaultRole}`; + } + return null; } function computeRuntimeBuildHash(filePath: string): string | null { @@ -10744,15 +10820,42 @@ function computeRuntimeBuildHash(filePath: string): string | null { } } +function prepareMachineRuntimeDaemonCommand(serviceCommand: AdeServiceCommand): { + args: string[]; + buildHash: string | null; +} { + const args = [...serviceCommand.args]; + let buildHash: string | null = null; + if ( + serviceCommand.command === process.execPath && + args.length === 1 && + args[0] === "serve" && + fs.existsSync(CLI_DIST_PATH) + ) { + args.splice(0, 1, CLI_DIST_PATH, "serve"); + buildHash = computeRuntimeBuildHash(CLI_DIST_PATH); + } else if (serviceCommand.command === process.execPath && args[0]) { + buildHash = computeRuntimeBuildHash(path.resolve(args[0])); + } else if (fs.existsSync(serviceCommand.command)) { + buildHash = computeRuntimeBuildHash(path.resolve(serviceCommand.command)); + } + return { args, buildHash }; +} + +async function resolveExpectedMachineRuntimeBuildHash(): Promise { + const { resolveAdeServeCommand } = await import("./serviceManager/common"); + return prepareMachineRuntimeDaemonCommand(resolveAdeServeCommand()).buildHash; +} + async function initializeMachineRuntimeDaemon( client: SocketJsonRpcClient, options: GlobalOptions, -): Promise { +): Promise { const result = await client.request( "ade/initialize", buildInitializeParams(options, "ade-rpc-stdio-proxy"), ); - return readRuntimeInfoVersion(result); + return readMachineRuntimeInfo(result); } async function shutdownMachineRuntimeDaemon( @@ -10778,19 +10881,8 @@ async function spawnMachineRuntimeDaemon( const { resolveAdeServeCommand } = await import("./serviceManager/common"); const serviceCommand = resolveAdeServeCommand(); - const args = [...serviceCommand.args]; - let runtimeBuildHash: string | null = null; - if ( - serviceCommand.command === process.execPath && - args.length === 1 && - args[0] === "serve" && - fs.existsSync(CLI_DIST_PATH) - ) { - args.splice(0, 1, CLI_DIST_PATH, "serve"); - runtimeBuildHash = computeRuntimeBuildHash(CLI_DIST_PATH); - } else if (serviceCommand.command === process.execPath && args[0]) { - runtimeBuildHash = computeRuntimeBuildHash(path.resolve(args[0])); - } + const { args, buildHash: runtimeBuildHash } = + prepareMachineRuntimeDaemonCommand(serviceCommand); args.push("--socket", socketPath); const env: NodeJS.ProcessEnv = { @@ -10822,28 +10914,34 @@ async function connectMachineRuntimeDaemon( const socketPath = await resolveMachineRuntimeSocketPath(socketPathOverride); const label = "ADE runtime daemon socket"; const allowSpawn = connectOptions.allowSpawn ?? !options.requireSocket; + const expectedBuildHash = await resolveExpectedMachineRuntimeBuildHash(); try { const client = await SocketJsonRpcClient.connect( socketPath, options.timeoutMs, label, ); - const runtimeVersion = await initializeMachineRuntimeDaemon( + const runtimeInfo = await initializeMachineRuntimeDaemon( client, options, ); - if (shouldReplaceMachineRuntimeVersion(runtimeVersion)) { - if (!allowSpawn) { + const mismatch = machineRuntimeMismatchReason( + runtimeInfo, + expectedBuildHash, + options.role, + ); + if (mismatch) { + if (!allowSpawn || socketPath.startsWith("tcp://")) { client.close(); throw new Error( - `ADE runtime daemon version ${runtimeVersion} does not match CLI version ${VERSION}.`, + `ADE runtime daemon ${mismatch}.`, ); } await shutdownMachineRuntimeDaemon(client); const spawned = await spawnMachineRuntimeDaemon(socketPath, options); if (!spawned) { throw new Error( - `ADE runtime daemon version ${runtimeVersion} does not match CLI version ${VERSION}.`, + `ADE runtime daemon ${mismatch}.`, ); } const restarted = await SocketJsonRpcClient.connect( @@ -10851,14 +10949,19 @@ async function connectMachineRuntimeDaemon( options.timeoutMs, label, ); - const restartedVersion = await initializeMachineRuntimeDaemon( + const restartedInfo = await initializeMachineRuntimeDaemon( restarted, options, ); - if (shouldReplaceMachineRuntimeVersion(restartedVersion)) { + const restartedMismatch = machineRuntimeMismatchReason( + restartedInfo, + expectedBuildHash, + options.role, + ); + if (restartedMismatch) { await shutdownMachineRuntimeDaemon(restarted); throw new Error( - `ADE runtime daemon version ${restartedVersion} does not match CLI version ${VERSION}.`, + `ADE runtime daemon ${restartedMismatch}.`, ); } return restarted; @@ -10874,14 +10977,19 @@ async function connectMachineRuntimeDaemon( options.timeoutMs, label, ); - const runtimeVersion = await initializeMachineRuntimeDaemon( + const runtimeInfo = await initializeMachineRuntimeDaemon( client, options, ); - if (shouldReplaceMachineRuntimeVersion(runtimeVersion)) { + const mismatch = machineRuntimeMismatchReason( + runtimeInfo, + expectedBuildHash, + options.role, + ); + if (mismatch) { await shutdownMachineRuntimeDaemon(client); throw new Error( - `ADE runtime daemon version ${runtimeVersion} does not match CLI version ${VERSION}.`, + `ADE runtime daemon ${mismatch}.`, ); } return client; @@ -10916,7 +11024,7 @@ async function runRuntimeCommand( "ADE runtime daemon socket", ); try { - const runtimeVersion = await initializeMachineRuntimeDaemon( + const runtimeInfo = await initializeMachineRuntimeDaemon( client, options, ); @@ -10924,7 +11032,11 @@ async function runRuntimeCommand( ok: true, running: true, socketPath, - version: runtimeVersion, + version: runtimeInfo.version, + buildHash: runtimeInfo.buildHash, + defaultRole: runtimeInfo.defaultRole, + projectRoot: runtimeInfo.projectRoot, + pid: runtimeInfo.pid, message: "ADE runtime daemon is running.", }; } finally { @@ -10945,7 +11057,7 @@ async function runRuntimeCommand( allowSpawn: true, }); try { - const runtimeVersion = await initializeMachineRuntimeDaemon( + const runtimeInfo = await initializeMachineRuntimeDaemon( client, options, ).catch(() => null); @@ -10953,7 +11065,11 @@ async function runRuntimeCommand( ok: true, running: true, socketPath, - version: runtimeVersion, + version: runtimeInfo?.version ?? null, + buildHash: runtimeInfo?.buildHash ?? null, + defaultRole: runtimeInfo?.defaultRole ?? null, + projectRoot: runtimeInfo?.projectRoot ?? null, + pid: runtimeInfo?.pid ?? null, message: "ADE runtime daemon is running.", }; } finally { @@ -11317,6 +11433,7 @@ async function runServe( }); const previousRole = process.env.ADE_DEFAULT_ROLE; process.env.ADE_DEFAULT_ROLE = options.role; + try { const states: HeadlessRpcServerState[] = []; let done = false; @@ -11414,9 +11531,11 @@ async function runServe( fs.unlinkSync(socketPath); } catch {} } - if (previousRole == null) delete process.env.ADE_DEFAULT_ROLE; - else process.env.ADE_DEFAULT_ROLE = previousRole; return null; + } finally { + if (previousRole == null) delete process.env.ADE_DEFAULT_ROLE; + else process.env.ADE_DEFAULT_ROLE = previousRole; + } } function isFailedServiceManagerResult(value: unknown): boolean { @@ -13324,7 +13443,7 @@ function summarizeExecution(args: { connection.mode === "desktop-socket" ? "local-desktop-socket" : "local-headless-project", - role: process.env.ADE_DEFAULT_ROLE ?? "agent", + role: resolveAdeDefaultRole(process.env.ADE_DEFAULT_ROLE, "cto"), projectRoot: connection.projectRoot, workspaceRoot: connection.workspaceRoot, socketPath: connection.socketPath, diff --git a/apps/ade-cli/src/multiProjectRpcServer.ts b/apps/ade-cli/src/multiProjectRpcServer.ts index f702ad045..795a90d21 100644 --- a/apps/ade-cli/src/multiProjectRpcServer.ts +++ b/apps/ade-cli/src/multiProjectRpcServer.ts @@ -28,6 +28,7 @@ import { } from "./services/projects/projectRegistry"; import { ProjectScopeRegistry } from "./services/projects/projectScope"; import { createHeadlessGitHubService } from "./headlessLinearServices"; +import { normalizeAdeRuntimeRole } from "./runtimeRoles"; import type { SyncPeerDeviceType } from "../../desktop/src/shared/types"; type HandlerEntry = { @@ -279,7 +280,6 @@ export function createMultiProjectRpcRequestHandler( const handler = createAdeRpcRequestHandler({ runtime: scope.runtime, serverVersion: options.serverVersion, - onActionsListChanged: () => {}, }); if (initializedParams) { await handler({ @@ -379,6 +379,20 @@ export function createMultiProjectRpcRequestHandler( return syncService; }; + const resolveRuntimeEnvInfo = () => ({ + buildHash: + typeof process.env.ADE_RUNTIME_BUILD_HASH === "string" && + process.env.ADE_RUNTIME_BUILD_HASH.trim() + ? process.env.ADE_RUNTIME_BUILD_HASH.trim() + : null, + defaultRole: normalizeAdeRuntimeRole(process.env.ADE_DEFAULT_ROLE), + projectRoot: + typeof process.env.ADE_PROJECT_ROOT === "string" && + process.env.ADE_PROJECT_ROOT.trim() + ? path.resolve(process.env.ADE_PROJECT_ROOT.trim()) + : null, + }); + const handler = (async (request: JsonRpcRequest): Promise => { const method = typeof request.method === "string" ? request.method : ""; const params = safeParams(request.params); @@ -393,17 +407,13 @@ export function createMultiProjectRpcRequestHandler( runtimeInfo: { name: "ade-rpc", version: options.serverVersion, - buildHash: - typeof process.env.ADE_RUNTIME_BUILD_HASH === "string" && - process.env.ADE_RUNTIME_BUILD_HASH.trim() - ? process.env.ADE_RUNTIME_BUILD_HASH.trim() - : null, + ...resolveRuntimeEnvInfo(), multiProject: true, pid: process.pid, }, capabilities: { actions: { - listChanged: true, + listChanged: false, }, projects: true, machineProjects: { @@ -436,9 +446,19 @@ export function createMultiProjectRpcRequestHandler( if (method === "runtime/info" || method === "machineInfo.get") { const layout = resolveMachineAdeLayout(); + const envInfo = resolveRuntimeEnvInfo(); return { version: options.serverVersion, runtimeKind: "headless", + ...envInfo, + pid: process.pid, + runtimeInfo: { + name: "ade-rpc", + version: options.serverVersion, + ...envInfo, + multiProject: true, + pid: process.pid, + }, adeDir: layout.adeDir, socketPath: layout.socketPath, projectCount: projectRegistry.list().length, diff --git a/apps/ade-cli/src/runtimeRoles.ts b/apps/ade-cli/src/runtimeRoles.ts new file mode 100644 index 000000000..faa6885ad --- /dev/null +++ b/apps/ade-cli/src/runtimeRoles.ts @@ -0,0 +1,24 @@ +export const ADE_RUNTIME_ROLES = [ + "cto", + "orchestrator", + "agent", + "external", + "evaluator", +] as const; + +export type AdeRuntimeRole = (typeof ADE_RUNTIME_ROLES)[number]; + +export function normalizeAdeRuntimeRole(value: unknown): AdeRuntimeRole | null { + if (typeof value !== "string") return null; + const trimmed = value.trim(); + return (ADE_RUNTIME_ROLES as readonly string[]).includes(trimmed) + ? (trimmed as AdeRuntimeRole) + : null; +} + +export function resolveAdeDefaultRole( + value: unknown, + fallback: AdeRuntimeRole, +): AdeRuntimeRole { + return normalizeAdeRuntimeRole(value) ?? fallback; +} diff --git a/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts b/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts index ee611c7c9..bc1fa4e82 100644 --- a/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts +++ b/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts @@ -108,7 +108,6 @@ import type { SyncStartCliSessionArgs, SyncStartCliSessionResult, SyncRunQuickCommandArgs, - TerminalSessionSummary, UpdateSessionMetaArgs, UpdateIntegrationProposalArgs, TerminalToolType, @@ -119,15 +118,12 @@ import type { } from "../../../../desktop/src/shared/types"; import { buildTrackedCliLaunchCommand, - buildTrackedCliResumeCommand, deriveTrackedCliInitialInputSessionMeta, isLaunchProfile, isTrackedCliPermissionMode, LAUNCH_PROFILE_TITLE, LAUNCH_PROFILE_TOOL_TYPE, - launchProfileForTerminalSession, resolveCleanShellLaunchFields, - resolveTrackedCliResumeCommand, validateLaunchProfilePermissionMode, } from "../../../../desktop/src/shared/cliLaunch"; import { normalizePrCreationStrategy } from "../../../../desktop/src/shared/prStrategy"; diff --git a/apps/ade-cli/src/stdioRpcDaemon.test.ts b/apps/ade-cli/src/stdioRpcDaemon.test.ts index 130df73ef..ef5810746 100644 --- a/apps/ade-cli/src/stdioRpcDaemon.test.ts +++ b/apps/ade-cli/src/stdioRpcDaemon.test.ts @@ -1,4 +1,5 @@ import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import { createHash } from "node:crypto"; import fs from "node:fs"; import net from "node:net"; import os from "node:os"; @@ -25,6 +26,10 @@ function withTsxNodeOptions(value: string | undefined): string { return existing ? `${existing} --import tsx` : "--import tsx"; } +function fileSha256(filePath: string): string { + return createHash("sha256").update(fs.readFileSync(filePath)).digest("hex"); +} + async function waitForSocket(socketPath: string, timeoutMs = 10_000): Promise { const startedAt = Date.now(); let lastError: Error | null = null; @@ -298,6 +303,123 @@ describe("ade rpc --stdio daemon bridge", () => { } }, 45_000); + itUnix("restarts a same-version daemon when its build hash is stale", async () => { + const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); + const cliPath = path.join(packageRoot, "src", "cli.ts"); + const adeHome = fs.mkdtempSync(path.join(os.tmpdir(), "ade-stdio-rpc-build-")); + const socketPath = path.join(adeHome, "sock", "ade.sock"); + const baseEnv = { + ...process.env, + ADE_HOME: adeHome, + ADE_RUNTIME_SOCKET_PATH: socketPath, + NODE_OPTIONS: withTsxNodeOptions(process.env.NODE_OPTIONS), + ADE_CLI_VERSION: "2.0.0", + }; + const oldDaemon = startServeProcess({ + cliPath, + cwd: packageRoot, + env: { + ...baseEnv, + ADE_RUNTIME_BUILD_HASH: "old-build", + }, + socketPath, + }); + + let proxy: StdioRpcProcess | null = null; + try { + await waitForSocket(socketPath); + + proxy = StdioRpcProcess.start({ + cliPath, + cwd: packageRoot, + env: baseEnv, + }); + const initialize = await proxy.request("ade/initialize", { + protocolVersion: "2025-06-18", + clientName: "stdio-daemon-build-test", + identity: { role: "external", callerId: "stdio-daemon-build-test" }, + }); + + expect(initialize).toMatchObject({ + runtimeInfo: { + version: "2.0.0", + multiProject: true, + }, + }); + expect( + (initialize as { runtimeInfo?: { buildHash?: string | null } }).runtimeInfo?.buildHash, + ).toBeTruthy(); + expect( + (initialize as { runtimeInfo?: { buildHash?: string | null } }).runtimeInfo?.buildHash, + ).not.toBe("old-build"); + + await expect(proxy.request("shutdown")).resolves.toEqual({}); + proxy.closeInput(); + await expect(proxy.waitForExit()).resolves.toMatchObject({ code: 0, signal: null }); + } finally { + proxy?.kill(); + if (!oldDaemon.killed) oldDaemon.kill(); + } + }, 45_000); + + itUnix("restarts a same-version daemon when its default role is stale", async () => { + const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); + const cliPath = path.join(packageRoot, "src", "cli.ts"); + const adeHome = fs.mkdtempSync(path.join(os.tmpdir(), "ade-stdio-rpc-role-")); + const socketPath = path.join(adeHome, "sock", "ade.sock"); + const baseEnv = { + ...process.env, + ADE_HOME: adeHome, + ADE_RUNTIME_SOCKET_PATH: socketPath, + NODE_OPTIONS: withTsxNodeOptions(process.env.NODE_OPTIONS), + ADE_CLI_VERSION: "2.0.0", + }; + const oldDaemon = startServeProcess({ + cliPath, + cwd: packageRoot, + env: { + ...baseEnv, + ADE_DEFAULT_ROLE: "external", + ADE_RUNTIME_BUILD_HASH: fileSha256(cliPath), + }, + socketPath, + }); + + let proxy: StdioRpcProcess | null = null; + try { + await waitForSocket(socketPath); + + proxy = StdioRpcProcess.start({ + cliPath, + cwd: packageRoot, + env: { + ...baseEnv, + ADE_DEFAULT_ROLE: "agent", + }, + }); + const initialize = await proxy.request("ade/initialize", { + protocolVersion: "2025-06-18", + clientName: "stdio-daemon-role-test", + identity: { role: "external", callerId: "stdio-daemon-role-test" }, + }); + + expect(initialize).toMatchObject({ + runtimeInfo: { + version: "2.0.0", + defaultRole: "agent", + multiProject: true, + }, + }); + + await expect(proxy.request("shutdown")).resolves.toEqual({}); + proxy.closeInput(); + await expect(proxy.waitForExit()).resolves.toMatchObject({ code: 0, signal: null }); + } finally { + proxy?.kill(); + if (!oldDaemon.killed) oldDaemon.kill(); + } + }, 45_000); + itUnix("does not replace a real daemon when the bridging CLI has only the placeholder version", async () => { const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const cliPath = path.join(packageRoot, "src", "cli.ts"); diff --git a/apps/ade-cli/src/tuiClient/__tests__/connection.test.ts b/apps/ade-cli/src/tuiClient/__tests__/connection.test.ts index af322dcf9..ae65de0ed 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/connection.test.ts +++ b/apps/ade-cli/src/tuiClient/__tests__/connection.test.ts @@ -1,4 +1,5 @@ import fs from "node:fs"; +import { createHash } from "node:crypto"; import net from "node:net"; import os from "node:os"; import path from "node:path"; @@ -28,7 +29,7 @@ vi.mock("node:child_process", () => ({ })); const embedded = vi.hoisted(() => { - const requests: Array<{ jsonrpc: string; id: number; method: string; params?: unknown }> = []; + const requests: Array<{ jsonrpc: string; id: number; method: string; params?: unknown; envRole?: string }> = []; const runtime = { dispose: vi.fn(), agentChatService: { @@ -37,7 +38,7 @@ const embedded = vi.hoisted(() => { }; const handler = Object.assign( vi.fn(async (message: { jsonrpc: string; id: number; method: string; params?: unknown }) => { - requests.push(message); + requests.push({ ...message, envRole: process.env.ADE_DEFAULT_ROLE }); return { ok: true, method: message.method }; }), { dispose: vi.fn() }, @@ -70,6 +71,7 @@ const project: ProjectLaunchContext = { const originalArgv1 = process.argv[1]; const originalAdeHome = process.env.ADE_HOME; const originalAdeRpcSocketPath = process.env.ADE_RPC_SOCKET_PATH; +const originalAdeDefaultRole = process.env.ADE_DEFAULT_ROLE; function restoreEnv(): void { process.argv[1] = originalArgv1; @@ -78,6 +80,8 @@ function restoreEnv(): void { if (originalAdeRpcSocketPath === undefined) delete process.env.ADE_RPC_SOCKET_PATH; else process.env.ADE_RPC_SOCKET_PATH = originalAdeRpcSocketPath; + if (originalAdeDefaultRole === undefined) delete process.env.ADE_DEFAULT_ROLE; + else process.env.ADE_DEFAULT_ROLE = originalAdeDefaultRole; } function useMissingMachineSocket(): string { @@ -92,9 +96,16 @@ function mockAttachedClient(): { onNotification: ReturnType; close: ReturnType; } { + const runtimeBuildHash = (() => { + const entrypoint = process.argv[1]; + if (!entrypoint || !fs.existsSync(entrypoint)) return null; + return createHash("sha256").update(fs.readFileSync(entrypoint)).digest("hex"); + })(); const client = { request: vi.fn(async (method: string) => { - if (method === "ade/initialize") return {}; + if (method === "ade/initialize") return { + runtimeInfo: { defaultRole: "cto", projectRoot: project.projectRoot, buildHash: runtimeBuildHash }, + }; if (method === "ade/initialized") return null; return { ok: true }; }), @@ -151,6 +162,53 @@ describe("connectToAde embedded mode", () => { expect(new Set(embedded.requests.map((request) => request.id)).size).toBe(4); }); + it("injects a trusted CTO role while initializing the direct embedded runtime", async () => { + delete process.env.ADE_DEFAULT_ROLE; + + const connection = await connectToAde({ + project, + forceEmbedded: true, + }); + await connection.close(); + + const initializeRequest = embedded.requests.find((request) => request.method === "ade/initialize"); + expect(initializeRequest?.envRole).toBe("cto"); + expect(initializeRequest?.params).toMatchObject({ + identity: { + role: "cto", + }, + }); + expect(process.env.ADE_DEFAULT_ROLE).toBeUndefined(); + }); + + it("treats an invalid embedded default role as missing during initialization", async () => { + process.env.ADE_DEFAULT_ROLE = "not-a-role"; + + const connection = await connectToAde({ + project, + forceEmbedded: true, + }); + await connection.close(); + + const initializeRequest = embedded.requests.find((request) => request.method === "ade/initialize"); + expect(initializeRequest?.envRole).toBe("cto"); + expect(process.env.ADE_DEFAULT_ROLE).toBe("not-a-role"); + }); + + it("treats a non-CTO embedded default role as stale during initialization", async () => { + process.env.ADE_DEFAULT_ROLE = "agent"; + + const connection = await connectToAde({ + project, + forceEmbedded: true, + }); + await connection.close(); + + const initializeRequest = embedded.requests.find((request) => request.method === "ade/initialize"); + expect(initializeRequest?.envRole).toBe("cto"); + expect(process.env.ADE_DEFAULT_ROLE).toBe("agent"); + }); + it("does not silently fall back to embedded mode when socket attach fails", async () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-code-missing-socket-")); const socketPath = path.join(tmpDir, "missing.sock"); @@ -163,6 +221,41 @@ describe("connectToAde embedded mode", () => { expect(embedded.createAdeRuntime).not.toHaveBeenCalled(); }); + it("rejects a direct socket whose runtime role is stale", async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-code-stale-role-")); + const socketPath = path.join(tmpDir, "ade.sock"); + const requests: string[] = []; + const server = net.createServer((socket) => { + let buffer = ""; + socket.on("data", (chunk) => { + buffer += chunk.toString("utf8"); + while (true) { + const newline = buffer.indexOf("\n"); + if (newline < 0) return; + const line = buffer.slice(0, newline).trim(); + buffer = buffer.slice(newline + 1); + if (!line) continue; + const request = JSON.parse(line) as { id: number; method: string }; + requests.push(request.method); + const result = request.method === "ade/initialize" + ? { runtimeInfo: { defaultRole: "agent", projectRoot: project.projectRoot, pid: 123 } } + : null; + socket.write(`${JSON.stringify({ jsonrpc: "2.0", id: request.id, result })}\n`); + } + }); + }); + await new Promise((resolve) => server.listen(socketPath, resolve)); + + await expect(connectToAde({ + project, + socketPath, + })).rejects.toThrow(/stale .*default role agent is not cto/); + + expect(requests).toEqual(["ade/initialize", "ade/initialized"]); + await new Promise((resolve) => server.close(() => resolve())); + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + it("registers the project and injects projectId when attached to the machine daemon", async () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-code-connection-")); const socketPath = path.join(tmpDir, "ade.sock"); @@ -182,7 +275,7 @@ describe("connectToAde embedded mode", () => { const result = (() => { if (request.method === "ade/initialize") { return { - runtimeInfo: { multiProject: true }, + runtimeInfo: { multiProject: true, defaultRole: "cto" }, capabilities: { projects: true }, }; } @@ -242,7 +335,7 @@ describe("connectToAde embedded mode", () => { const result = (() => { if (request.method === "ade/initialize") { return { - runtimeInfo: { multiProject: true }, + runtimeInfo: { multiProject: true, defaultRole: "cto" }, capabilities: { projects: true }, }; } @@ -330,7 +423,10 @@ describe("connectToAde embedded mode", () => { expect(spawnCall?.[2]).toMatchObject({ detached: true, stdio: "ignore", - env: expect.objectContaining({ ADE_RPC_SOCKET_PATH: socketPath }), + env: expect.objectContaining({ + ADE_DEFAULT_ROLE: "cto", + ADE_RPC_SOCKET_PATH: socketPath, + }), }); expect(childProcess.child.unref).toHaveBeenCalledTimes(1); expect(client.close).toHaveBeenCalledTimes(1); @@ -358,6 +454,12 @@ describe("connectToAde embedded mode", () => { "--socket", socketPath, ]); + expect(spawnCall?.[2]).toMatchObject({ + env: expect.objectContaining({ + ADE_DEFAULT_ROLE: "cto", + ADE_RPC_SOCKET_PATH: socketPath, + }), + }); }); }); diff --git a/apps/ade-cli/src/tuiClient/connection.ts b/apps/ade-cli/src/tuiClient/connection.ts index 7b971057e..eec998538 100644 --- a/apps/ade-cli/src/tuiClient/connection.ts +++ b/apps/ade-cli/src/tuiClient/connection.ts @@ -1,4 +1,5 @@ import { spawn } from "node:child_process"; +import { createHash } from "node:crypto"; import fs from "node:fs"; import path from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; @@ -8,6 +9,7 @@ import { JsonRpcClient } from "./jsonRpcClient"; import type { AdeCodeConnection, ProjectLaunchContext } from "./types"; import type { AgentChatEventEnvelope } from "../../../desktop/src/shared/types/chat"; import type { BufferedEvent } from "../eventBuffer"; +import { resolveAdeDefaultRole } from "../runtimeRoles"; type RpcResponseEnvelope = | T @@ -26,12 +28,24 @@ type AdeActionHelpers = Pick< type InitializeResult = { runtimeInfo?: { multiProject?: boolean; + version?: string | null; + buildHash?: string | null; + defaultRole?: string | null; + projectRoot?: string | null; + pid?: number | null; }; capabilities?: { projects?: boolean; }; }; +type AttachedRuntimeInfo = { + buildHash: string | null; + defaultRole: string | null; + projectRoot: string | null; + pid: number | null; +}; + type ProjectRecord = { projectId: string; }; @@ -245,6 +259,22 @@ async function initialize(request: AdeRpcRequest): Promise { return result; } +async function initializeEmbeddedCto(request: AdeRpcRequest): Promise { + const previousRole = process.env.ADE_DEFAULT_ROLE; + const shouldInjectTrustedRole = previousRole?.trim() !== "cto"; + if (shouldInjectTrustedRole) { + process.env.ADE_DEFAULT_ROLE = "cto"; + } + try { + return await initialize(request); + } finally { + if (shouldInjectTrustedRole) { + if (previousRole === undefined) delete process.env.ADE_DEFAULT_ROLE; + else process.env.ADE_DEFAULT_ROLE = previousRole; + } + } +} + async function withTimeout( promise: Promise, timeoutMs: number, @@ -309,6 +339,16 @@ function withProjectId( return { projectId }; } +function isLikelyAdeCliEntrypoint(candidate: string): boolean { + const basename = path.basename(candidate).toLowerCase(); + return basename === "ade" + || basename === "ade-cli" + || basename === "cli.ts" + || basename === "cli.js" + || basename === "cli.cjs" + || basename === "cli.mjs"; +} + function resolveCliEntrypoint(): string | null { const moduleDir = path.dirname(fileURLToPath(import.meta.url)); const candidates = [ @@ -318,7 +358,9 @@ function resolveCliEntrypoint(): string | null { process.argv[1], ].filter( (candidate): candidate is string => - typeof candidate === "string" && candidate.trim().length > 0, + typeof candidate === "string" && + candidate.trim().length > 0 && + (candidate !== process.argv[1] || isLikelyAdeCliEntrypoint(candidate)), ); for (const candidate of candidates) { try { @@ -332,6 +374,82 @@ function resolveCliEntrypoint(): string | null { return null; } +class StaleAdeSocketError extends Error { + readonly pid: number | null; + + constructor(message: string, pid: number | null) { + super(message); + this.name = "StaleAdeSocketError"; + this.pid = pid; + } +} + +function readAttachedRuntimeInfo(result: InitializeResult): AttachedRuntimeInfo { + const runtimeInfo = result.runtimeInfo; + const pid = runtimeInfo?.pid; + return { + buildHash: + typeof runtimeInfo?.buildHash === "string" && runtimeInfo.buildHash.trim() + ? runtimeInfo.buildHash.trim() + : null, + defaultRole: + typeof runtimeInfo?.defaultRole === "string" && runtimeInfo.defaultRole.trim() + ? runtimeInfo.defaultRole.trim() + : null, + projectRoot: + typeof runtimeInfo?.projectRoot === "string" && runtimeInfo.projectRoot.trim() + ? path.resolve(runtimeInfo.projectRoot.trim()) + : null, + pid: + typeof pid === "number" && Number.isFinite(pid) && pid > 0 + ? Math.floor(pid) + : null, + }; +} + +function computeCliEntrypointBuildHash(): string | null { + const entrypoint = resolveCliEntrypoint(); + if (!entrypoint) return null; + try { + return createHash("sha256").update(fs.readFileSync(entrypoint)).digest("hex"); + } catch { + return null; + } +} + +function attachedRuntimeMismatchReason( + result: InitializeResult, + project: ProjectLaunchContext, +): { reason: string; pid: number | null } | null { + const info = readAttachedRuntimeInfo(result); + if (info.defaultRole !== "cto") { + return { + reason: `default role ${info.defaultRole ?? "missing"} is not cto`, + pid: info.pid, + }; + } + + const expectedBuildHash = computeCliEntrypointBuildHash(); + if (expectedBuildHash && info.buildHash !== expectedBuildHash) { + return { + reason: info.buildHash ? "build hash changed" : "build hash missing", + pid: info.pid, + }; + } + + if (!isMultiProjectRuntime(result)) { + const expectedProjectRoot = path.resolve(project.projectRoot); + if (info.projectRoot !== expectedProjectRoot) { + return { + reason: `project root ${info.projectRoot ?? "missing"} does not match ${expectedProjectRoot}`, + pid: info.pid, + }; + } + } + + return null; +} + function spawnDaemon(socketPath: string): boolean { const cliEntrypoint = resolveCliEntrypoint(); const daemonArgs = cliEntrypoint @@ -345,6 +463,7 @@ function spawnDaemon(socketPath: string): boolean { stdio: "ignore", env: { ...process.env, + ADE_DEFAULT_ROLE: "cto", ADE_RPC_SOCKET_PATH: socketPath, }, }, @@ -356,6 +475,7 @@ function spawnDaemon(socketPath: string): boolean { async function connectAttachedSocket(args: { socketPath: string; project: ProjectLaunchContext; + shutdownOnStale?: boolean; }): Promise { let client: JsonRpcClient | null = await JsonRpcClient.connect( args.socketPath, @@ -369,6 +489,16 @@ async function connectAttachedSocket(args: { 3000, "ADE RPC socket did not finish initialization.", ); + const runtimeMismatch = attachedRuntimeMismatchReason(initializeResult, args.project); + if (runtimeMismatch) { + if (args.shutdownOnStale) { + await connectedClient.request("shutdown").catch(() => null); + } + throw new StaleAdeSocketError( + `ADE RPC socket is stale (${runtimeMismatch.reason}).`, + runtimeMismatch.pid, + ); + } let request = rawRequest; const multiProjectRuntime = isMultiProjectRuntime(initializeResult); if (multiProjectRuntime) { @@ -495,6 +625,7 @@ async function connectAttachedSocketWithRetry(args: { project: ProjectLaunchContext; attempts: number; delayMs: number; + shutdownOnStale?: boolean; }): Promise { let lastError: unknown = null; for (let attempt = 0; attempt < Math.max(1, args.attempts); attempt += 1) { @@ -502,6 +633,7 @@ async function connectAttachedSocketWithRetry(args: { return await connectAttachedSocket({ socketPath: args.socketPath, project: args.project, + shutdownOnStale: args.shutdownOnStale, }); } catch (error) { lastError = error; @@ -558,6 +690,7 @@ export async function connectToAde(args: { project: args.project, attempts, delayMs: 200, + shutdownOnStale: true, }); try { if (!fs.existsSync(machineSocketPath)) { @@ -566,6 +699,9 @@ export async function connectToAde(args: { } return await tryDaemon(1); } catch (firstError) { + if (firstError instanceof StaleAdeSocketError) { + await new Promise((resolve) => setTimeout(resolve, 200)); + } try { const spawned = spawnDaemon(machineSocketPath); if (spawned) return await tryDaemon(25); @@ -635,7 +771,7 @@ export async function connectToAde(args: { params, })) as T; }; - await initialize(request); + await initializeEmbeddedCto(request); const chatEvents = typeof runtime.agentChatService?.subscribeToEvents === "function" ? runtime.agentChatService.subscribeToEvents.bind( diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 0d1485694..c3bea1b7d 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -31,9 +31,9 @@ "release:mac:local": "node ./scripts/release-mac-local.mjs", "typecheck": "tsc -p tsconfig.json --noEmit", "test": "vitest run", - "test:unit": "vitest run --project unit", - "test:integration": "vitest run --project integration", - "test:component": "vitest run --project component", + "test:unit": "vitest run", + "test:integration": "vitest run .integration.test --passWithNoTests", + "test:component": "vitest run src/renderer", "test:coverage": "vitest run --coverage", "test:orchestrator-smoke": "vitest run src/main/services/orchestrator/orchestratorSmoke.test.ts --reporter=verbose", "test:orchestrator-complex-mock": "vitest run src/main/services/orchestrator/orchestratorSmoke.test.ts -t \"complex mock prompt\" --reporter=verbose", diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index dde9e2f24..a57482c45 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -337,19 +337,6 @@ const defaultEnabledBackgroundTaskFlags = new Set([ "ADE_ENABLE_SYNC_INIT", ]); -function isBackgroundTaskEnabled(enableFlag?: string): boolean { - if (!devStabilityMode || enableAllBackgroundTasks) { - return true; - } - if (!enableFlag) { - return false; - } - return ( - process.env[enableFlag] === "1" || - defaultEnabledBackgroundTaskFlags.has(enableFlag) - ); -} - function readString(source: Record | null | undefined, key: string): string | undefined { const value = source?.[key]; return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; @@ -4028,9 +4015,6 @@ app.whenReady().then(async () => { const rpcHandler = createAdeRpcRequestHandler({ runtime: rpcRuntime, serverVersion: app.getVersion(), - onActionsListChanged: () => { - stop?.notify("ade/actions/list_changed", {}); - }, }); stop = startJsonRpcServer(rpcHandler, transport, { nonFatal: true }); const unsubscribeChatEvents = rpcRuntime.agentChatService?.subscribeToEvents((event) => { diff --git a/apps/desktop/src/main/services/ai/tools/universalTools.ts b/apps/desktop/src/main/services/ai/tools/universalTools.ts index 7912bc41e..cb3ec1e28 100644 --- a/apps/desktop/src/main/services/ai/tools/universalTools.ts +++ b/apps/desktop/src/main/services/ai/tools/universalTools.ts @@ -2476,7 +2476,6 @@ function createTodoWriteTool(args: { getItems?: () => TodoToolItem[]; onUpdate?: (items: TodoToolItem[]) => void; }) { - let todoItems = args.getItems?.() ?? []; return tool({ description: "Create or update the current task list for this chat. " + @@ -2489,7 +2488,6 @@ function createTodoWriteTool(args: { if (normalized == null) { return { updated: false, error: "Provide a todos array." }; } - todoItems = normalized; args.onUpdate?.(normalized); return { updated: true, diff --git a/apps/desktop/src/main/services/appControl/appControlService.test.ts b/apps/desktop/src/main/services/appControl/appControlService.test.ts index 3477e510d..568b90031 100644 --- a/apps/desktop/src/main/services/appControl/appControlService.test.ts +++ b/apps/desktop/src/main/services/appControl/appControlService.test.ts @@ -14,6 +14,7 @@ const mockState = vi.hoisted(() => ({ httpResponses: [] as Array>, sockets: [] as Array<{ url: string; sent: string[] }>, runtimeValues: [] as unknown[], + screenshotData: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=", })); vi.mock("node:http", async () => { @@ -74,7 +75,9 @@ vi.mock("ws", async () => { if (this.readyState === FakeWebSocket.OPEN) { const result = message.method === "Runtime.evaluate" ? { result: { value: mockState.runtimeValues.shift() ?? {} } } - : {}; + : message.method === "Page.captureScreenshot" + ? { data: mockState.screenshotData } + : {}; this.emit("message", Buffer.from(JSON.stringify({ id: message.id, result }))); } callback?.(); @@ -212,4 +215,25 @@ describe("appControlService", () => { expect(messages.filter((message) => message.method === "Runtime.evaluate")).toHaveLength(2); expect(messages.some((message) => message.method === "Input.dispatchMouseEvent")).toBe(false); }); + + it("clears the bounded capture timeout after successful screenshots", async () => { + const targetA = target("a"); + mockState.httpResponses.push([targetA]); + + const service = createAppControlService({ + projectRoot: "/tmp/project", + logger: createLogger(), + }); + + await service.connect({ cdpPort: 12345, force: true }); + const timerCountBeforeCapture = vi.getTimerCount(); + + const screenshotPromise = service.screenshot(); + await vi.advanceTimersByTimeAsync(100); + const screenshot = await screenshotPromise; + + expect(screenshot.width).toBe(1); + expect(screenshot.height).toBe(1); + expect(vi.getTimerCount()).toBe(timerCountBeforeCapture); + }); }); diff --git a/apps/desktop/src/main/services/appControl/appControlService.ts b/apps/desktop/src/main/services/appControl/appControlService.ts index 4b26b8fd3..a9b48b2ba 100644 --- a/apps/desktop/src/main/services/appControl/appControlService.ts +++ b/apps/desktop/src/main/services/appControl/appControlService.ts @@ -1548,13 +1548,17 @@ export function createAppControlService(args: CreateAppControlServiceArgs) { // A backgrounded or slow renderer can sit on this for 15s otherwise, and // the caller (getSnapshot) almost always has the option to fall back to a // live frame on the next tick. + let captureTimeoutHandle: ReturnType | null = null; const captureTimeout = new Promise((_, reject) => { - setTimeout(() => reject(new Error("Page.captureScreenshot timed out after 3000ms.")), 3000); + captureTimeoutHandle = setTimeout(() => reject(new Error("Page.captureScreenshot timed out after 3000ms.")), 3000); + captureTimeoutHandle.unref?.(); }); const response = await Promise.race([ client.send("Page.captureScreenshot", { format: "png", fromSurface: true }), captureTimeout, - ]); + ]).finally(() => { + if (captureTimeoutHandle) clearTimeout(captureTimeoutHandle); + }); const buffer = Buffer.from(response.data, "base64"); const dimensions = pngDimensions(buffer) ?? { width: 0, height: 0 }; return { diff --git a/apps/desktop/src/main/services/chat/agentChatService.suggestLaneName.test.ts b/apps/desktop/src/main/services/chat/agentChatService.suggestLaneName.test.ts deleted file mode 100644 index 9981f7580..000000000 --- a/apps/desktop/src/main/services/chat/agentChatService.suggestLaneName.test.ts +++ /dev/null @@ -1,727 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - -vi.mock("@opencode-ai/sdk", () => ({ - createOpencodeServer: vi.fn(async () => ({ - url: "http://mock-opencode-server", - close: vi.fn(), - })), - createOpencodeClient: vi.fn(() => ({})), -})); - -// --------------------------------------------------------------------------- -// vi.hoisted mock state (mirrored from agentChatService.test.ts) -// --------------------------------------------------------------------------- -const mockState = vi.hoisted(() => ({ - sessions: new Map(), - uuidCounter: 0, - codexThreadCounter: 0, - codexTurnCounter: 0, - codexRequestPayloads: [] as Array>, - codexCollaborationModes: [{ mode: "default" }, { mode: "plan" }] as Array | string>, - codexLineHandler: null as ((line: string) => void) | null, - emitCodexPayload(payload: Record) { - mockState.codexLineHandler?.(JSON.stringify(payload)); - }, - nextUuid: () => { - mockState.uuidCounter += 1; - return `test-uuid-${mockState.uuidCounter}`; - }, -})); - -// --------------------------------------------------------------------------- -// vi.mock — external dependencies (same as agentChatService.test.ts) -// --------------------------------------------------------------------------- - -vi.mock("node:crypto", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - randomUUID: () => mockState.nextUuid(), - }; -}); - -vi.mock("node:child_process", () => ({ - spawn: vi.fn(() => { - const proc: any = { - stdin: { - writable: true, - write: vi.fn(() => true), - end: vi.fn(), - }, - stdout: { on: vi.fn() }, - stderr: { on: vi.fn() }, - on: vi.fn(), - kill: vi.fn(), - pid: 99999, - }; - return proc; - }), -})); - -vi.mock("node:readline", () => ({ - default: { - createInterface: vi.fn(() => ({ - on: vi.fn(), - close: vi.fn(), - [Symbol.asyncIterator]: vi.fn(), - })), - }, - createInterface: vi.fn(() => ({ - on: vi.fn(), - close: vi.fn(), - [Symbol.asyncIterator]: vi.fn(), - })), -})); - - -vi.mock("@anthropic-ai/claude-agent-sdk", () => ({ - getSessionInfo: vi.fn(), - getSessionMessages: vi.fn(), - listSessions: vi.fn(), - query: vi.fn(), - renameSession: vi.fn(async () => undefined), - startup: vi.fn(), - tagSession: vi.fn(async () => undefined), -})); - -vi.mock("@modelcontextprotocol/sdk/client/index.js", () => { - const Client = vi.fn().mockImplementation(() => ({ - connect: vi.fn(async () => {}), - listTools: vi.fn(async () => ({ tools: [] })), - callTool: vi.fn(async () => ({ content: [{ type: "text", text: "" }] })), - close: vi.fn(), - })); - return { Client }; -}); - -vi.mock("@modelcontextprotocol/sdk/client/stdio.js", () => { - const StdioClientTransport = vi.fn().mockImplementation(() => ({})); - return { StdioClientTransport }; -}); - -vi.mock("../ai/codexExecutable", () => ({ - resolveCodexExecutable: vi.fn(() => ({ path: "codex", source: "fallback-command" })), -})); - -vi.mock("../opencode/openCodeRuntime", () => ({ - buildOpenCodePromptParts: vi.fn(({ prompt, files = [] }: { prompt: string; files?: Array> }) => [ - { type: "text", text: prompt }, - ...files, - ]), - mapPermissionModeToOpenCodeAgent: vi.fn((mode: string) => { - if (mode === "plan") return "ade-plan"; - if (mode === "full-auto") return "ade-full-auto"; - return "ade-edit"; - }), - resolveOpenCodeModelSelection: vi.fn((descriptor: Record) => ({ - providerID: String(descriptor.family ?? "openai"), - modelID: String(descriptor.providerModelId ?? descriptor.id ?? "model"), - })), - runOpenCodeTextPrompt: vi.fn(), - startOpenCodeSession: vi.fn(async () => ({ - sessionId: "opencode-stub", - events: async function* () { /* noop */ }, - abort: vi.fn(), - })), - openCodeEventStream: vi.fn(), -})); - -vi.mock("../ai/tools/universalTools", () => ({ - createUniversalToolSet: vi.fn((): Record => ({ - readFile: { description: "stub", parameters: { type: "object", properties: {} }, execute: vi.fn() }, - grep: { description: "stub", parameters: { type: "object", properties: {} }, execute: vi.fn() }, - bash: { description: "stub", parameters: { type: "object", properties: {} }, execute: vi.fn() }, - })), -})); - -vi.mock("../ai/tools/workflowTools", () => ({ - createWorkflowTools: vi.fn(() => []), -})); - -vi.mock("../ai/tools/linearTools", () => ({ - createLinearTools: vi.fn(() => []), -})); - -vi.mock("../ai/tools/ctoOperatorTools", () => ({ - createCtoOperatorTools: vi.fn(() => []), -})); - -vi.mock("../ai/tools/systemPrompt", () => ({ - buildCodingAgentSystemPrompt: vi.fn(() => "system prompt"), - composeSystemPrompt: vi.fn(() => "system prompt"), -})); - -vi.mock("../ai/claudeModelUtils", () => ({ - resolveClaudeCliModel: vi.fn((model: string) => model), -})); - -vi.mock("../ai/providerRuntimeHealth", () => ({ - getProviderRuntimeHealth: vi.fn(() => null), - reportProviderRuntimeAuthFailure: vi.fn(), - reportProviderRuntimeFailure: vi.fn(), - reportProviderRuntimeReady: vi.fn(), -})); - -vi.mock("../ai/claudeRuntimeProbe", () => ({ - CLAUDE_RUNTIME_AUTH_ERROR: "Claude authentication failed", - isClaudeRuntimeAuthError: vi.fn(() => false), -})); - -vi.mock("../ai/claudeCodeExecutable", () => ({ - resolveClaudeCodeExecutable: vi.fn(() => ({ path: "/usr/local/bin/claude", source: "path" })), -})); - -vi.mock("../ai/authDetector", () => ({ - detectAllAuth: vi.fn(async () => []), -})); - -vi.mock("../git/git", () => ({ - runGit: vi.fn(async () => ({ stdout: "", stderr: "", exitCode: 0 })), -})); - -vi.mock("../orchestrator/unifiedOrchestratorAdapter", () => ({ - resolveAdeMcpServerLaunch: vi.fn(() => ({ - command: "node", - cmdArgs: [], - env: {}, - })), - resolveUnifiedRuntimeRoot: vi.fn(() => process.cwd()), -})); - -vi.mock("../orchestrator/permissionMapping", () => ({ - mapPermissionToClaude: vi.fn(() => "plan"), - mapPermissionToCodex: vi.fn(() => ({ - approvalPolicy: "on-request", - sandbox: "read-only", - })), -})); - -vi.mock("../computerUse/proofObserver", () => ({ - createProofObserver: vi.fn(() => ({ - observe: vi.fn(), - flush: vi.fn(), - })), -})); - -vi.mock("../../../shared/chatTranscript", () => ({ - parseAgentChatTranscript: vi.fn(() => []), -})); - -// --------------------------------------------------------------------------- -// Import system under test (after mocks) -// --------------------------------------------------------------------------- -import { createAgentChatService } from "./agentChatService"; -import { detectAllAuth } from "../ai/authDetector"; - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -let tmpRoot: string; - -function createLogger() { - return { - debug: vi.fn(), - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - } as const; -} - -function createMockLaneService() { - const lanes = [ - { id: "lane-1", name: "Primary", laneType: "primary", branchRef: "feature/primary", worktreePath: tmpRoot }, - ]; - return { - getLaneBaseAndBranch: vi.fn(() => ({ - baseRef: "main", - branchRef: "feature/primary", - worktreePath: tmpRoot, - laneType: "primary", - })), - list: vi.fn(async () => lanes), - ensurePrimaryLane: vi.fn(async () => {}), - create: vi.fn(async ({ name }: { name: string }) => { - const lane = { - id: `lane-${lanes.length + 1}`, - name, - description: null, - laneType: "feature", - branchRef: `feature/generated-lane-${lanes.length + 1}`, - worktreePath: path.join(tmpRoot, `generated-lane-${lanes.length + 1}`), - parentLaneId: "lane-1", - }; - fs.mkdirSync(lane.worktreePath, { recursive: true }); - lanes.push(lane); - return lane; - }), - getLane: vi.fn((laneId: string) => lanes.find((l) => l.id === laneId) ?? null), - } as any; -} - -function createMockSessionService() { - const sessions = mockState.sessions; - return { - create: vi.fn((args: any) => { - sessions.set(args.sessionId, { - id: args.sessionId, - laneId: args.laneId, - ptyId: args.ptyId ?? null, - title: args.title ?? "Chat", - toolType: args.toolType ?? "ai-chat", - status: "running", - startedAt: args.startedAt ?? new Date().toISOString(), - endedAt: null, - transcriptPath: args.transcriptPath ?? "", - resumeCommand: args.resumeCommand ?? null, - lastOutputPreview: null, - summary: null, - goal: null, - headShaStart: null, - headShaEnd: null, - }); - }), - get: vi.fn((sessionId: string) => sessions.get(sessionId) ?? null), - list: vi.fn(() => Array.from(sessions.values())), - reopen: vi.fn(), - end: vi.fn(), - updateMeta: vi.fn(), - setHeadShaStart: vi.fn(), - setHeadShaEnd: vi.fn(), - setLastOutputPreview: vi.fn(), - setSummary: vi.fn(), - setResumeCommand: vi.fn(), - } as any; -} - -function createMockProjectConfigService(options: { titleGenerationEnabled?: boolean; titleModelId?: string } = {}) { - const titleOptions: Record = {}; - if (typeof options.titleGenerationEnabled === "boolean") titleOptions.enabled = options.titleGenerationEnabled; - if (options.titleModelId) titleOptions.modelId = options.titleModelId; - const sessionIntelligence = Object.keys(titleOptions).length - ? { titles: titleOptions } - : {}; - return { - get: vi.fn(() => ({ - effective: { - ai: { - permissions: { - cli: { mode: "edit" }, - inProcess: { mode: "edit" }, - }, - chat: {}, - sessionIntelligence, - }, - }, - })), - getAll: vi.fn(() => ({})), - set: vi.fn(), - } as any; -} - -function createMockIssueInventoryService() { - const now = new Date().toISOString(); - return { - syncFromPrData: vi.fn((prId: string) => ({ - prId, - items: [], - convergence: { - currentRound: 0, - maxRounds: 5, - issuesPerRound: [], - totalNew: 0, - totalFixed: 0, - totalDismissed: 0, - totalEscalated: 0, - totalSentToAgent: 0, - isConverging: false, - canAutoAdvance: false, - }, - runtime: { - prId, - autoConvergeEnabled: false, - status: "idle", - pollerStatus: "idle", - currentRound: 0, - activeSessionId: null, - activeLaneId: null, - activeHref: null, - pauseReason: null, - errorMessage: null, - lastStartedAt: null, - lastPolledAt: null, - lastPausedAt: null, - lastStoppedAt: null, - createdAt: now, - updatedAt: now, - }, - })), - getInventory: vi.fn(), - getNewItems: vi.fn(() => []), - markSentToAgent: vi.fn(), - markFixed: vi.fn(), - markDismissed: vi.fn(), - markEscalated: vi.fn(), - getConvergenceStatus: vi.fn(() => ({ - currentRound: 0, - maxRounds: 5, - issuesPerRound: [], - totalNew: 0, - totalFixed: 0, - totalDismissed: 0, - totalEscalated: 0, - totalSentToAgent: 0, - isConverging: false, - canAutoAdvance: false, - })), - resetInventory: vi.fn(), - getConvergenceRuntime: vi.fn(() => null), - saveConvergenceRuntime: vi.fn(), - resetConvergenceRuntime: vi.fn(), - getPipelineSettings: vi.fn(() => ({ - maxRounds: 5, - autoMerge: false, - mergeMethod: "repo_default", - onRebaseNeeded: "pause", - })), - savePipelineSettings: vi.fn(), - deletePipelineSettings: vi.fn(), - } as any; -} - -function createService(options: { titleGenerationEnabled?: boolean; titleModelId?: string } = {}) { - const logger = createLogger(); - const laneService = createMockLaneService(); - const sessionService = createMockSessionService(); - const projectConfigService = createMockProjectConfigService(options); - const issueInventoryService = createMockIssueInventoryService(); - const transcriptsDir = path.join(tmpRoot, "transcripts"); - fs.mkdirSync(transcriptsDir, { recursive: true }); - - const aiIntegrationService = { - onLinearConnectionChange: vi.fn(), - getLinearProjectMapping: vi.fn(() => null), - setLinearProjectMapping: vi.fn(), - getLinearConnection: vi.fn(() => null), - setLinearConnection: vi.fn(), - getSettings: vi.fn(() => ({})), - updateSettings: vi.fn(), - getMode: vi.fn(() => "subscription"), - summarizeTerminal: vi.fn(async () => ({ text: "Parallel Task" })), - }; - - const service = createAgentChatService({ - projectRoot: tmpRoot, - transcriptsDir, - laneService, - sessionService, - projectConfigService, - aiIntegrationService: aiIntegrationService as any, - issueInventoryService, - logger: logger as any, - appVersion: "0.0.1-test", - getDirtyFileTextForPath: () => undefined, - }); - - return { service, logger, aiIntegrationService }; -} - -// --------------------------------------------------------------------------- -// Lifecycle -// --------------------------------------------------------------------------- - -beforeEach(() => { - tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-lane-name-test-")); - fs.mkdirSync(path.join(tmpRoot, ".ade", "cache", "chat-sessions"), { recursive: true }); - fs.mkdirSync(path.join(tmpRoot, ".ade", "transcripts", "chat"), { recursive: true }); - mockState.sessions.clear(); - mockState.uuidCounter = 0; - vi.mocked(detectAllAuth).mockResolvedValue([]); -}); - -afterEach(() => { - vi.restoreAllMocks(); - try { - fs.rmSync(tmpRoot, { recursive: true, force: true }); - } catch { /* ignore */ } -}); - -// --------------------------------------------------------------------------- -// Tests — suggestLaneNameFromPrompt -// --------------------------------------------------------------------------- - -describe("suggestLaneNameFromPrompt", () => { - it("returns 'parallel-task' for an empty prompt", async () => { - const { service } = createService(); - const result = await service.suggestLaneNameFromPrompt({ - prompt: "", - modelId: "anthropic/claude-haiku-4-5", - laneId: "lane-1", - }); - expect(result).toBe("parallel-task"); - }); - - it("returns 'parallel-task' for a whitespace-only prompt", async () => { - const { service } = createService(); - const result = await service.suggestLaneNameFromPrompt({ - prompt: " \t\n ", - modelId: "anthropic/claude-haiku-4-5", - laneId: "lane-1", - }); - expect(result).toBe("parallel-task"); - }); - - it("returns a slug from a short prompt via fallback (no auth = no models)", async () => { - // detectAllAuth returns [] so getRegistryModels returns [] → fallback path - const { service } = createService(); - const result = await service.suggestLaneNameFromPrompt({ - prompt: "Fix the login bug", - modelId: "anthropic/claude-haiku-4-5", - laneId: "lane-1", - }); - expect(result).toBe("fix-the-login-bug"); - }); - - it("takes only first 4 words of a long prompt", async () => { - const { service } = createService(); - const result = await service.suggestLaneNameFromPrompt({ - prompt: "Refactor the authentication service to use JWT tokens", - modelId: "anthropic/claude-haiku-4-5", - laneId: "lane-1", - }); - expect(result).toBe("refactor-the-authentication-service"); - }); - - it("strips special characters from the prompt slug", async () => { - const { service } = createService(); - const result = await service.suggestLaneNameFromPrompt({ - prompt: "Fix bug #123 in module!", - modelId: "anthropic/claude-haiku-4-5", - laneId: "lane-1", - }); - // "Fix" "bug" "#123" "in" → "fix-bug--123-in" → strip non-alphanumeric except hyphens → "fix-bug--123-in" → collapse hyphens → "fix-bug-123-in" - expect(result).toBe("fix-bug-123-in"); - }); - - it("truncates the fallback slug to 48 characters", async () => { - const { service } = createService(); - const result = await service.suggestLaneNameFromPrompt({ - prompt: "superlongwordthatexceedsfortyeightcharacterswhenalone secondword thirdword fourthword", - modelId: "anthropic/claude-haiku-4-5", - laneId: "lane-1", - }); - expect(result.length).toBeLessThanOrEqual(48); - }); - - it("collapses multiple whitespace in the prompt", async () => { - const { service } = createService(); - const result = await service.suggestLaneNameFromPrompt({ - prompt: " fix the bug now please ", - modelId: "anthropic/claude-haiku-4-5", - laneId: "lane-1", - }); - // First 4 words: fix, the, bug, now - expect(result).toBe("fix-the-bug-now"); - }); - - it("falls back when the model runtime throws an error", async () => { - // Provide auth so models are available, but make the runtime throw - vi.mocked(detectAllAuth).mockResolvedValue([ - { type: "cli-subscription" as any, cli: "claude", authenticated: true, path: "/usr/bin/claude", verified: true }, - ]); - - const { service, logger, aiIntegrationService } = createService(); - vi.mocked(aiIntegrationService.summarizeTerminal).mockRejectedValue(new Error("API rate limited")); - const result = await service.suggestLaneNameFromPrompt({ - prompt: "Write a test suite", - modelId: "anthropic/claude-haiku-4-5", - laneId: "lane-1", - }); - - // Should fall back to slug generation - expect(result).toBe("write-a-test-suite"); - // Should have logged a warning - expect(logger.warn).toHaveBeenCalledWith( - "agent_chat.suggest_lane_name_failed", - expect.objectContaining({ error: "API rate limited" }), - ); - }); - - it("keeps the prompt fallback readable while adding the temporary suffix when title generation is disabled", async () => { - vi.mocked(detectAllAuth).mockResolvedValue([ - { type: "cli-subscription" as any, cli: "claude", authenticated: true, path: "/usr/bin/claude", verified: true }, - ]); - - const { service, aiIntegrationService } = createService({ titleGenerationEnabled: false }); - const result = await service.suggestLaneNameFromPrompt({ - prompt: "Fix the authentication login failure in the dashboard", - modelId: "anthropic/claude-haiku-4-5", - laneId: "lane-1", - fallbackName: "chat-20260514-010203", - }); - - expect(result).toBe("fix-the-authentication-login-20260514-010203"); - expect(aiIntegrationService.summarizeTerminal).not.toHaveBeenCalled(); - }); - - it("uses the explicit fallback directly when the prompt fallback is generic", async () => { - const { service } = createService(); - const result = await service.suggestLaneNameFromPrompt({ - prompt: "!!!", - modelId: "anthropic/claude-haiku-4-5", - laneId: "lane-1", - fallbackName: "chat-20260514-010203", - }); - - expect(result).toBe("chat-20260514-010203"); - }); - - it("uses AI-generated name when the model runtime succeeds", async () => { - vi.mocked(detectAllAuth).mockResolvedValue([ - { type: "cli-subscription" as any, cli: "claude", authenticated: true, path: "/usr/bin/claude", verified: true }, - ]); - const { service, aiIntegrationService } = createService(); - vi.mocked(aiIntegrationService.summarizeTerminal).mockResolvedValue({ - text: "Login Bug Fix", - inputTokens: 10, - outputTokens: 5, - } as any); - - const result = await service.suggestLaneNameFromPrompt({ - prompt: "Fix the authentication login failure in the dashboard", - modelId: "anthropic/claude-haiku-4-5", - laneId: "lane-1", - }); - - // normalizeLaneBase: lowercase, strip non-alnum/space/hyphen, trim, spaces→hyphens, collapse hyphens, slice(0,60) - expect(result).toBe("login-bug-fix"); - }); - - it("retries the configured title model when the requested Codex model cannot name the lane", async () => { - vi.mocked(detectAllAuth).mockResolvedValue([ - { type: "cli-subscription" as any, cli: "codex", authenticated: true, path: "/usr/bin/codex", verified: true }, - ]); - const { service, aiIntegrationService } = createService({ titleModelId: "openai/gpt-5.4-mini" }); - vi.mocked(aiIntegrationService.summarizeTerminal) - .mockRejectedValueOnce(new Error("GPT-5.5 unavailable for title task")) - .mockResolvedValueOnce({ - text: "Auto Create Lane Fix", - inputTokens: 10, - outputTokens: 5, - } as any); - - const result = await service.suggestLaneNameFromPrompt({ - prompt: "Fix auto create lane routing and naming", - modelId: "openai/gpt-5.5", - laneId: "lane-1", - fallbackName: "chat-20260514-010203", - }); - - expect(result).toBe("auto-create-lane-fix"); - expect(aiIntegrationService.summarizeTerminal).toHaveBeenNthCalledWith(1, expect.objectContaining({ - model: "openai/gpt-5.5", - taskType: "session_title", - })); - expect(aiIntegrationService.summarizeTerminal).toHaveBeenNthCalledWith(2, expect.objectContaining({ - model: "openai/gpt-5.4-mini", - taskType: "session_title", - })); - }); - - it("normalizes AI-generated name: strips special chars and lowercases", async () => { - vi.mocked(detectAllAuth).mockResolvedValue([ - { type: "cli-subscription" as any, cli: "claude", authenticated: true, path: "/usr/bin/claude", verified: true }, - ]); - const { service, aiIntegrationService } = createService(); - vi.mocked(aiIntegrationService.summarizeTerminal).mockResolvedValue({ - text: "JWT Auth Refactor!", - inputTokens: 10, - outputTokens: 5, - } as any); - - const result = await service.suggestLaneNameFromPrompt({ - prompt: "Refactor auth to use JWT", - modelId: "anthropic/claude-haiku-4-5", - laneId: "lane-1", - }); - - // "JWT Auth Refactor!" → sanitizeAutoTitle → "JWT Auth Refactor" → normalizeLaneBase → "jwt-auth-refactor" - expect(result).toBe("jwt-auth-refactor"); - }); - - it("normalizes AI-generated name: truncates to 60 characters", async () => { - vi.mocked(detectAllAuth).mockResolvedValue([ - { type: "cli-subscription" as any, cli: "claude", authenticated: true, path: "/usr/bin/claude", verified: true }, - ]); - const longName = "a".repeat(70); - const { service, aiIntegrationService } = createService(); - vi.mocked(aiIntegrationService.summarizeTerminal).mockResolvedValue({ - text: longName, - inputTokens: 10, - outputTokens: 5, - } as any); - - const result = await service.suggestLaneNameFromPrompt({ - prompt: "Do a very long task", - modelId: "anthropic/claude-haiku-4-5", - laneId: "lane-1", - }); - - expect(result.length).toBeLessThanOrEqual(60); - }); - - it("trims edge hyphens after AI title truncation", async () => { - vi.mocked(detectAllAuth).mockResolvedValue([ - { type: "cli-subscription" as any, cli: "claude", authenticated: true, path: "/usr/bin/claude", verified: true }, - ]); - const { service, aiIntegrationService } = createService(); - vi.mocked(aiIntegrationService.summarizeTerminal).mockResolvedValue({ - text: `${"a".repeat(55)}- tail`, - inputTokens: 10, - outputTokens: 5, - } as any); - - const result = await service.suggestLaneNameFromPrompt({ - prompt: "Trim the generated lane name", - modelId: "anthropic/claude-haiku-4-5", - laneId: "lane-1", - }); - - expect(result).toBe("a".repeat(55)); - }); - - it("falls back when AI returns empty text after sanitization", async () => { - vi.mocked(detectAllAuth).mockResolvedValue([ - { type: "cli-subscription" as any, cli: "claude", authenticated: true, path: "/usr/bin/claude", verified: true }, - ]); - // Return only emoji/special chars that sanitizeAutoTitle will strip - const { service, aiIntegrationService } = createService(); - vi.mocked(aiIntegrationService.summarizeTerminal).mockResolvedValue({ - text: "!!!", - inputTokens: 10, - outputTokens: 5, - } as any); - - const result = await service.suggestLaneNameFromPrompt({ - prompt: "Something useful", - modelId: "anthropic/claude-haiku-4-5", - laneId: "lane-1", - }); - - // sanitizeAutoTitle strips these → empty → fallback - expect(result).toBe("something-useful"); - }); - - it("handles null/undefined args fields gracefully", async () => { - const { service } = createService(); - const result = await service.suggestLaneNameFromPrompt({ - prompt: null as any, - modelId: null as any, - laneId: null as any, - }); - expect(result).toBe("parallel-task"); - }); -}); diff --git a/apps/desktop/src/main/services/chat/agentChatService.test.ts b/apps/desktop/src/main/services/chat/agentChatService.test.ts index 8f5df006e..ee6fbb08c 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.test.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.test.ts @@ -15411,3 +15411,297 @@ describe("createAgentChatService", () => { }); }); }); + +describe("suggestLaneNameFromPrompt", () => { + function createProjectConfigServiceWithTitleOptions( + options: { titleGenerationEnabled?: boolean; titleModelId?: string } = {}, + ) { + const titleOptions: Record = {}; + if (typeof options.titleGenerationEnabled === "boolean") titleOptions.enabled = options.titleGenerationEnabled; + if (options.titleModelId) titleOptions.modelId = options.titleModelId; + const sessionIntelligence = Object.keys(titleOptions).length ? { titles: titleOptions } : {}; + return { + get: vi.fn(() => ({ + effective: { + ai: { + permissions: { + cli: { mode: "edit" }, + inProcess: { mode: "edit" }, + }, + chat: {}, + sessionIntelligence, + }, + }, + })), + getAll: vi.fn(() => ({})), + set: vi.fn(), + } as any; + } + + function createSuggestService(options: { titleGenerationEnabled?: boolean; titleModelId?: string } = {}) { + return createService({ + projectConfigService: createProjectConfigServiceWithTitleOptions(options), + }); + } + + it("returns 'parallel-task' for an empty prompt", async () => { + const { service } = createSuggestService(); + const result = await service.suggestLaneNameFromPrompt({ + prompt: "", + modelId: "anthropic/claude-haiku-4-5", + laneId: "lane-1", + }); + expect(result).toBe("parallel-task"); + }); + + it("returns 'parallel-task' for a whitespace-only prompt", async () => { + const { service } = createSuggestService(); + const result = await service.suggestLaneNameFromPrompt({ + prompt: " \t\n ", + modelId: "anthropic/claude-haiku-4-5", + laneId: "lane-1", + }); + expect(result).toBe("parallel-task"); + }); + + it("returns a slug from a short prompt via fallback (no auth = no models)", async () => { + const { service } = createSuggestService(); + const result = await service.suggestLaneNameFromPrompt({ + prompt: "Fix the login bug", + modelId: "anthropic/claude-haiku-4-5", + laneId: "lane-1", + }); + expect(result).toBe("fix-the-login-bug"); + }); + + it("takes only first 4 words of a long prompt", async () => { + const { service } = createSuggestService(); + const result = await service.suggestLaneNameFromPrompt({ + prompt: "Refactor the authentication service to use JWT tokens", + modelId: "anthropic/claude-haiku-4-5", + laneId: "lane-1", + }); + expect(result).toBe("refactor-the-authentication-service"); + }); + + it("strips special characters from the prompt slug", async () => { + const { service } = createSuggestService(); + const result = await service.suggestLaneNameFromPrompt({ + prompt: "Fix bug #123 in module!", + modelId: "anthropic/claude-haiku-4-5", + laneId: "lane-1", + }); + expect(result).toBe("fix-bug-123-in"); + }); + + it("truncates the fallback slug to 48 characters", async () => { + const { service } = createSuggestService(); + const result = await service.suggestLaneNameFromPrompt({ + prompt: "superlongwordthatexceedsfortyeightcharacterswhenalone secondword thirdword fourthword", + modelId: "anthropic/claude-haiku-4-5", + laneId: "lane-1", + }); + expect(result.length).toBeLessThanOrEqual(48); + }); + + it("collapses multiple whitespace in the prompt", async () => { + const { service } = createSuggestService(); + const result = await service.suggestLaneNameFromPrompt({ + prompt: " fix the bug now please ", + modelId: "anthropic/claude-haiku-4-5", + laneId: "lane-1", + }); + expect(result).toBe("fix-the-bug-now"); + }); + + it("falls back when the model runtime throws an error", async () => { + vi.mocked(detectAllAuth).mockResolvedValue([ + { type: "cli-subscription" as any, cli: "claude", authenticated: true, path: "/usr/bin/claude", verified: true }, + ]); + + const { service, logger, aiIntegrationService } = createSuggestService(); + vi.mocked(aiIntegrationService.summarizeTerminal).mockRejectedValue(new Error("API rate limited")); + const result = await service.suggestLaneNameFromPrompt({ + prompt: "Write a test suite", + modelId: "anthropic/claude-haiku-4-5", + laneId: "lane-1", + }); + + expect(result).toBe("write-a-test-suite"); + expect(logger.warn).toHaveBeenCalledWith( + "agent_chat.suggest_lane_name_failed", + expect.objectContaining({ error: "API rate limited" }), + ); + }); + + it("keeps the prompt fallback readable while adding the temporary suffix when title generation is disabled", async () => { + vi.mocked(detectAllAuth).mockResolvedValue([ + { type: "cli-subscription" as any, cli: "claude", authenticated: true, path: "/usr/bin/claude", verified: true }, + ]); + + const { service, aiIntegrationService } = createSuggestService({ titleGenerationEnabled: false }); + const result = await service.suggestLaneNameFromPrompt({ + prompt: "Fix the authentication login failure in the dashboard", + modelId: "anthropic/claude-haiku-4-5", + laneId: "lane-1", + fallbackName: "chat-20260514-010203", + }); + + expect(result).toBe("fix-the-authentication-login-20260514-010203"); + expect(aiIntegrationService.summarizeTerminal).not.toHaveBeenCalled(); + }); + + it("uses the explicit fallback directly when the prompt fallback is generic", async () => { + const { service } = createSuggestService(); + const result = await service.suggestLaneNameFromPrompt({ + prompt: "!!!", + modelId: "anthropic/claude-haiku-4-5", + laneId: "lane-1", + fallbackName: "chat-20260514-010203", + }); + + expect(result).toBe("chat-20260514-010203"); + }); + + it("uses AI-generated name when the model runtime succeeds", async () => { + vi.mocked(detectAllAuth).mockResolvedValue([ + { type: "cli-subscription" as any, cli: "claude", authenticated: true, path: "/usr/bin/claude", verified: true }, + ]); + const { service, aiIntegrationService } = createSuggestService(); + vi.mocked(aiIntegrationService.summarizeTerminal).mockResolvedValue({ + text: "Login Bug Fix", + inputTokens: 10, + outputTokens: 5, + } as any); + + const result = await service.suggestLaneNameFromPrompt({ + prompt: "Fix the authentication login failure in the dashboard", + modelId: "anthropic/claude-haiku-4-5", + laneId: "lane-1", + }); + + expect(result).toBe("login-bug-fix"); + }); + + it("retries the configured title model when the requested Codex model cannot name the lane", async () => { + vi.mocked(detectAllAuth).mockResolvedValue([ + { type: "cli-subscription" as any, cli: "codex", authenticated: true, path: "/usr/bin/codex", verified: true }, + ]); + const { service, aiIntegrationService } = createSuggestService({ titleModelId: "openai/gpt-5.4-mini" }); + vi.mocked(aiIntegrationService.summarizeTerminal) + .mockRejectedValueOnce(new Error("GPT-5.5 unavailable for title task")) + .mockResolvedValueOnce({ + text: "Auto Create Lane Fix", + inputTokens: 10, + outputTokens: 5, + } as any); + + const result = await service.suggestLaneNameFromPrompt({ + prompt: "Fix auto create lane routing and naming", + modelId: "openai/gpt-5.5", + laneId: "lane-1", + fallbackName: "chat-20260514-010203", + }); + + expect(result).toBe("auto-create-lane-fix"); + expect(aiIntegrationService.summarizeTerminal).toHaveBeenNthCalledWith(1, expect.objectContaining({ + model: "openai/gpt-5.5", + taskType: "session_title", + })); + expect(aiIntegrationService.summarizeTerminal).toHaveBeenNthCalledWith(2, expect.objectContaining({ + model: "openai/gpt-5.4-mini", + taskType: "session_title", + })); + }); + + it("normalizes AI-generated name: strips special chars and lowercases", async () => { + vi.mocked(detectAllAuth).mockResolvedValue([ + { type: "cli-subscription" as any, cli: "claude", authenticated: true, path: "/usr/bin/claude", verified: true }, + ]); + const { service, aiIntegrationService } = createSuggestService(); + vi.mocked(aiIntegrationService.summarizeTerminal).mockResolvedValue({ + text: "JWT Auth Refactor!", + inputTokens: 10, + outputTokens: 5, + } as any); + + const result = await service.suggestLaneNameFromPrompt({ + prompt: "Refactor auth to use JWT", + modelId: "anthropic/claude-haiku-4-5", + laneId: "lane-1", + }); + + expect(result).toBe("jwt-auth-refactor"); + }); + + it("normalizes AI-generated name: truncates to 60 characters", async () => { + vi.mocked(detectAllAuth).mockResolvedValue([ + { type: "cli-subscription" as any, cli: "claude", authenticated: true, path: "/usr/bin/claude", verified: true }, + ]); + const longName = "a".repeat(70); + const { service, aiIntegrationService } = createSuggestService(); + vi.mocked(aiIntegrationService.summarizeTerminal).mockResolvedValue({ + text: longName, + inputTokens: 10, + outputTokens: 5, + } as any); + + const result = await service.suggestLaneNameFromPrompt({ + prompt: "Do a very long task", + modelId: "anthropic/claude-haiku-4-5", + laneId: "lane-1", + }); + + expect(result.length).toBeLessThanOrEqual(60); + }); + + it("trims edge hyphens after AI title truncation", async () => { + vi.mocked(detectAllAuth).mockResolvedValue([ + { type: "cli-subscription" as any, cli: "claude", authenticated: true, path: "/usr/bin/claude", verified: true }, + ]); + const { service, aiIntegrationService } = createSuggestService(); + vi.mocked(aiIntegrationService.summarizeTerminal).mockResolvedValue({ + text: `${"a".repeat(55)}- tail`, + inputTokens: 10, + outputTokens: 5, + } as any); + + const result = await service.suggestLaneNameFromPrompt({ + prompt: "Trim the generated lane name", + modelId: "anthropic/claude-haiku-4-5", + laneId: "lane-1", + }); + + expect(result).toBe("a".repeat(55)); + }); + + it("falls back when AI returns empty text after sanitization", async () => { + vi.mocked(detectAllAuth).mockResolvedValue([ + { type: "cli-subscription" as any, cli: "claude", authenticated: true, path: "/usr/bin/claude", verified: true }, + ]); + const { service, aiIntegrationService } = createSuggestService(); + vi.mocked(aiIntegrationService.summarizeTerminal).mockResolvedValue({ + text: "!!!", + inputTokens: 10, + outputTokens: 5, + } as any); + + const result = await service.suggestLaneNameFromPrompt({ + prompt: "Something useful", + modelId: "anthropic/claude-haiku-4-5", + laneId: "lane-1", + }); + + expect(result).toBe("something-useful"); + }); + + it("handles null/undefined args fields gracefully", async () => { + const { service } = createSuggestService(); + const result = await service.suggestLaneNameFromPrompt({ + prompt: null as any, + modelId: null as any, + laneId: null as any, + }); + expect(result).toBe("parallel-task"); + }); +}); diff --git a/apps/desktop/src/main/services/config/projectConfigService.aiModeMigration.test.ts b/apps/desktop/src/main/services/config/projectConfigService.aiModeMigration.test.ts deleted file mode 100644 index 165e11c05..000000000 --- a/apps/desktop/src/main/services/config/projectConfigService.aiModeMigration.test.ts +++ /dev/null @@ -1,222 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import YAML from "yaml"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import { createProjectConfigService } from "./projectConfigService"; - -function makeDb() { - const store = new Map(); - return { - getJson: vi.fn((key: string) => (store.has(key) ? store.get(key) : null)), - setJson: vi.fn((key: string, value: unknown) => { - store.set(key, value); - }), - run: vi.fn() - } as any; -} - -function makeLogger() { - return { - debug: vi.fn(), - info: vi.fn(), - warn: vi.fn(), - error: vi.fn() - } as any; -} - -describe("projectConfigService AI mode normalization", () => { - const tempDirs: string[] = []; - - afterEach(() => { - while (tempDirs.length > 0) { - const dir = tempDirs.pop(); - if (!dir) break; - fs.rmSync(dir, { recursive: true, force: true }); - } - }); - - it("ignores providers.mode and removes it on save", () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-project-config-")); - tempDirs.push(root); - - const adeDir = path.join(root, ".ade"); - fs.mkdirSync(adeDir, { recursive: true }); - - const localPath = path.join(adeDir, "local.yaml"); - fs.writeFileSync( - localPath, - YAML.stringify({ - version: 1, - processes: [], - stackButtons: [], - testSuites: [], - laneOverlayPolicies: [], - automations: [], - providers: { - mode: "hosted", - contextTools: { - conflictResolvers: { - claude: { command: ["node", "resolver.js"] } - } - } - } - }), - "utf8" - ); - - const service = createProjectConfigService({ - projectRoot: root, - adeDir, - projectId: "project-1", - db: makeDb(), - logger: makeLogger() - }); - - const snapshot = service.get(); - expect(snapshot.effective.providerMode).toBe("guest"); - expect(snapshot.local.ai?.mode).toBeUndefined(); - expect((snapshot.local.providers as Record | undefined)?.mode).toBeUndefined(); - expect((snapshot.local.providers as Record | undefined)?.contextTools).toBeDefined(); - - service.save({ - shared: snapshot.shared, - local: snapshot.local - }); - - const persisted = YAML.parse(fs.readFileSync(localPath, "utf8")) as Record; - const persistedAi = persisted.ai as Record | undefined; - const persistedProviders = persisted.providers as Record | undefined; - - expect(persistedAi).toBeUndefined(); - expect(persistedProviders?.mode).toBeUndefined(); - expect((persistedProviders?.contextTools as Record | undefined)).toBeDefined(); - }); - - it("parses and normalizes ai.orchestrator settings from local config", () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-project-config-orchestrator-")); - tempDirs.push(root); - - const adeDir = path.join(root, ".ade"); - fs.mkdirSync(adeDir, { recursive: true }); - - const localPath = path.join(adeDir, "local.yaml"); - fs.writeFileSync( - localPath, - YAML.stringify({ - version: 1, - processes: [], - stackButtons: [], - testSuites: [], - laneOverlayPolicies: [], - automations: [], - ai: { - orchestrator: { - teammatePlanMode: "required", - maxParallelWorkers: 9, - contextPressureThreshold: 0.82, - progressiveLoading: false, - hooks: { - TeammateIdle: { - command: "echo teammate-idle", - timeoutMs: 4500 - }, - TaskCompleted: { - command: "echo task-completed" - } - } - } - } - }), - "utf8" - ); - - const service = createProjectConfigService({ - projectRoot: root, - adeDir, - projectId: "project-1", - db: makeDb(), - logger: makeLogger() - }); - - const orchestrator = service.get().effective.ai?.orchestrator; - expect(orchestrator?.teammatePlanMode).toBe("required"); - expect(orchestrator?.maxParallelWorkers).toBe(9); - expect(orchestrator?.contextPressureThreshold).toBe(0.82); - expect(orchestrator?.progressiveLoading).toBe(false); - expect(orchestrator?.hooks?.TeammateIdle?.command).toBe("echo teammate-idle"); - expect(orchestrator?.hooks?.TeammateIdle?.timeoutMs).toBe(4500); - expect(orchestrator?.hooks?.TaskCompleted?.command).toBe("echo task-completed"); - }); - - it("preserves commit message feature settings and chat settings on read/save", () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-project-config-commit-messages-")); - tempDirs.push(root); - - const adeDir = path.join(root, ".ade"); - fs.mkdirSync(adeDir, { recursive: true }); - - const localPath = path.join(adeDir, "local.yaml"); - fs.writeFileSync( - localPath, - YAML.stringify({ - version: 1, - processes: [], - stackButtons: [], - testSuites: [], - laneOverlayPolicies: [], - automations: [], - ai: { - features: { - commit_messages: true, - }, - featureModelOverrides: { - commit_messages: "openai/gpt-5.4-mini", - }, - chat: { - autoTitleEnabled: true, - autoTitleModelId: "openai/gpt-5.4-mini", - autoTitleRefreshOnComplete: false, - autoAllowAskUser: false, - codexSandbox: "workspace-write", - }, - }, - }), - "utf8" - ); - - const service = createProjectConfigService({ - projectRoot: root, - adeDir, - projectId: "project-1", - db: makeDb(), - logger: makeLogger(), - }); - - const snapshot = service.get(); - expect(snapshot.effective.ai?.features?.commit_messages).toBe(true); - expect(snapshot.effective.ai?.featureModelOverrides?.commit_messages).toBe("openai/gpt-5.4-mini"); - expect(snapshot.effective.ai?.sessionIntelligence?.titles?.enabled).toBe(true); - expect(snapshot.effective.ai?.sessionIntelligence?.titles?.modelId).toBe("openai/gpt-5.4-mini"); - expect(snapshot.effective.ai?.sessionIntelligence?.titles?.refreshOnComplete).toBe(false); - expect(snapshot.effective.ai?.chat?.autoAllowAskUser).toBe(false); - expect(snapshot.effective.ai?.chat?.codexSandbox).toBe("workspace-write"); - - service.save({ - shared: snapshot.shared, - local: snapshot.local, - }); - - const persisted = YAML.parse(fs.readFileSync(localPath, "utf8")) as Record; - expect(persisted.ai?.features?.commit_messages).toBe(true); - expect(persisted.ai?.featureModelOverrides?.commit_messages).toBe("openai/gpt-5.4-mini"); - expect(persisted.ai?.chat?.autoTitleEnabled).toBeUndefined(); - expect(persisted.ai?.chat?.autoTitleModelId).toBeUndefined(); - expect(persisted.ai?.chat?.autoTitleRefreshOnComplete).toBeUndefined(); - expect(persisted.ai?.sessionIntelligence?.titles?.enabled).toBe(true); - expect(persisted.ai?.sessionIntelligence?.titles?.modelId).toBe("openai/gpt-5.4-mini"); - expect(persisted.ai?.sessionIntelligence?.titles?.refreshOnComplete).toBe(false); - expect(persisted.ai?.chat?.autoAllowAskUser).toBe(false); - expect(persisted.ai?.chat?.codexSandbox).toBe("workspace-write"); - }); -}); diff --git a/apps/desktop/src/main/services/config/projectConfigService.automationExecution.test.ts b/apps/desktop/src/main/services/config/projectConfigService.automationExecution.test.ts deleted file mode 100644 index 62e924c52..000000000 --- a/apps/desktop/src/main/services/config/projectConfigService.automationExecution.test.ts +++ /dev/null @@ -1,255 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import YAML from "yaml"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import { createProjectConfigService } from "./projectConfigService"; - -function makeDb() { - const store = new Map(); - return { - getJson: vi.fn((key: string) => (store.has(key) ? store.get(key) : null)), - setJson: vi.fn((key: string, value: unknown) => { - store.set(key, value); - }), - run: vi.fn(), - } as any; -} - -function makeLogger() { - return { - debug: vi.fn(), - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - } as any; -} - -describe("projectConfigService automation execution normalization", () => { - const tempDirs: string[] = []; - - afterEach(() => { - while (tempDirs.length > 0) { - const dir = tempDirs.pop(); - if (!dir) break; - fs.rmSync(dir, { recursive: true, force: true }); - } - }); - - it("preserves lane creation fields from config", () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-project-config-automation-execution-")); - tempDirs.push(root); - - const adeDir = path.join(root, ".ade"); - fs.mkdirSync(adeDir, { recursive: true }); - - fs.writeFileSync( - path.join(adeDir, "ade.yaml"), - YAML.stringify({ - version: 1, - processes: [], - stackButtons: [], - testSuites: [], - laneOverlayPolicies: [], - automations: [ - { - id: "custom-lane-rule", - trigger: { type: "manual" }, - execution: { - kind: "mission", - laneMode: "create", - laneNamePreset: "custom", - laneNameTemplate: "Auto {{trigger.issue.title}}", - mission: { title: "Run mission" }, - }, - }, - { - id: "preset-lane-rule", - trigger: { type: "manual" }, - execution: { - kind: "agent-session", - laneMode: "nope", - laneNamePreset: "issue-title", - laneNameTemplate: "Should be dropped", - session: { codexFastMode: true }, - }, - }, - { - id: "require-trigger-lane-rule", - trigger: { type: "manual" }, - execution: { - kind: "agent-session", - laneMode: "require-on-trigger", - }, - }, - { - id: "legacy-prompt-at-run-rule", - trigger: { type: "manual" }, - execution: { - kind: "agent-session", - laneMode: "prompt-at-run", - }, - }, - { - id: "built-in-agent-rule", - trigger: { type: "manual" }, - execution: { - kind: "built-in", - builtIn: { - actions: [ - { - type: "agent-session", - prompt: "Summarize", - modelConfig: { modelId: "openai/gpt-5.5", thinkingLevel: "high" }, - codexFastMode: true, - permissionConfig: { providers: { codex: "full-auto", codexSandbox: "danger-full-access" } }, - }, - ], - }, - }, - }, - ], - }), - "utf8", - ); - - const service = createProjectConfigService({ - projectRoot: root, - adeDir, - projectId: "project-automation-execution", - db: makeDb(), - logger: makeLogger(), - }); - - const [customRule, presetRule, requireTriggerLaneRule, legacyPromptAtRunRule, builtInAgentRule] = service.get().effective.automations; - - expect(customRule.execution).toMatchObject({ - kind: "mission", - laneMode: "create", - laneNamePreset: "custom", - laneNameTemplate: "Auto {{trigger.issue.title}}", - mission: { title: "Run mission" }, - }); - expect(presetRule.execution).toMatchObject({ - kind: "agent-session", - laneMode: "reuse", - laneNamePreset: "issue-title", - session: { codexFastMode: true }, - }); - expect(presetRule.execution?.laneNameTemplate).toBeUndefined(); - expect(requireTriggerLaneRule.execution).toMatchObject({ - kind: "agent-session", - laneMode: "require-on-trigger", - }); - expect(legacyPromptAtRunRule.execution).toMatchObject({ - kind: "agent-session", - laneMode: "require-on-trigger", - }); - expect(builtInAgentRule.execution).toMatchObject({ - kind: "built-in", - builtIn: { - actions: [ - { - type: "agent-session", - modelConfig: { modelId: "openai/gpt-5.5", thinkingLevel: "high" }, - codexFastMode: true, - permissionConfig: { providers: { codex: "full-auto", codexSandbox: "danger-full-access" } }, - }, - ], - }, - }); - }); - - it("flags fixed target lanes on require-on-trigger automation execution", () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-project-config-automation-execution-")); - tempDirs.push(root); - - const adeDir = path.join(root, ".ade"); - fs.mkdirSync(adeDir, { recursive: true }); - - const service = createProjectConfigService({ - projectRoot: root, - adeDir, - projectId: "project-automation-execution-validation", - db: makeDb(), - logger: makeLogger(), - }); - - const validation = service.validate({ - shared: { - version: 1, - processes: [], - stackButtons: [], - testSuites: [], - laneOverlayPolicies: [], - automations: [], - }, - local: { - version: 1, - processes: [], - stackButtons: [], - testSuites: [], - laneOverlayPolicies: [], - automations: [ - { - id: "bad-trigger-lane", - name: "Bad trigger lane", - enabled: true, - mode: "review", - trigger: { type: "manual" }, - triggers: [{ type: "manual" }], - execution: { - kind: "agent-session", - laneMode: "require-on-trigger", - targetLaneId: "lane-fixed", - }, - executor: { mode: "automation-bot" }, - prompt: "Run.", - reviewProfile: "quick", - toolPalette: ["repo"], - contextSources: [], - guardrails: {}, - outputs: { disposition: "comment-only", createArtifact: true }, - verification: { verifyBeforePublish: false, mode: "intervention" }, - billingCode: "auto:test", - }, - { - id: "bad-step-lane", - name: "Bad step lane", - enabled: true, - mode: "review", - trigger: { type: "manual" }, - triggers: [{ type: "manual" }], - execution: { - kind: "built-in", - laneMode: "require-on-trigger", - builtIn: { - actions: [{ type: "run-command", command: "pwd", targetLaneId: "lane-fixed" }], - }, - }, - executor: { mode: "automation-bot" }, - reviewProfile: "quick", - toolPalette: ["repo"], - contextSources: [], - guardrails: {}, - outputs: { disposition: "comment-only", createArtifact: true }, - verification: { verifyBeforePublish: false, mode: "intervention" }, - billingCode: "auto:test", - }, - ], - }, - } as any); - - expect(validation.ok).toBe(false); - expect(validation.issues).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - path: "effective.automations[0].execution.targetLaneId", - }), - expect.objectContaining({ - path: "effective.automations[1].execution.builtIn.actions[0].targetLaneId", - }), - ]), - ); - }); -}); diff --git a/apps/desktop/src/main/services/config/projectConfigService.laneEnvInit.test.ts b/apps/desktop/src/main/services/config/projectConfigService.laneEnvInit.test.ts deleted file mode 100644 index b5d509b6a..000000000 --- a/apps/desktop/src/main/services/config/projectConfigService.laneEnvInit.test.ts +++ /dev/null @@ -1,300 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import YAML from "yaml"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import { createProjectConfigService } from "./projectConfigService"; - -function makeDb() { - const store = new Map(); - return { - getJson: vi.fn((key: string) => (store.has(key) ? store.get(key) : null)), - setJson: vi.fn((key: string, value: unknown) => { - store.set(key, value); - }), - run: vi.fn() - } as any; -} - -function makeLogger() { - return { - debug: vi.fn(), - info: vi.fn(), - warn: vi.fn(), - error: vi.fn() - } as any; -} - -describe("projectConfigService lane env init", () => { - const tempDirs: string[] = []; - - afterEach(() => { - while (tempDirs.length > 0) { - const dir = tempDirs.pop(); - if (!dir) break; - fs.rmSync(dir, { recursive: true, force: true }); - } - }); - - it("preserves extended overlay fields and merged lane env init in effective config", () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-project-config-lane-init-")); - tempDirs.push(root); - - const adeDir = path.join(root, ".ade"); - fs.mkdirSync(adeDir, { recursive: true }); - - fs.writeFileSync(path.join(root, "docker-compose.yml"), "services: {}\n", "utf8"); - - fs.writeFileSync( - path.join(adeDir, "ade.yaml"), - YAML.stringify({ - version: 1, - processes: [], - stackButtons: [], - testSuites: [], - automations: [], - laneEnvInit: { - envFiles: [{ source: ".env.template", dest: ".env" }] - }, - laneOverlayPolicies: [ - { - id: "backend-policy", - name: "Backend policy", - enabled: true, - match: { tags: ["backend"] }, - overrides: { - portRange: { start: 4100, end: 4199 }, - proxyHostname: "backend.localhost", - computeBackend: "vps", - envInit: { - dependencies: [{ command: ["npm", "install"] }] - } - } - } - ] - }), - "utf8" - ); - - fs.writeFileSync( - path.join(adeDir, "local.yaml"), - YAML.stringify({ - version: 1, - processes: [], - stackButtons: [], - testSuites: [], - automations: [], - laneEnvInit: { - mountPoints: [{ source: "agent-profiles/default.json", dest: ".ade-agent/profile.json" }] - }, - laneOverlayPolicies: [ - { - id: "backend-policy", - overrides: { - envInit: { - docker: { composePath: "docker-compose.yml" } - } - } - } - ] - }), - "utf8" - ); - - const service = createProjectConfigService({ - projectRoot: root, - adeDir, - projectId: "project-1", - db: makeDb(), - logger: makeLogger() - }); - - const snapshot = service.get(); - const policy = snapshot.effective.laneOverlayPolicies[0]; - - expect(policy?.overrides.portRange).toEqual({ start: 4100, end: 4199 }); - expect(policy?.overrides.proxyHostname).toBe("backend.localhost"); - expect(policy?.overrides.computeBackend).toBe("vps"); - expect(policy?.overrides.envInit).toEqual({ - dependencies: [{ command: ["npm", "install"] }], - docker: { composePath: "docker-compose.yml" } - }); - expect(snapshot.effective.laneEnvInit).toEqual({ - envFiles: [{ source: ".env.template", dest: ".env" }], - mountPoints: [{ source: "agent-profiles/default.json", dest: ".ade-agent/profile.json" }] - }); - }); - - it("flags invalid extended lane env init settings during validation", () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-project-config-lane-init-invalid-")); - tempDirs.push(root); - - const adeDir = path.join(root, ".ade"); - fs.mkdirSync(adeDir, { recursive: true }); - - const service = createProjectConfigService({ - projectRoot: root, - adeDir, - projectId: "project-1", - db: makeDb(), - logger: makeLogger() - }); - - const validation = service.validate({ - shared: { - version: 1, - processes: [], - stackButtons: [], - testSuites: [], - automations: [], - laneEnvInit: { - docker: { composePath: "missing-compose.yml" }, - dependencies: [{ command: ["npm", "install"], cwd: "missing-dir" }] - }, - laneOverlayPolicies: [ - { - id: "invalid-overlay", - overrides: { - portRange: { start: 4300, end: 4200 } - } - } - ] - }, - local: { - version: 1, - processes: [], - stackButtons: [], - testSuites: [], - laneOverlayPolicies: [], - automations: [] - } - }); - - expect(validation.ok).toBe(false); - expect(validation.issues).toEqual( - expect.arrayContaining([ - expect.objectContaining({ path: "effective.laneOverlayPolicies[0].overrides.portRange" }), - expect.objectContaining({ path: "effective.laneEnvInit.docker.composePath" }), - expect.objectContaining({ path: "effective.laneEnvInit.dependencies[0].cwd" }) - ]) - ); - }); - - it("rejects process, suite, and overlay cwd values outside the project root", () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-project-config-cwd-")); - tempDirs.push(root); - - const adeDir = path.join(root, ".ade"); - fs.mkdirSync(adeDir, { recursive: true }); - - const outsideDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-project-config-cwd-outside-")); - tempDirs.push(outsideDir); - - const service = createProjectConfigService({ - projectRoot: root, - adeDir, - projectId: "project-1", - db: makeDb(), - logger: makeLogger() - }); - - const validation = service.validate({ - shared: { - version: 1, - processes: [ - { - id: "proc-1", - name: "Proc 1", - command: ["echo", "ok"], - cwd: outsideDir, - }, - ], - stackButtons: [], - testSuites: [ - { - id: "suite-1", - name: "Suite 1", - command: ["echo", "ok"], - cwd: outsideDir, - }, - ], - laneOverlayPolicies: [ - { - id: "overlay-1", - name: "Overlay 1", - overrides: { - cwd: outsideDir, - }, - }, - ], - automations: [] - }, - local: { - version: 1, - processes: [], - stackButtons: [], - testSuites: [], - laneOverlayPolicies: [], - automations: [] - } - }); - - expect(validation.ok).toBe(false); - expect(validation.issues).toEqual( - expect.arrayContaining([ - expect.objectContaining({ path: "effective.processes[0].cwd", message: expect.stringContaining("project root") }), - expect.objectContaining({ path: "effective.testSuites[0].cwd", message: expect.stringContaining("project root") }), - expect.objectContaining({ path: "effective.laneOverlayPolicies[0].overrides.cwd", message: expect.stringContaining("project root") }), - ]), - ); - }); - - it("deep merges nested docker config across shared and local lane env init", () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-project-config-lane-init-docker-merge-")); - tempDirs.push(root); - - const adeDir = path.join(root, ".ade"); - fs.mkdirSync(adeDir, { recursive: true }); - fs.writeFileSync(path.join(root, "docker-compose.yml"), "services: {}\n", "utf8"); - - const service = createProjectConfigService({ - projectRoot: root, - adeDir, - projectId: "project-1", - db: makeDb(), - logger: makeLogger() - }); - - service.save({ - shared: { - version: 1, - processes: [], - stackButtons: [], - testSuites: [], - automations: [], - laneEnvInit: { - docker: { composePath: "docker-compose.yml", projectPrefix: "shared" } - }, - laneOverlayPolicies: [] - }, - local: { - version: 1, - processes: [], - stackButtons: [], - testSuites: [], - automations: [], - laneEnvInit: { - docker: { services: ["api"] } - }, - laneOverlayPolicies: [] - } - }); - - const effective = service.getEffective(); - expect(effective.laneEnvInit?.docker).toEqual({ - composePath: "docker-compose.yml", - projectPrefix: "shared", - services: ["api"] - }); - }); -}); diff --git a/apps/desktop/src/main/services/config/projectConfigService.linearSync.test.ts b/apps/desktop/src/main/services/config/projectConfigService.linearSync.test.ts deleted file mode 100644 index c28b801af..000000000 --- a/apps/desktop/src/main/services/config/projectConfigService.linearSync.test.ts +++ /dev/null @@ -1,98 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { describe, expect, it } from "vitest"; -import { openKvDb } from "../state/kvDb"; -import { createProjectConfigService } from "./projectConfigService"; - -function createLogger() { - return { - debug: () => {}, - info: () => {}, - warn: () => {}, - error: () => {}, - } as any; -} - -async function createFixture() { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-project-config-linear-")); - const adeDir = path.join(root, ".ade"); - fs.mkdirSync(adeDir, { recursive: true }); - const db = await openKvDb(path.join(adeDir, "ade.db"), createLogger()); - const service = createProjectConfigService({ - projectRoot: root, - adeDir, - projectId: "project-config-linear", - db, - logger: createLogger(), - }); - return { root, adeDir, db, service }; -} - -describe("projectConfigService linearSync", () => { - it("merges shared/local linear sync config with local precedence", async () => { - const fixture = await createFixture(); - - fixture.service.save({ - shared: { - linearSync: { - enabled: true, - pollingIntervalSec: 300, - projects: [{ slug: "acme-platform" }], - routing: { byLabel: { bug: "backend-dev" } }, - autoDispatch: { - default: "escalate", - rules: [{ id: "rule-shared", action: "auto", match: { labels: ["bug"] } }], - }, - }, - }, - local: { - linearSync: { - pollingIntervalSec: 120, - routing: { byLabel: { feature: "frontend-dev" } }, - autoDispatch: { - rules: [{ id: "rule-local", action: "escalate", match: { labels: ["night"] } }], - }, - }, - }, - }); - - const effective = fixture.service.getEffective(); - expect(effective.linearSync?.enabled).toBe(true); - expect(effective.linearSync?.pollingIntervalSec).toBe(120); - expect(effective.linearSync?.routing?.byLabel).toEqual({ - bug: "backend-dev", - feature: "frontend-dev", - }); - expect(effective.linearSync?.autoDispatch?.default).toBe("escalate"); - expect(effective.linearSync?.autoDispatch?.rules).toEqual([ - { - id: "rule-local", - action: "escalate", - match: { labels: ["night"] }, - }, - ]); - - fixture.db.close(); - }); - - it("clamps linear sync confidence threshold to valid range", async () => { - const fixture = await createFixture(); - - fixture.service.save({ - shared: { - linearSync: { - enabled: true, - projects: [{ slug: "acme-platform" }], - classification: { mode: "hybrid", confidenceThreshold: 1.4 }, - }, - }, - local: {}, - }); - - const effective = fixture.service.getEffective(); - expect(effective.linearSync?.classification?.confidenceThreshold).toBe(1); - - fixture.db.close(); - }); -}); diff --git a/apps/desktop/src/main/services/config/projectConfigService.notifications.test.ts b/apps/desktop/src/main/services/config/projectConfigService.notifications.test.ts deleted file mode 100644 index 3770917ff..000000000 --- a/apps/desktop/src/main/services/config/projectConfigService.notifications.test.ts +++ /dev/null @@ -1,104 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import YAML from "yaml"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import { createProjectConfigService } from "./projectConfigService"; - -function makeDb() { - const store = new Map(); - return { - getJson: vi.fn((key: string) => (store.has(key) ? store.get(key) : null)), - setJson: vi.fn((key: string, value: unknown) => { - store.set(key, value); - }), - run: vi.fn(), - } as any; -} - -function makeLogger() { - return { - debug: vi.fn(), - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - } as any; -} - -describe("projectConfigService notifications", () => { - const tempDirs: string[] = []; - - afterEach(() => { - while (tempDirs.length > 0) { - const dir = tempDirs.pop(); - if (!dir) break; - fs.rmSync(dir, { recursive: true, force: true }); - } - }); - - it("deep-merges APNs local overrides with shared notification config", () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-project-config-notifications-")); - tempDirs.push(root); - - const adeDir = path.join(root, ".ade"); - fs.mkdirSync(adeDir, { recursive: true }); - - fs.writeFileSync( - path.join(adeDir, "ade.yaml"), - YAML.stringify({ - version: 1, - processes: [], - stackButtons: [], - testSuites: [], - laneOverlayPolicies: [], - automations: [], - notifications: { - apns: { - enabled: true, - env: "production", - keyId: "KEY_SHARED", - teamId: "TEAM_SHARED", - bundleId: "com.ade.shared", - keyStored: true, - }, - }, - }), - "utf8", - ); - - fs.writeFileSync( - path.join(adeDir, "local.yaml"), - YAML.stringify({ - version: 1, - processes: [], - stackButtons: [], - testSuites: [], - laneOverlayPolicies: [], - automations: [], - notifications: { - apns: { - keyId: "KEY_LOCAL", - }, - }, - }), - "utf8", - ); - - const service = createProjectConfigService({ - projectRoot: root, - adeDir, - projectId: "project-notifications", - db: makeDb(), - logger: makeLogger(), - }); - - expect(service.get().effective.notifications?.apns).toEqual({ - enabled: true, - env: "production", - keyId: "KEY_LOCAL", - teamId: "TEAM_SHARED", - bundleId: "com.ade.shared", - keyStored: true, - }); - }); -}); diff --git a/apps/desktop/src/main/services/config/projectConfigService.processGroups.test.ts b/apps/desktop/src/main/services/config/projectConfigService.processGroups.test.ts deleted file mode 100644 index b168a1950..000000000 --- a/apps/desktop/src/main/services/config/projectConfigService.processGroups.test.ts +++ /dev/null @@ -1,437 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import YAML from "yaml"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import { createProjectConfigService } from "./projectConfigService"; - -function makeDb() { - const store = new Map(); - return { - getJson: vi.fn((key: string) => (store.has(key) ? store.get(key) : null)), - setJson: vi.fn((key: string, value: unknown) => { - store.set(key, value); - }), - run: vi.fn(), - } as any; -} - -function makeLogger() { - return { - debug: vi.fn(), - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - } as any; -} - -describe("projectConfigService process groups", () => { - const tempDirs: string[] = []; - - afterEach(() => { - while (tempDirs.length > 0) { - const dir = tempDirs.pop(); - if (!dir) break; - fs.rmSync(dir, { recursive: true, force: true }); - } - }); - - it("merges processGroups by id with local overriding shared", () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-project-config-groups-merge-")); - tempDirs.push(root); - - const adeDir = path.join(root, ".ade"); - fs.mkdirSync(adeDir, { recursive: true }); - - fs.writeFileSync( - path.join(adeDir, "ade.yaml"), - YAML.stringify({ - version: 1, - processes: [], - processGroups: [ - { id: "backend", name: "Backend" }, - { id: "frontend", name: "Frontend" }, - ], - stackButtons: [], - testSuites: [], - laneOverlayPolicies: [], - automations: [], - }), - "utf8", - ); - - fs.writeFileSync( - path.join(adeDir, "local.yaml"), - YAML.stringify({ - version: 1, - processes: [], - processGroups: [{ id: "frontend", name: "Web" }], - stackButtons: [], - testSuites: [], - laneOverlayPolicies: [], - automations: [], - }), - "utf8", - ); - - const service = createProjectConfigService({ - projectRoot: root, - adeDir, - projectId: "project-groups-merge", - db: makeDb(), - logger: makeLogger(), - }); - - const groups = service.get().effective.processGroups; - const byId = new Map(groups.map((g) => [g.id, g])); - expect(byId.has("backend")).toBe(true); - expect(byId.has("frontend")).toBe(true); - expect(byId.get("backend")!.name).toBe("Backend"); - expect(byId.get("frontend")!.name).toBe("Web"); - }); - - it("rolls back config-derived row refreshes when a snapshot write fails", () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-project-config-rollback-")); - tempDirs.push(root); - - const adeDir = path.join(root, ".ade"); - fs.mkdirSync(adeDir, { recursive: true }); - fs.writeFileSync( - path.join(adeDir, "ade.yaml"), - YAML.stringify({ - version: 1, - processes: [], - processGroups: [], - stackButtons: [], - testSuites: [], - laneOverlayPolicies: [], - automations: [], - }), - "utf8", - ); - const db = makeDb(); - db.run.mockImplementation((sql: string) => { - if (/delete from stack_buttons/i.test(sql)) { - throw new Error("delete failed"); - } - }); - const service = createProjectConfigService({ - projectRoot: root, - adeDir, - projectId: "project-rollback", - db, - logger: makeLogger(), - }); - - expect(() => service.get()).toThrow("delete failed"); - const statements = db.run.mock.calls.map((call: unknown[]) => String(call[0]).trim()); - expect(statements[0]).toBe("BEGIN IMMEDIATE"); - expect(statements).toContain("ROLLBACK"); - expect(statements).not.toContain("COMMIT"); - }); - - it("falls back to id when an effective processGroup has no name", () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-project-config-groups-fallback-")); - tempDirs.push(root); - - const adeDir = path.join(root, ".ade"); - fs.mkdirSync(adeDir, { recursive: true }); - - fs.writeFileSync( - path.join(adeDir, "ade.yaml"), - YAML.stringify({ - version: 1, - processes: [], - processGroups: [{ id: "infra" }], - stackButtons: [], - testSuites: [], - laneOverlayPolicies: [], - automations: [], - }), - "utf8", - ); - - const service = createProjectConfigService({ - projectRoot: root, - adeDir, - projectId: "project-groups-fallback", - db: makeDb(), - logger: makeLogger(), - }); - - const groups = service.get().effective.processGroups; - const infra = groups.find((g) => g.id === "infra"); - expect(infra).toBeTruthy(); - expect(infra!.name).toBe("infra"); - }); - - it("round-trips ProcessDefinition.groupIds with local overriding shared", () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-project-config-groupids-override-")); - tempDirs.push(root); - - const adeDir = path.join(root, ".ade"); - fs.mkdirSync(adeDir, { recursive: true }); - - fs.writeFileSync( - path.join(adeDir, "ade.yaml"), - YAML.stringify({ - version: 1, - processes: [ - { - id: "api", - name: "API", - command: ["npm", "run", "api"], - groupIds: ["backend"], - }, - ], - processGroups: [ - { id: "backend", name: "Backend" }, - { id: "frontend", name: "Frontend" }, - ], - stackButtons: [], - testSuites: [], - laneOverlayPolicies: [], - automations: [], - }), - "utf8", - ); - - fs.writeFileSync( - path.join(adeDir, "local.yaml"), - YAML.stringify({ - version: 1, - processes: [{ id: "api", groupIds: ["frontend"] }], - processGroups: [], - stackButtons: [], - testSuites: [], - laneOverlayPolicies: [], - automations: [], - }), - "utf8", - ); - - const service = createProjectConfigService({ - projectRoot: root, - adeDir, - projectId: "project-groupids-override", - db: makeDb(), - logger: makeLogger(), - }); - - const api = service.get().effective.processes.find((p) => p.id === "api"); - expect(api).toBeTruthy(); - expect(api!.groupIds).toEqual(["frontend"]); - }); - - it("preserves shared groupIds when local does not override them", () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-project-config-groupids-preserve-")); - tempDirs.push(root); - - const adeDir = path.join(root, ".ade"); - fs.mkdirSync(adeDir, { recursive: true }); - - fs.writeFileSync( - path.join(adeDir, "ade.yaml"), - YAML.stringify({ - version: 1, - processes: [ - { - id: "api", - name: "API", - command: ["npm", "run", "api"], - groupIds: ["backend"], - }, - ], - processGroups: [{ id: "backend", name: "Backend" }], - stackButtons: [], - testSuites: [], - laneOverlayPolicies: [], - automations: [], - }), - "utf8", - ); - - fs.writeFileSync( - path.join(adeDir, "local.yaml"), - YAML.stringify({ - version: 1, - processes: [{ id: "api", cwd: "./server" }], - processGroups: [], - stackButtons: [], - testSuites: [], - laneOverlayPolicies: [], - automations: [], - }), - "utf8", - ); - - const service = createProjectConfigService({ - projectRoot: root, - adeDir, - projectId: "project-groupids-preserve", - db: makeDb(), - logger: makeLogger(), - }); - - const api = service.get().effective.processes.find((p) => p.id === "api"); - expect(api).toBeTruthy(); - expect(api!.groupIds).toEqual(["backend"]); - }); - - it("returns an empty array when processGroups section is absent", () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-project-config-groups-empty-")); - tempDirs.push(root); - - const adeDir = path.join(root, ".ade"); - fs.mkdirSync(adeDir, { recursive: true }); - - fs.writeFileSync( - path.join(adeDir, "local.yaml"), - YAML.stringify({ - version: 1, - processes: [], - stackButtons: [], - testSuites: [], - laneOverlayPolicies: [], - automations: [], - }), - "utf8", - ); - - const service = createProjectConfigService({ - projectRoot: root, - adeDir, - projectId: "project-groups-empty", - db: makeDb(), - logger: makeLogger(), - }); - - const groups = service.get().effective.processGroups; - expect(Array.isArray(groups)).toBe(true); - expect(groups.length).toBe(0); - }); - - it("normalizes project-root absolute process and test paths to portable relative paths", () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-project-config-portable-paths-")); - tempDirs.push(root); - - const adeDir = path.join(root, ".ade"); - fs.mkdirSync(path.join(root, "scripts"), { recursive: true }); - fs.mkdirSync(path.join(root, "apps", "desktop"), { recursive: true }); - fs.mkdirSync(adeDir, { recursive: true }); - fs.writeFileSync(path.join(root, "scripts", "dogfood.sh"), "#!/bin/sh\n", "utf8"); - fs.writeFileSync(path.join(root, "scripts", "run-tests.sh"), "#!/bin/sh\n", "utf8"); - - const service = createProjectConfigService({ - projectRoot: root, - adeDir, - projectId: "project-portable-paths", - db: makeDb(), - logger: makeLogger(), - }); - - const snapshot = service.save({ - shared: { - version: 1, - processes: [ - { - id: "dogfood", - name: "Dogfood", - command: [path.join(root, "scripts", "dogfood.sh"), "code-review"], - cwd: root, - }, - ], - stackButtons: [], - testSuites: [ - { - id: "desktop-tests", - name: "Desktop tests", - command: [path.join(root, "scripts", "run-tests.sh")], - cwd: path.join(root, "apps", "desktop"), - }, - ], - laneOverlayPolicies: [ - { - id: "desktop", - name: "Desktop", - overrides: { cwd: path.join(root, "apps", "desktop") }, - }, - ], - automations: [], - }, - local: { - version: 1, - processes: [], - stackButtons: [], - testSuites: [], - laneOverlayPolicies: [], - automations: [], - }, - }); - - expect(snapshot.effective.processes[0]?.cwd).toBe("."); - expect(snapshot.effective.processes[0]?.command[0]).toBe("scripts/dogfood.sh"); - expect(snapshot.effective.testSuites[0]?.cwd).toBe("apps/desktop"); - expect(snapshot.effective.testSuites[0]?.command[0]).toBe("../../scripts/run-tests.sh"); - expect(snapshot.effective.laneOverlayPolicies[0]?.overrides.cwd).toBe("apps/desktop"); - - const saved = YAML.parse(fs.readFileSync(path.join(adeDir, "ade.yaml"), "utf8")); - expect(saved.processes[0].cwd).toBe("."); - expect(saved.processes[0].command[0]).toBe("scripts/dogfood.sh"); - expect(saved.testSuites[0].cwd).toBe("apps/desktop"); - expect(saved.testSuites[0].command[0]).toBe("../../scripts/run-tests.sh"); - expect(saved.laneOverlayPolicies[0].overrides.cwd).toBe("apps/desktop"); - }); - - it("normalizes foreign-platform absolute process paths to portable relative paths", () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-project-config-cross-platform-")); - tempDirs.push(root); - - const projectDirName = path.basename(root); - const windowsProjectRoot = `C:\\repo\\${projectDirName}`; - const adeDir = path.join(root, ".ade"); - fs.mkdirSync(path.join(root, "scripts"), { recursive: true }); - fs.mkdirSync(adeDir, { recursive: true }); - - const service = createProjectConfigService({ - projectRoot: root, - adeDir, - projectId: "project-cross-platform-paths", - db: makeDb(), - logger: makeLogger(), - }); - - const snapshot = service.save({ - shared: { - version: 1, - processes: [ - { - id: "dogfood", - name: "Dogfood", - command: [`${windowsProjectRoot}\\scripts\\dogfood.sh`, "code-review"], - cwd: windowsProjectRoot, - }, - ], - stackButtons: [], - testSuites: [], - laneOverlayPolicies: [], - automations: [], - }, - local: { - version: 1, - processes: [], - stackButtons: [], - testSuites: [], - laneOverlayPolicies: [], - automations: [], - }, - }); - - expect(snapshot.effective.processes[0]?.cwd).toBe("."); - expect(snapshot.effective.processes[0]?.command[0]).toBe("scripts/dogfood.sh"); - - const saved = YAML.parse(fs.readFileSync(path.join(adeDir, "ade.yaml"), "utf8")); - expect(saved.processes[0].cwd).toBe("."); - expect(saved.processes[0].command[0]).toBe("scripts/dogfood.sh"); - }); -}); diff --git a/apps/desktop/src/main/services/config/projectConfigService.providers.test.ts b/apps/desktop/src/main/services/config/projectConfigService.providers.test.ts deleted file mode 100644 index 14643cbb3..000000000 --- a/apps/desktop/src/main/services/config/projectConfigService.providers.test.ts +++ /dev/null @@ -1,151 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import YAML from "yaml"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import { createProjectConfigService } from "./projectConfigService"; - -function makeDb() { - const store = new Map(); - return { - getJson: vi.fn((key: string) => (store.has(key) ? store.get(key) : null)), - setJson: vi.fn((key: string, value: unknown) => { - store.set(key, value); - }), - run: vi.fn() - } as any; -} - -function makeLogger() { - return { - debug: vi.fn(), - info: vi.fn(), - warn: vi.fn(), - error: vi.fn() - } as any; -} - -function writeLocalYaml(adeDir: string, providers: Record) { - const localPath = path.join(adeDir, "local.yaml"); - fs.writeFileSync( - localPath, - YAML.stringify({ - version: 1, - processes: [], - stackButtons: [], - testSuites: [], - laneOverlayPolicies: [], - automations: [], - ai: { - permissions: { - providers - } - } - }), - "utf8" - ); - return localPath; -} - -describe("projectConfigService permissions.providers coercion", () => { - const tempDirs: string[] = []; - - afterEach(() => { - while (tempDirs.length > 0) { - const dir = tempDirs.pop(); - if (!dir) break; - fs.rmSync(dir, { recursive: true, force: true }); - } - }); - - it("parses permissions.providers fields into effective config", () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-project-config-providers-")); - tempDirs.push(root); - - const adeDir = path.join(root, ".ade"); - fs.mkdirSync(adeDir, { recursive: true }); - - writeLocalYaml(adeDir, { - claude: "edit", - codex: "plan", - cursor: "full-auto", - opencode: "default", - codexSandbox: "workspace-write", - writablePaths: ["/tmp/a", "/tmp/b"], - allowedTools: ["Read", "Write"] - }); - - const service = createProjectConfigService({ - projectRoot: root, - adeDir, - projectId: "project-1", - db: makeDb(), - logger: makeLogger() - }); - - const providers = service.get().effective.ai?.permissions?.providers; - expect(providers).toMatchObject({ - claude: "edit", - codex: "plan", - cursor: "full-auto", - opencode: "default", - codexSandbox: "workspace-write", - writablePaths: ["/tmp/a", "/tmp/b"], - allowedTools: ["Read", "Write"] - }); - }); - - it("drops invalid provider modes and keeps valid ones", () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-project-config-providers-invalid-")); - tempDirs.push(root); - - const adeDir = path.join(root, ".ade"); - fs.mkdirSync(adeDir, { recursive: true }); - - writeLocalYaml(adeDir, { - claude: "bogus", - codex: "plan" - }); - - const service = createProjectConfigService({ - projectRoot: root, - adeDir, - projectId: "project-1", - db: makeDb(), - logger: makeLogger() - }); - - const providers = service.get().effective.ai?.permissions?.providers; - expect(providers).toBeDefined(); - expect(providers?.codex).toBe("plan"); - expect(providers?.claude).toBeUndefined(); - }); - - it("ignores empty writablePaths/allowedTools arrays", () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-project-config-providers-empty-")); - tempDirs.push(root); - - const adeDir = path.join(root, ".ade"); - fs.mkdirSync(adeDir, { recursive: true }); - - writeLocalYaml(adeDir, { - codex: "full-auto", - writablePaths: [], - allowedTools: [] - }); - - const service = createProjectConfigService({ - projectRoot: root, - adeDir, - projectId: "project-1", - db: makeDb(), - logger: makeLogger() - }); - - const providers = service.get().effective.ai?.permissions?.providers; - expect(providers).toBeDefined(); - expect(providers?.codex).toBe("full-auto"); - expect(providers).not.toHaveProperty("writablePaths"); - expect(providers).not.toHaveProperty("allowedTools"); - }); -}); diff --git a/apps/desktop/src/main/services/config/projectConfigService.test.ts b/apps/desktop/src/main/services/config/projectConfigService.test.ts new file mode 100644 index 000000000..e1bfead31 --- /dev/null +++ b/apps/desktop/src/main/services/config/projectConfigService.test.ts @@ -0,0 +1,1307 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import YAML from "yaml"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { openKvDb } from "../state/kvDb"; +import { createProjectConfigService } from "./projectConfigService"; + +function makeDb() { + const store = new Map(); + return { + getJson: vi.fn((key: string) => (store.has(key) ? store.get(key) : null)), + setJson: vi.fn((key: string, value: unknown) => { + store.set(key, value); + }), + run: vi.fn(), + } as any; +} + +function makeLogger() { + return { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + } as any; +} + +function quietLogger() { + return { + debug: () => {}, + info: () => {}, + warn: () => {}, + error: () => {}, + } as any; +} + +const tempDirs: string[] = []; + +afterEach(() => { + while (tempDirs.length > 0) { + const dir = tempDirs.pop(); + if (!dir) break; + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +function makeProjectFixture(prefix: string): { root: string; adeDir: string } { + const root = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + tempDirs.push(root); + const adeDir = path.join(root, ".ade"); + fs.mkdirSync(adeDir, { recursive: true }); + return { root, adeDir }; +} + +describe("projectConfigService - providers permissions", () => { + function writeLocalYaml(adeDir: string, providers: Record) { + const localPath = path.join(adeDir, "local.yaml"); + fs.writeFileSync( + localPath, + YAML.stringify({ + version: 1, + processes: [], + stackButtons: [], + testSuites: [], + laneOverlayPolicies: [], + automations: [], + ai: { + permissions: { + providers, + }, + }, + }), + "utf8", + ); + return localPath; + } + + it("parses permissions.providers fields into effective config", () => { + const { root, adeDir } = makeProjectFixture("ade-project-config-providers-"); + + writeLocalYaml(adeDir, { + claude: "edit", + codex: "plan", + cursor: "full-auto", + opencode: "default", + codexSandbox: "workspace-write", + writablePaths: ["/tmp/a", "/tmp/b"], + allowedTools: ["Read", "Write"], + }); + + const service = createProjectConfigService({ + projectRoot: root, + adeDir, + projectId: "project-1", + db: makeDb(), + logger: makeLogger(), + }); + + const providers = service.get().effective.ai?.permissions?.providers; + expect(providers).toMatchObject({ + claude: "edit", + codex: "plan", + cursor: "full-auto", + opencode: "default", + codexSandbox: "workspace-write", + writablePaths: ["/tmp/a", "/tmp/b"], + allowedTools: ["Read", "Write"], + }); + }); + + it("drops invalid provider modes and keeps valid ones", () => { + const { root, adeDir } = makeProjectFixture("ade-project-config-providers-invalid-"); + + writeLocalYaml(adeDir, { + claude: "bogus", + codex: "plan", + }); + + const service = createProjectConfigService({ + projectRoot: root, + adeDir, + projectId: "project-1", + db: makeDb(), + logger: makeLogger(), + }); + + const providers = service.get().effective.ai?.permissions?.providers; + expect(providers).toBeDefined(); + expect(providers?.codex).toBe("plan"); + expect(providers?.claude).toBeUndefined(); + }); + + it("ignores empty writablePaths/allowedTools arrays", () => { + const { root, adeDir } = makeProjectFixture("ade-project-config-providers-empty-"); + + writeLocalYaml(adeDir, { + codex: "full-auto", + writablePaths: [], + allowedTools: [], + }); + + const service = createProjectConfigService({ + projectRoot: root, + adeDir, + projectId: "project-1", + db: makeDb(), + logger: makeLogger(), + }); + + const providers = service.get().effective.ai?.permissions?.providers; + expect(providers).toBeDefined(); + expect(providers?.codex).toBe("full-auto"); + expect(providers).not.toHaveProperty("writablePaths"); + expect(providers).not.toHaveProperty("allowedTools"); + }); +}); + +describe("projectConfigService - lane env init", () => { + it("preserves extended overlay fields and merged lane env init in effective config", () => { + const { root, adeDir } = makeProjectFixture("ade-project-config-lane-init-"); + + fs.writeFileSync(path.join(root, "docker-compose.yml"), "services: {}\n", "utf8"); + + fs.writeFileSync( + path.join(adeDir, "ade.yaml"), + YAML.stringify({ + version: 1, + processes: [], + stackButtons: [], + testSuites: [], + automations: [], + laneEnvInit: { + envFiles: [{ source: ".env.template", dest: ".env" }], + }, + laneOverlayPolicies: [ + { + id: "backend-policy", + name: "Backend policy", + enabled: true, + match: { tags: ["backend"] }, + overrides: { + portRange: { start: 4100, end: 4199 }, + proxyHostname: "backend.localhost", + computeBackend: "vps", + envInit: { + dependencies: [{ command: ["npm", "install"] }], + }, + }, + }, + ], + }), + "utf8", + ); + + fs.writeFileSync( + path.join(adeDir, "local.yaml"), + YAML.stringify({ + version: 1, + processes: [], + stackButtons: [], + testSuites: [], + automations: [], + laneEnvInit: { + mountPoints: [{ source: "agent-profiles/default.json", dest: ".ade-agent/profile.json" }], + }, + laneOverlayPolicies: [ + { + id: "backend-policy", + overrides: { + envInit: { + docker: { composePath: "docker-compose.yml" }, + }, + }, + }, + ], + }), + "utf8", + ); + + const service = createProjectConfigService({ + projectRoot: root, + adeDir, + projectId: "project-1", + db: makeDb(), + logger: makeLogger(), + }); + + const snapshot = service.get(); + const policy = snapshot.effective.laneOverlayPolicies[0]; + + expect(policy?.overrides.portRange).toEqual({ start: 4100, end: 4199 }); + expect(policy?.overrides.proxyHostname).toBe("backend.localhost"); + expect(policy?.overrides.computeBackend).toBe("vps"); + expect(policy?.overrides.envInit).toEqual({ + dependencies: [{ command: ["npm", "install"] }], + docker: { composePath: "docker-compose.yml" }, + }); + expect(snapshot.effective.laneEnvInit).toEqual({ + envFiles: [{ source: ".env.template", dest: ".env" }], + mountPoints: [{ source: "agent-profiles/default.json", dest: ".ade-agent/profile.json" }], + }); + }); + + it("flags invalid extended lane env init settings during validation", () => { + const { root, adeDir } = makeProjectFixture("ade-project-config-lane-init-invalid-"); + + const service = createProjectConfigService({ + projectRoot: root, + adeDir, + projectId: "project-1", + db: makeDb(), + logger: makeLogger(), + }); + + const validation = service.validate({ + shared: { + version: 1, + processes: [], + stackButtons: [], + testSuites: [], + automations: [], + laneEnvInit: { + docker: { composePath: "missing-compose.yml" }, + dependencies: [{ command: ["npm", "install"], cwd: "missing-dir" }], + }, + laneOverlayPolicies: [ + { + id: "invalid-overlay", + overrides: { + portRange: { start: 4300, end: 4200 }, + }, + }, + ], + }, + local: { + version: 1, + processes: [], + stackButtons: [], + testSuites: [], + laneOverlayPolicies: [], + automations: [], + }, + }); + + expect(validation.ok).toBe(false); + expect(validation.issues).toEqual( + expect.arrayContaining([ + expect.objectContaining({ path: "effective.laneOverlayPolicies[0].overrides.portRange" }), + expect.objectContaining({ path: "effective.laneEnvInit.docker.composePath" }), + expect.objectContaining({ path: "effective.laneEnvInit.dependencies[0].cwd" }), + ]), + ); + }); + + it("rejects process, suite, and overlay cwd values outside the project root", () => { + const { root, adeDir } = makeProjectFixture("ade-project-config-cwd-"); + const outsideDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-project-config-cwd-outside-")); + tempDirs.push(outsideDir); + + const service = createProjectConfigService({ + projectRoot: root, + adeDir, + projectId: "project-1", + db: makeDb(), + logger: makeLogger(), + }); + + const validation = service.validate({ + shared: { + version: 1, + processes: [ + { + id: "proc-1", + name: "Proc 1", + command: ["echo", "ok"], + cwd: outsideDir, + }, + ], + stackButtons: [], + testSuites: [ + { + id: "suite-1", + name: "Suite 1", + command: ["echo", "ok"], + cwd: outsideDir, + }, + ], + laneOverlayPolicies: [ + { + id: "overlay-1", + name: "Overlay 1", + overrides: { + cwd: outsideDir, + }, + }, + ], + automations: [], + }, + local: { + version: 1, + processes: [], + stackButtons: [], + testSuites: [], + laneOverlayPolicies: [], + automations: [], + }, + }); + + expect(validation.ok).toBe(false); + expect(validation.issues).toEqual( + expect.arrayContaining([ + expect.objectContaining({ path: "effective.processes[0].cwd", message: expect.stringContaining("project root") }), + expect.objectContaining({ path: "effective.testSuites[0].cwd", message: expect.stringContaining("project root") }), + expect.objectContaining({ path: "effective.laneOverlayPolicies[0].overrides.cwd", message: expect.stringContaining("project root") }), + ]), + ); + }); + + it("deep merges nested docker config across shared and local lane env init", () => { + const { root, adeDir } = makeProjectFixture("ade-project-config-lane-init-docker-merge-"); + fs.writeFileSync(path.join(root, "docker-compose.yml"), "services: {}\n", "utf8"); + + const service = createProjectConfigService({ + projectRoot: root, + adeDir, + projectId: "project-1", + db: makeDb(), + logger: makeLogger(), + }); + + service.save({ + shared: { + version: 1, + processes: [], + stackButtons: [], + testSuites: [], + automations: [], + laneEnvInit: { + docker: { composePath: "docker-compose.yml", projectPrefix: "shared" }, + }, + laneOverlayPolicies: [], + }, + local: { + version: 1, + processes: [], + stackButtons: [], + testSuites: [], + automations: [], + laneEnvInit: { + docker: { services: ["api"] }, + }, + laneOverlayPolicies: [], + }, + }); + + const effective = service.getEffective(); + expect(effective.laneEnvInit?.docker).toEqual({ + composePath: "docker-compose.yml", + projectPrefix: "shared", + services: ["api"], + }); + }); +}); + +describe("projectConfigService - AI mode migration", () => { + it("ignores providers.mode and removes it on save", () => { + const { root, adeDir } = makeProjectFixture("ade-project-config-"); + + const localPath = path.join(adeDir, "local.yaml"); + fs.writeFileSync( + localPath, + YAML.stringify({ + version: 1, + processes: [], + stackButtons: [], + testSuites: [], + laneOverlayPolicies: [], + automations: [], + providers: { + mode: "hosted", + contextTools: { + conflictResolvers: { + claude: { command: ["node", "resolver.js"] }, + }, + }, + }, + }), + "utf8", + ); + + const service = createProjectConfigService({ + projectRoot: root, + adeDir, + projectId: "project-1", + db: makeDb(), + logger: makeLogger(), + }); + + const snapshot = service.get(); + expect(snapshot.effective.providerMode).toBe("guest"); + expect(snapshot.local.ai?.mode).toBeUndefined(); + expect((snapshot.local.providers as Record | undefined)?.mode).toBeUndefined(); + expect((snapshot.local.providers as Record | undefined)?.contextTools).toBeDefined(); + + service.save({ + shared: snapshot.shared, + local: snapshot.local, + }); + + const persisted = YAML.parse(fs.readFileSync(localPath, "utf8")) as Record; + const persistedAi = persisted.ai as Record | undefined; + const persistedProviders = persisted.providers as Record | undefined; + + expect(persistedAi).toBeUndefined(); + expect(persistedProviders?.mode).toBeUndefined(); + expect((persistedProviders?.contextTools as Record | undefined)).toBeDefined(); + }); + + it("parses and normalizes ai.orchestrator settings from local config", () => { + const { root, adeDir } = makeProjectFixture("ade-project-config-orchestrator-"); + + const localPath = path.join(adeDir, "local.yaml"); + fs.writeFileSync( + localPath, + YAML.stringify({ + version: 1, + processes: [], + stackButtons: [], + testSuites: [], + laneOverlayPolicies: [], + automations: [], + ai: { + orchestrator: { + teammatePlanMode: "required", + maxParallelWorkers: 9, + contextPressureThreshold: 0.82, + progressiveLoading: false, + hooks: { + TeammateIdle: { + command: "echo teammate-idle", + timeoutMs: 4500, + }, + TaskCompleted: { + command: "echo task-completed", + }, + }, + }, + }, + }), + "utf8", + ); + + const service = createProjectConfigService({ + projectRoot: root, + adeDir, + projectId: "project-1", + db: makeDb(), + logger: makeLogger(), + }); + + const orchestrator = service.get().effective.ai?.orchestrator; + expect(orchestrator?.teammatePlanMode).toBe("required"); + expect(orchestrator?.maxParallelWorkers).toBe(9); + expect(orchestrator?.contextPressureThreshold).toBe(0.82); + expect(orchestrator?.progressiveLoading).toBe(false); + expect(orchestrator?.hooks?.TeammateIdle?.command).toBe("echo teammate-idle"); + expect(orchestrator?.hooks?.TeammateIdle?.timeoutMs).toBe(4500); + expect(orchestrator?.hooks?.TaskCompleted?.command).toBe("echo task-completed"); + }); + + it("preserves commit message feature settings and chat settings on read/save", () => { + const { root, adeDir } = makeProjectFixture("ade-project-config-commit-messages-"); + + const localPath = path.join(adeDir, "local.yaml"); + fs.writeFileSync( + localPath, + YAML.stringify({ + version: 1, + processes: [], + stackButtons: [], + testSuites: [], + laneOverlayPolicies: [], + automations: [], + ai: { + features: { + commit_messages: true, + }, + featureModelOverrides: { + commit_messages: "openai/gpt-5.4-mini", + }, + chat: { + autoTitleEnabled: true, + autoTitleModelId: "openai/gpt-5.4-mini", + autoTitleRefreshOnComplete: false, + autoAllowAskUser: false, + codexSandbox: "workspace-write", + }, + }, + }), + "utf8", + ); + + const service = createProjectConfigService({ + projectRoot: root, + adeDir, + projectId: "project-1", + db: makeDb(), + logger: makeLogger(), + }); + + const snapshot = service.get(); + expect(snapshot.effective.ai?.features?.commit_messages).toBe(true); + expect(snapshot.effective.ai?.featureModelOverrides?.commit_messages).toBe("openai/gpt-5.4-mini"); + expect(snapshot.effective.ai?.sessionIntelligence?.titles?.enabled).toBe(true); + expect(snapshot.effective.ai?.sessionIntelligence?.titles?.modelId).toBe("openai/gpt-5.4-mini"); + expect(snapshot.effective.ai?.sessionIntelligence?.titles?.refreshOnComplete).toBe(false); + expect(snapshot.effective.ai?.chat?.autoAllowAskUser).toBe(false); + expect(snapshot.effective.ai?.chat?.codexSandbox).toBe("workspace-write"); + + service.save({ + shared: snapshot.shared, + local: snapshot.local, + }); + + const persisted = YAML.parse(fs.readFileSync(localPath, "utf8")) as Record; + expect(persisted.ai?.features?.commit_messages).toBe(true); + expect(persisted.ai?.featureModelOverrides?.commit_messages).toBe("openai/gpt-5.4-mini"); + expect(persisted.ai?.chat?.autoTitleEnabled).toBeUndefined(); + expect(persisted.ai?.chat?.autoTitleModelId).toBeUndefined(); + expect(persisted.ai?.chat?.autoTitleRefreshOnComplete).toBeUndefined(); + expect(persisted.ai?.sessionIntelligence?.titles?.enabled).toBe(true); + expect(persisted.ai?.sessionIntelligence?.titles?.modelId).toBe("openai/gpt-5.4-mini"); + expect(persisted.ai?.sessionIntelligence?.titles?.refreshOnComplete).toBe(false); + expect(persisted.ai?.chat?.autoAllowAskUser).toBe(false); + expect(persisted.ai?.chat?.codexSandbox).toBe("workspace-write"); + }); +}); + +describe("projectConfigService - linear sync", () => { + async function createLinearFixture() { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-project-config-linear-")); + tempDirs.push(root); + const adeDir = path.join(root, ".ade"); + fs.mkdirSync(adeDir, { recursive: true }); + const db = await openKvDb(path.join(adeDir, "ade.db"), quietLogger()); + const service = createProjectConfigService({ + projectRoot: root, + adeDir, + projectId: "project-config-linear", + db, + logger: quietLogger(), + }); + return { root, adeDir, db, service }; + } + + it("merges shared/local linear sync config with local precedence", async () => { + const fixture = await createLinearFixture(); + + fixture.service.save({ + shared: { + linearSync: { + enabled: true, + pollingIntervalSec: 300, + projects: [{ slug: "acme-platform" }], + routing: { byLabel: { bug: "backend-dev" } }, + autoDispatch: { + default: "escalate", + rules: [{ id: "rule-shared", action: "auto", match: { labels: ["bug"] } }], + }, + }, + }, + local: { + linearSync: { + pollingIntervalSec: 120, + routing: { byLabel: { feature: "frontend-dev" } }, + autoDispatch: { + rules: [{ id: "rule-local", action: "escalate", match: { labels: ["night"] } }], + }, + }, + }, + }); + + const effective = fixture.service.getEffective(); + expect(effective.linearSync?.enabled).toBe(true); + expect(effective.linearSync?.pollingIntervalSec).toBe(120); + expect(effective.linearSync?.routing?.byLabel).toEqual({ + bug: "backend-dev", + feature: "frontend-dev", + }); + expect(effective.linearSync?.autoDispatch?.default).toBe("escalate"); + expect(effective.linearSync?.autoDispatch?.rules).toEqual([ + { + id: "rule-local", + action: "escalate", + match: { labels: ["night"] }, + }, + ]); + + fixture.db.close(); + }); + + it("clamps linear sync confidence threshold to valid range", async () => { + const fixture = await createLinearFixture(); + + fixture.service.save({ + shared: { + linearSync: { + enabled: true, + projects: [{ slug: "acme-platform" }], + classification: { mode: "hybrid", confidenceThreshold: 1.4 }, + }, + }, + local: {}, + }); + + const effective = fixture.service.getEffective(); + expect(effective.linearSync?.classification?.confidenceThreshold).toBe(1); + + fixture.db.close(); + }); +}); + +describe("projectConfigService - automation execution", () => { + it("preserves lane creation fields from config", () => { + const { root, adeDir } = makeProjectFixture("ade-project-config-automation-execution-"); + + fs.writeFileSync( + path.join(adeDir, "ade.yaml"), + YAML.stringify({ + version: 1, + processes: [], + stackButtons: [], + testSuites: [], + laneOverlayPolicies: [], + automations: [ + { + id: "custom-lane-rule", + trigger: { type: "manual" }, + execution: { + kind: "mission", + laneMode: "create", + laneNamePreset: "custom", + laneNameTemplate: "Auto {{trigger.issue.title}}", + mission: { title: "Run mission" }, + }, + }, + { + id: "preset-lane-rule", + trigger: { type: "manual" }, + execution: { + kind: "agent-session", + laneMode: "nope", + laneNamePreset: "issue-title", + laneNameTemplate: "Should be dropped", + session: { codexFastMode: true }, + }, + }, + { + id: "require-trigger-lane-rule", + trigger: { type: "manual" }, + execution: { + kind: "agent-session", + laneMode: "require-on-trigger", + }, + }, + { + id: "legacy-prompt-at-run-rule", + trigger: { type: "manual" }, + execution: { + kind: "agent-session", + laneMode: "prompt-at-run", + }, + }, + { + id: "built-in-agent-rule", + trigger: { type: "manual" }, + execution: { + kind: "built-in", + builtIn: { + actions: [ + { + type: "agent-session", + prompt: "Summarize", + modelConfig: { modelId: "openai/gpt-5.5", thinkingLevel: "high" }, + codexFastMode: true, + permissionConfig: { providers: { codex: "full-auto", codexSandbox: "danger-full-access" } }, + }, + ], + }, + }, + }, + ], + }), + "utf8", + ); + + const service = createProjectConfigService({ + projectRoot: root, + adeDir, + projectId: "project-automation-execution", + db: makeDb(), + logger: makeLogger(), + }); + + const [customRule, presetRule, requireTriggerLaneRule, legacyPromptAtRunRule, builtInAgentRule] = service.get().effective.automations; + + expect(customRule.execution).toMatchObject({ + kind: "mission", + laneMode: "create", + laneNamePreset: "custom", + laneNameTemplate: "Auto {{trigger.issue.title}}", + mission: { title: "Run mission" }, + }); + expect(presetRule.execution).toMatchObject({ + kind: "agent-session", + laneMode: "reuse", + laneNamePreset: "issue-title", + session: { codexFastMode: true }, + }); + expect(presetRule.execution?.laneNameTemplate).toBeUndefined(); + expect(requireTriggerLaneRule.execution).toMatchObject({ + kind: "agent-session", + laneMode: "require-on-trigger", + }); + expect(legacyPromptAtRunRule.execution).toMatchObject({ + kind: "agent-session", + laneMode: "require-on-trigger", + }); + expect(builtInAgentRule.execution).toMatchObject({ + kind: "built-in", + builtIn: { + actions: [ + { + type: "agent-session", + modelConfig: { modelId: "openai/gpt-5.5", thinkingLevel: "high" }, + codexFastMode: true, + permissionConfig: { providers: { codex: "full-auto", codexSandbox: "danger-full-access" } }, + }, + ], + }, + }); + }); + + it("flags fixed target lanes on require-on-trigger automation execution", () => { + const { root, adeDir } = makeProjectFixture("ade-project-config-automation-execution-validation-"); + + const service = createProjectConfigService({ + projectRoot: root, + adeDir, + projectId: "project-automation-execution-validation", + db: makeDb(), + logger: makeLogger(), + }); + + const validation = service.validate({ + shared: { + version: 1, + processes: [], + stackButtons: [], + testSuites: [], + laneOverlayPolicies: [], + automations: [], + }, + local: { + version: 1, + processes: [], + stackButtons: [], + testSuites: [], + laneOverlayPolicies: [], + automations: [ + { + id: "bad-trigger-lane", + name: "Bad trigger lane", + enabled: true, + mode: "review", + trigger: { type: "manual" }, + triggers: [{ type: "manual" }], + execution: { + kind: "agent-session", + laneMode: "require-on-trigger", + targetLaneId: "lane-fixed", + }, + executor: { mode: "automation-bot" }, + prompt: "Run.", + reviewProfile: "quick", + toolPalette: ["repo"], + contextSources: [], + guardrails: {}, + outputs: { disposition: "comment-only", createArtifact: true }, + verification: { verifyBeforePublish: false, mode: "intervention" }, + billingCode: "auto:test", + }, + { + id: "bad-step-lane", + name: "Bad step lane", + enabled: true, + mode: "review", + trigger: { type: "manual" }, + triggers: [{ type: "manual" }], + execution: { + kind: "built-in", + laneMode: "require-on-trigger", + builtIn: { + actions: [{ type: "run-command", command: "pwd", targetLaneId: "lane-fixed" }], + }, + }, + executor: { mode: "automation-bot" }, + reviewProfile: "quick", + toolPalette: ["repo"], + contextSources: [], + guardrails: {}, + outputs: { disposition: "comment-only", createArtifact: true }, + verification: { verifyBeforePublish: false, mode: "intervention" }, + billingCode: "auto:test", + }, + ], + }, + } as any); + + expect(validation.ok).toBe(false); + expect(validation.issues).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + path: "effective.automations[0].execution.targetLaneId", + }), + expect.objectContaining({ + path: "effective.automations[1].execution.builtIn.actions[0].targetLaneId", + }), + ]), + ); + }); +}); + +describe("projectConfigService - process groups", () => { + it("merges processGroups by id with local overriding shared", () => { + const { root, adeDir } = makeProjectFixture("ade-project-config-groups-merge-"); + + fs.writeFileSync( + path.join(adeDir, "ade.yaml"), + YAML.stringify({ + version: 1, + processes: [], + processGroups: [ + { id: "backend", name: "Backend" }, + { id: "frontend", name: "Frontend" }, + ], + stackButtons: [], + testSuites: [], + laneOverlayPolicies: [], + automations: [], + }), + "utf8", + ); + + fs.writeFileSync( + path.join(adeDir, "local.yaml"), + YAML.stringify({ + version: 1, + processes: [], + processGroups: [{ id: "frontend", name: "Web" }], + stackButtons: [], + testSuites: [], + laneOverlayPolicies: [], + automations: [], + }), + "utf8", + ); + + const service = createProjectConfigService({ + projectRoot: root, + adeDir, + projectId: "project-groups-merge", + db: makeDb(), + logger: makeLogger(), + }); + + const groups = service.get().effective.processGroups; + const byId = new Map(groups.map((g) => [g.id, g])); + expect(byId.has("backend")).toBe(true); + expect(byId.has("frontend")).toBe(true); + expect(byId.get("backend")!.name).toBe("Backend"); + expect(byId.get("frontend")!.name).toBe("Web"); + }); + + it("rolls back config-derived row refreshes when a snapshot write fails", () => { + const { root, adeDir } = makeProjectFixture("ade-project-config-rollback-"); + fs.writeFileSync( + path.join(adeDir, "ade.yaml"), + YAML.stringify({ + version: 1, + processes: [], + processGroups: [], + stackButtons: [], + testSuites: [], + laneOverlayPolicies: [], + automations: [], + }), + "utf8", + ); + const db = makeDb(); + db.run.mockImplementation((sql: string) => { + if (/delete from stack_buttons/i.test(sql)) { + throw new Error("delete failed"); + } + }); + const service = createProjectConfigService({ + projectRoot: root, + adeDir, + projectId: "project-rollback", + db, + logger: makeLogger(), + }); + + expect(() => service.get()).toThrow("delete failed"); + const statements = db.run.mock.calls.map((call: unknown[]) => String(call[0]).trim()); + expect(statements[0]).toBe("BEGIN IMMEDIATE"); + expect(statements).toContain("ROLLBACK"); + expect(statements).not.toContain("COMMIT"); + }); + + it("falls back to id when an effective processGroup has no name", () => { + const { root, adeDir } = makeProjectFixture("ade-project-config-groups-fallback-"); + + fs.writeFileSync( + path.join(adeDir, "ade.yaml"), + YAML.stringify({ + version: 1, + processes: [], + processGroups: [{ id: "infra" }], + stackButtons: [], + testSuites: [], + laneOverlayPolicies: [], + automations: [], + }), + "utf8", + ); + + const service = createProjectConfigService({ + projectRoot: root, + adeDir, + projectId: "project-groups-fallback", + db: makeDb(), + logger: makeLogger(), + }); + + const groups = service.get().effective.processGroups; + const infra = groups.find((g) => g.id === "infra"); + expect(infra).toBeTruthy(); + expect(infra!.name).toBe("infra"); + }); + + it("round-trips ProcessDefinition.groupIds with local overriding shared", () => { + const { root, adeDir } = makeProjectFixture("ade-project-config-groupids-override-"); + + fs.writeFileSync( + path.join(adeDir, "ade.yaml"), + YAML.stringify({ + version: 1, + processes: [ + { + id: "api", + name: "API", + command: ["npm", "run", "api"], + groupIds: ["backend"], + }, + ], + processGroups: [ + { id: "backend", name: "Backend" }, + { id: "frontend", name: "Frontend" }, + ], + stackButtons: [], + testSuites: [], + laneOverlayPolicies: [], + automations: [], + }), + "utf8", + ); + + fs.writeFileSync( + path.join(adeDir, "local.yaml"), + YAML.stringify({ + version: 1, + processes: [{ id: "api", groupIds: ["frontend"] }], + processGroups: [], + stackButtons: [], + testSuites: [], + laneOverlayPolicies: [], + automations: [], + }), + "utf8", + ); + + const service = createProjectConfigService({ + projectRoot: root, + adeDir, + projectId: "project-groupids-override", + db: makeDb(), + logger: makeLogger(), + }); + + const api = service.get().effective.processes.find((p) => p.id === "api"); + expect(api).toBeTruthy(); + expect(api!.groupIds).toEqual(["frontend"]); + }); + + it("preserves shared groupIds when local does not override them", () => { + const { root, adeDir } = makeProjectFixture("ade-project-config-groupids-preserve-"); + + fs.writeFileSync( + path.join(adeDir, "ade.yaml"), + YAML.stringify({ + version: 1, + processes: [ + { + id: "api", + name: "API", + command: ["npm", "run", "api"], + groupIds: ["backend"], + }, + ], + processGroups: [{ id: "backend", name: "Backend" }], + stackButtons: [], + testSuites: [], + laneOverlayPolicies: [], + automations: [], + }), + "utf8", + ); + + fs.writeFileSync( + path.join(adeDir, "local.yaml"), + YAML.stringify({ + version: 1, + processes: [{ id: "api", cwd: "./server" }], + processGroups: [], + stackButtons: [], + testSuites: [], + laneOverlayPolicies: [], + automations: [], + }), + "utf8", + ); + + const service = createProjectConfigService({ + projectRoot: root, + adeDir, + projectId: "project-groupids-preserve", + db: makeDb(), + logger: makeLogger(), + }); + + const api = service.get().effective.processes.find((p) => p.id === "api"); + expect(api).toBeTruthy(); + expect(api!.groupIds).toEqual(["backend"]); + }); + + it("returns an empty array when processGroups section is absent", () => { + const { root, adeDir } = makeProjectFixture("ade-project-config-groups-empty-"); + + fs.writeFileSync( + path.join(adeDir, "local.yaml"), + YAML.stringify({ + version: 1, + processes: [], + stackButtons: [], + testSuites: [], + laneOverlayPolicies: [], + automations: [], + }), + "utf8", + ); + + const service = createProjectConfigService({ + projectRoot: root, + adeDir, + projectId: "project-groups-empty", + db: makeDb(), + logger: makeLogger(), + }); + + const groups = service.get().effective.processGroups; + expect(Array.isArray(groups)).toBe(true); + expect(groups.length).toBe(0); + }); + + it("normalizes project-root absolute process and test paths to portable relative paths", () => { + const { root, adeDir } = makeProjectFixture("ade-project-config-portable-paths-"); + fs.mkdirSync(path.join(root, "scripts"), { recursive: true }); + fs.mkdirSync(path.join(root, "apps", "desktop"), { recursive: true }); + fs.writeFileSync(path.join(root, "scripts", "dogfood.sh"), "#!/bin/sh\n", "utf8"); + fs.writeFileSync(path.join(root, "scripts", "run-tests.sh"), "#!/bin/sh\n", "utf8"); + + const service = createProjectConfigService({ + projectRoot: root, + adeDir, + projectId: "project-portable-paths", + db: makeDb(), + logger: makeLogger(), + }); + + const snapshot = service.save({ + shared: { + version: 1, + processes: [ + { + id: "dogfood", + name: "Dogfood", + command: [path.join(root, "scripts", "dogfood.sh"), "code-review"], + cwd: root, + }, + ], + stackButtons: [], + testSuites: [ + { + id: "desktop-tests", + name: "Desktop tests", + command: [path.join(root, "scripts", "run-tests.sh")], + cwd: path.join(root, "apps", "desktop"), + }, + ], + laneOverlayPolicies: [ + { + id: "desktop", + name: "Desktop", + overrides: { cwd: path.join(root, "apps", "desktop") }, + }, + ], + automations: [], + }, + local: { + version: 1, + processes: [], + stackButtons: [], + testSuites: [], + laneOverlayPolicies: [], + automations: [], + }, + }); + + expect(snapshot.effective.processes[0]?.cwd).toBe("."); + expect(snapshot.effective.processes[0]?.command[0]).toBe("scripts/dogfood.sh"); + expect(snapshot.effective.testSuites[0]?.cwd).toBe("apps/desktop"); + expect(snapshot.effective.testSuites[0]?.command[0]).toBe("../../scripts/run-tests.sh"); + expect(snapshot.effective.laneOverlayPolicies[0]?.overrides.cwd).toBe("apps/desktop"); + + const saved = YAML.parse(fs.readFileSync(path.join(adeDir, "ade.yaml"), "utf8")); + expect(saved.processes[0].cwd).toBe("."); + expect(saved.processes[0].command[0]).toBe("scripts/dogfood.sh"); + expect(saved.testSuites[0].cwd).toBe("apps/desktop"); + expect(saved.testSuites[0].command[0]).toBe("../../scripts/run-tests.sh"); + expect(saved.laneOverlayPolicies[0].overrides.cwd).toBe("apps/desktop"); + }); + + it("normalizes foreign-platform absolute process paths to portable relative paths", () => { + const { root, adeDir } = makeProjectFixture("ade-project-config-cross-platform-"); + + const projectDirName = path.basename(root); + const windowsProjectRoot = `C:\\repo\\${projectDirName}`; + fs.mkdirSync(path.join(root, "scripts"), { recursive: true }); + + const service = createProjectConfigService({ + projectRoot: root, + adeDir, + projectId: "project-cross-platform-paths", + db: makeDb(), + logger: makeLogger(), + }); + + const snapshot = service.save({ + shared: { + version: 1, + processes: [ + { + id: "dogfood", + name: "Dogfood", + command: [`${windowsProjectRoot}\\scripts\\dogfood.sh`, "code-review"], + cwd: windowsProjectRoot, + }, + ], + stackButtons: [], + testSuites: [], + laneOverlayPolicies: [], + automations: [], + }, + local: { + version: 1, + processes: [], + stackButtons: [], + testSuites: [], + laneOverlayPolicies: [], + automations: [], + }, + }); + + expect(snapshot.effective.processes[0]?.cwd).toBe("."); + expect(snapshot.effective.processes[0]?.command[0]).toBe("scripts/dogfood.sh"); + + const saved = YAML.parse(fs.readFileSync(path.join(adeDir, "ade.yaml"), "utf8")); + expect(saved.processes[0].cwd).toBe("."); + expect(saved.processes[0].command[0]).toBe("scripts/dogfood.sh"); + }); +}); + +describe("projectConfigService - notifications", () => { + it("deep-merges APNs local overrides with shared notification config", () => { + const { root, adeDir } = makeProjectFixture("ade-project-config-notifications-"); + + fs.writeFileSync( + path.join(adeDir, "ade.yaml"), + YAML.stringify({ + version: 1, + processes: [], + stackButtons: [], + testSuites: [], + laneOverlayPolicies: [], + automations: [], + notifications: { + apns: { + enabled: true, + env: "production", + keyId: "KEY_SHARED", + teamId: "TEAM_SHARED", + bundleId: "com.ade.shared", + keyStored: true, + }, + }, + }), + "utf8", + ); + + fs.writeFileSync( + path.join(adeDir, "local.yaml"), + YAML.stringify({ + version: 1, + processes: [], + stackButtons: [], + testSuites: [], + laneOverlayPolicies: [], + automations: [], + notifications: { + apns: { + keyId: "KEY_LOCAL", + }, + }, + }), + "utf8", + ); + + const service = createProjectConfigService({ + projectRoot: root, + adeDir, + projectId: "project-notifications", + db: makeDb(), + logger: makeLogger(), + }); + + expect(service.get().effective.notifications?.apns).toEqual({ + enabled: true, + env: "production", + keyId: "KEY_LOCAL", + teamId: "TEAM_SHARED", + bundleId: "com.ade.shared", + keyStored: true, + }); + }); +}); diff --git a/apps/desktop/src/main/services/cto/workerAgentService.ts b/apps/desktop/src/main/services/cto/workerAgentService.ts index b96de62b6..a5be9e906 100644 --- a/apps/desktop/src/main/services/cto/workerAgentService.ts +++ b/apps/desktop/src/main/services/cto/workerAgentService.ts @@ -22,7 +22,6 @@ import { hasEnvRefToken, looksSensitiveKey, looksSensitiveValue, - stableStringify, } from "../shared/utils"; import { createLogIntegrityService } from "../projects/logIntegrityService"; diff --git a/apps/desktop/src/main/services/git/gitOperationsService.branchSwitch.test.ts b/apps/desktop/src/main/services/git/gitOperationsService.branchSwitch.test.ts deleted file mode 100644 index 505f8931d..000000000 --- a/apps/desktop/src/main/services/git/gitOperationsService.branchSwitch.test.ts +++ /dev/null @@ -1,341 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; - -const mockGit = vi.hoisted(() => ({ - runGit: vi.fn(), - runGitOrThrow: vi.fn(), - getHeadSha: vi.fn(), -})); - -vi.mock("./git", () => ({ - runGit: (...args: unknown[]) => mockGit.runGit(...args), - runGitOrThrow: (...args: unknown[]) => mockGit.runGitOrThrow(...args), - getHeadSha: (...args: unknown[]) => mockGit.getHeadSha(...args), -})); - -import { createGitOperationsService } from "./gitOperationsService"; - -function makeStubLogger() { - return { - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - debug: vi.fn(), - } as any; -} - -function makeServiceWithLanes(opts: { - branchProfiles?: Array<{ branchRef: string }>; - lanes?: Array<{ id: string; name: string; branchRef: string; laneType: string }>; - switchBranch?: ReturnType; - listBranchProfilesThrows?: boolean; - listThrows?: boolean; -}) { - const switchBranchMock = opts.switchBranch ?? vi.fn().mockResolvedValue({ lane: { id: "lane-1" }, previousBranchRef: "feature/old", activeWork: [] }); - - const listBranchProfiles = opts.listBranchProfilesThrows - ? vi.fn(() => { throw new Error("profile lookup failed"); }) - : vi.fn().mockReturnValue(opts.branchProfiles ?? []); - - const lanes = opts.lanes ?? []; - const listBranchOwners = opts.listThrows - ? vi.fn(() => { throw new Error("owner lookup failed"); }) - : vi.fn(({ excludeLaneId }: { excludeLaneId?: string } = {}) => - lanes - .filter((l) => l.laneType !== "primary" && l.id !== excludeLaneId) - .map((l) => ({ id: l.id, name: l.name, branchRef: l.branchRef })), - ); - - const service = createGitOperationsService({ - laneService: { - getLaneBaseAndBranch: vi.fn().mockReturnValue({ - baseRef: "main", - branchRef: "feature/source", - worktreePath: "/tmp/ade-lane", - laneType: "worktree", - }), - listBranchProfiles, - listBranchOwners, - switchBranch: switchBranchMock, - } as any, - operationService: { - start: vi.fn().mockReturnValue({ operationId: "op-1" }), - finish: vi.fn(), - } as any, - projectConfigService: { - get: () => ({ effective: { ai: {} } }), - } as any, - aiIntegrationService: { - getFeatureFlag: () => false, - getStatus: vi.fn(async () => ({ availableModelIds: [] })), - generateCommitMessage: vi.fn(), - } as any, - logger: makeStubLogger(), - }); - - return { service, switchBranchMock, listBranchProfiles, listBranchOwners }; -} - -describe("gitOperationsService.listBranches annotations", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it("annotates branches with profile-in-lane and active owner metadata", async () => { - mockGit.runGitOrThrow.mockResolvedValue( - [ - "refs/heads/main\tmain\t \torigin/main", - "refs/heads/feature/source\tfeature/source\t*\t", - "refs/heads/feature/owned\tfeature/owned\t \t", - "refs/remotes/origin/feature/remote-only\torigin/feature/remote-only\t \t", - "refs/remotes/origin/main\torigin/main\t \t", - ].join("\n"), - ); - - const { service, listBranchProfiles, listBranchOwners } = makeServiceWithLanes({ - branchProfiles: [ - { branchRef: "feature/source" }, - { branchRef: "feature/profiled-but-no-local" }, - ], - lanes: [ - { id: "lane-1", name: "Source", branchRef: "feature/source", laneType: "worktree" }, - { id: "lane-2", name: "Owner Lane", branchRef: "feature/owned", laneType: "worktree" }, - { id: "lane-primary", name: "Primary", branchRef: "main", laneType: "primary" }, - ], - }); - - const branches = await service.listBranches({ laneId: "lane-1" }); - expect(listBranchProfiles).toHaveBeenCalledWith("lane-1"); - expect(listBranchOwners).toHaveBeenCalledWith({ excludeLaneId: "lane-1" }); - - const byName = new Map(branches.map((b) => [b.name, b])); - - // current branch (lane-1's own branch) is profiled in lane-1, owner skipped - // because it equals the calling lane's id. - const source = byName.get("feature/source"); - expect(source).toBeDefined(); - expect(source!.profiledInCurrentLane).toBe(true); - expect(source!.ownedByLaneId).toBeNull(); - expect(source!.ownedByLaneName).toBeNull(); - expect(source!.isCurrent).toBe(true); - - // lane-2 owns "feature/owned" - const owned = byName.get("feature/owned"); - expect(owned).toBeDefined(); - expect(owned!.ownedByLaneId).toBe("lane-2"); - expect(owned!.ownedByLaneName).toBe("Owner Lane"); - expect(owned!.profiledInCurrentLane).toBe(false); - - // primary lane branches are excluded from the active-owner map (so main is not "owned") - const main = byName.get("main"); - expect(main).toBeDefined(); - expect(main!.ownedByLaneId).toBeNull(); - expect(main!.profiledInCurrentLane).toBe(false); - - // remote-only branch is preserved and annotated; localBranchNameFromRemoteRef - // strips the remote name ("origin/feature/remote-only" → "feature/remote-only"). - const remoteOnly = byName.get("origin/feature/remote-only"); - expect(remoteOnly).toBeDefined(); - expect(remoteOnly!.isRemote).toBe(true); - expect(remoteOnly!.profiledInCurrentLane).toBe(false); - }); - - it("still returns branches when listing lanes throws (best-effort owner lookup)", async () => { - mockGit.runGitOrThrow.mockResolvedValue( - "refs/heads/main\tmain\t*\t\nrefs/heads/feature/x\tfeature/x\t \t", - ); - const { service } = makeServiceWithLanes({ listThrows: true }); - - const branches = await service.listBranches({ laneId: "lane-1" }); - expect(branches.length).toBeGreaterThan(0); - for (const branch of branches) { - expect(branch.ownedByLaneId).toBeNull(); - } - }); - - it("dedupes a remote ref when its local counterpart already exists", async () => { - mockGit.runGitOrThrow.mockResolvedValue( - [ - "refs/heads/feature/dup\tfeature/dup\t*\t", - "refs/remotes/origin/feature/dup\torigin/feature/dup\t \t", - ].join("\n"), - ); - const { service } = makeServiceWithLanes({}); - - const branches = await service.listBranches({ laneId: "lane-1" }); - // Only the local copy should appear; the remote duplicate is filtered out. - expect(branches.filter((b) => b.name === "feature/dup")).toHaveLength(1); - expect(branches.find((b) => b.name === "origin/feature/dup")).toBeUndefined(); - }); - - it("filters refs/remotes/.../HEAD entries out of the result", async () => { - mockGit.runGitOrThrow.mockResolvedValue( - [ - "refs/heads/main\tmain\t*\t", - "refs/remotes/origin/HEAD\torigin/HEAD\t \t", - ].join("\n"), - ); - const { service } = makeServiceWithLanes({}); - - const branches = await service.listBranches({ laneId: "lane-1" }); - expect(branches.find((b) => b.name === "origin/HEAD")).toBeUndefined(); - }); - - it("attaches last-commit metadata and rejoins tab-containing subjects", async () => { - // Subject is the last column so any tab inside it stays inside parts[7+] - // and is rejoined — see the FORMAT comment in gitOperationsService. - mockGit.runGitOrThrow.mockResolvedValue( - [ - [ - "refs/heads/feat/widget", - "feat/widget", - "*", - "origin/feat/widget", - "abc1234", - "2026-04-30T10:00:00+00:00", - "Arul Sharma", - "tweak\twidget alignment", - ].join("\t"), - [ - "refs/remotes/origin/feat/sidebar", - "origin/feat/sidebar", - " ", - "", - "def5678", - "2026-04-29T08:00:00+00:00", - "Jamie Lee", - "rebuild sidebar nav", - ].join("\t"), - ].join("\n"), - ); - - const { service } = makeServiceWithLanes({}); - const branches = await service.listBranches({ laneId: "lane-1" }); - const local = branches.find((b) => b.name === "feat/widget"); - expect(local).toBeDefined(); - expect(local!.lastCommitSha).toBe("abc1234"); - expect(local!.lastCommitDate).toBe("2026-04-30T10:00:00+00:00"); - expect(local!.lastCommitAuthor).toBe("Arul Sharma"); - expect(local!.lastCommitMessage).toBe("tweak\twidget alignment"); - - const remote = branches.find((b) => b.name === "origin/feat/sidebar"); - expect(remote).toBeDefined(); - expect(remote!.lastCommitSha).toBe("def5678"); - expect(remote!.lastCommitAuthor).toBe("Jamie Lee"); - expect(remote!.lastCommitMessage).toBe("rebuild sidebar nav"); - }); -}); - -describe("gitOperationsService.checkoutBranch", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it("rejects empty branch names", async () => { - const { service } = makeServiceWithLanes({}); - await expect(service.checkoutBranch({ laneId: "lane-1", branchName: " " })) - .rejects.toThrow(/Branch name is required/); - }); - - it("delegates to laneService.switchBranch and forwards mode/startPoint/baseRef in op metadata", async () => { - mockGit.getHeadSha.mockResolvedValue("sha-pre"); - const operationStart = vi.fn().mockReturnValue({ operationId: "op-99" }); - const operationFinish = vi.fn(); - const switchBranch = vi.fn().mockResolvedValue({ lane: { id: "lane-1" }, previousBranchRef: "feature/old", activeWork: [] }); - - const service = createGitOperationsService({ - laneService: { - getLaneBaseAndBranch: vi.fn().mockReturnValue({ - baseRef: "main", - branchRef: "feature/old", - worktreePath: "/tmp/ade-lane", - laneType: "worktree", - }), - listBranchProfiles: vi.fn().mockReturnValue([]), - listBranchOwners: vi.fn().mockReturnValue([]), - switchBranch, - } as any, - operationService: { start: operationStart, finish: operationFinish } as any, - projectConfigService: { get: () => ({ effective: { ai: {} } }) } as any, - aiIntegrationService: { - getFeatureFlag: () => false, - getStatus: vi.fn(async () => ({ availableModelIds: [] })), - generateCommitMessage: vi.fn(), - } as any, - logger: makeStubLogger(), - }); - - await service.checkoutBranch({ - laneId: "lane-1", - branchName: "feature/new", - mode: "create", - startPoint: "main", - baseRef: "main", - acknowledgeActiveWork: true, - }); - - expect(switchBranch).toHaveBeenCalledWith({ - laneId: "lane-1", - branchName: "feature/new", - mode: "create", - startPoint: "main", - baseRef: "main", - acknowledgeActiveWork: true, - }); - - expect(operationStart).toHaveBeenCalledWith( - expect.objectContaining({ - laneId: "lane-1", - kind: "git_checkout_branch", - metadata: expect.objectContaining({ - reason: "checkout_branch", - branchName: "feature/new", - mode: "create", - startPoint: "main", - baseRef: "main", - }), - }), - ); - expect(operationFinish).toHaveBeenCalledWith( - expect.objectContaining({ operationId: "op-99", status: "succeeded" }), - ); - }); - - it("defaults mode to 'existing' and nulls metadata for omitted optional args", async () => { - mockGit.getHeadSha.mockResolvedValue("sha-pre"); - const operationStart = vi.fn().mockReturnValue({ operationId: "op-1" }); - const switchBranch = vi.fn().mockResolvedValue({ lane: { id: "lane-1" }, previousBranchRef: "main", activeWork: [] }); - - const service = createGitOperationsService({ - laneService: { - getLaneBaseAndBranch: vi.fn().mockReturnValue({ - baseRef: "main", branchRef: "main", worktreePath: "/tmp/ade-lane", laneType: "worktree", - }), - listBranchProfiles: vi.fn().mockReturnValue([]), - listBranchOwners: vi.fn().mockReturnValue([]), - switchBranch, - } as any, - operationService: { start: operationStart, finish: vi.fn() } as any, - projectConfigService: { get: () => ({ effective: { ai: {} } }) } as any, - aiIntegrationService: { - getFeatureFlag: () => false, - getStatus: vi.fn(async () => ({ availableModelIds: [] })), - generateCommitMessage: vi.fn(), - } as any, - logger: makeStubLogger(), - }); - - await service.checkoutBranch({ laneId: "lane-1", branchName: "feature/foo" }); - - expect(operationStart).toHaveBeenCalledWith( - expect.objectContaining({ - metadata: expect.objectContaining({ - mode: "existing", - startPoint: null, - baseRef: null, - }), - }), - ); - // switchBranch still receives the raw args (no defaults injected upstream). - expect(switchBranch).toHaveBeenCalledWith({ laneId: "lane-1", branchName: "feature/foo" }); - }); -}); diff --git a/apps/desktop/src/main/services/git/gitOperationsService.test.ts b/apps/desktop/src/main/services/git/gitOperationsService.test.ts index 133e17ffd..981da71e1 100644 --- a/apps/desktop/src/main/services/git/gitOperationsService.test.ts +++ b/apps/desktop/src/main/services/git/gitOperationsService.test.ts @@ -1171,3 +1171,323 @@ describe("gitOperationsService cached lane reads", () => { ]); }); }); + +// --------------------------------------------------------------------------- +// branchSwitch (merged from gitOperationsService.branchSwitch.test.ts) +// --------------------------------------------------------------------------- + +function makeStubLogger() { + return { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + } as any; +} + +function makeServiceWithLanes(opts: { + branchProfiles?: Array<{ branchRef: string }>; + lanes?: Array<{ id: string; name: string; branchRef: string; laneType: string }>; + switchBranch?: ReturnType; + listBranchProfilesThrows?: boolean; + listThrows?: boolean; +}) { + const switchBranchMock = opts.switchBranch ?? vi.fn().mockResolvedValue({ lane: { id: "lane-1" }, previousBranchRef: "feature/old", activeWork: [] }); + + const listBranchProfiles = opts.listBranchProfilesThrows + ? vi.fn(() => { throw new Error("profile lookup failed"); }) + : vi.fn().mockReturnValue(opts.branchProfiles ?? []); + + const lanes = opts.lanes ?? []; + const listBranchOwners = opts.listThrows + ? vi.fn(() => { throw new Error("owner lookup failed"); }) + : vi.fn(({ excludeLaneId }: { excludeLaneId?: string } = {}) => + lanes + .filter((l) => l.laneType !== "primary" && l.id !== excludeLaneId) + .map((l) => ({ id: l.id, name: l.name, branchRef: l.branchRef })), + ); + + const service = createGitOperationsService({ + laneService: { + getLaneBaseAndBranch: vi.fn().mockReturnValue({ + baseRef: "main", + branchRef: "feature/source", + worktreePath: "/tmp/ade-lane", + laneType: "worktree", + }), + listBranchProfiles, + listBranchOwners, + switchBranch: switchBranchMock, + } as any, + operationService: { + start: vi.fn().mockReturnValue({ operationId: "op-1" }), + finish: vi.fn(), + } as any, + projectConfigService: { + get: () => ({ effective: { ai: {} } }), + } as any, + aiIntegrationService: { + getFeatureFlag: () => false, + getStatus: vi.fn(async () => ({ availableModelIds: [] })), + generateCommitMessage: vi.fn(), + } as any, + logger: makeStubLogger(), + }); + + return { service, switchBranchMock, listBranchProfiles, listBranchOwners }; +} + +describe("gitOperationsService.listBranches annotations", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("annotates branches with profile-in-lane and active owner metadata", async () => { + mockGit.runGitOrThrow.mockResolvedValue( + [ + "refs/heads/main\tmain\t \torigin/main", + "refs/heads/feature/source\tfeature/source\t*\t", + "refs/heads/feature/owned\tfeature/owned\t \t", + "refs/remotes/origin/feature/remote-only\torigin/feature/remote-only\t \t", + "refs/remotes/origin/main\torigin/main\t \t", + ].join("\n"), + ); + + const { service, listBranchProfiles, listBranchOwners } = makeServiceWithLanes({ + branchProfiles: [ + { branchRef: "feature/source" }, + { branchRef: "feature/profiled-but-no-local" }, + ], + lanes: [ + { id: "lane-1", name: "Source", branchRef: "feature/source", laneType: "worktree" }, + { id: "lane-2", name: "Owner Lane", branchRef: "feature/owned", laneType: "worktree" }, + { id: "lane-primary", name: "Primary", branchRef: "main", laneType: "primary" }, + ], + }); + + const branches = await service.listBranches({ laneId: "lane-1" }); + expect(listBranchProfiles).toHaveBeenCalledWith("lane-1"); + expect(listBranchOwners).toHaveBeenCalledWith({ excludeLaneId: "lane-1" }); + + const byName = new Map(branches.map((b) => [b.name, b])); + + const source = byName.get("feature/source"); + expect(source).toBeDefined(); + expect(source!.profiledInCurrentLane).toBe(true); + expect(source!.ownedByLaneId).toBeNull(); + expect(source!.ownedByLaneName).toBeNull(); + expect(source!.isCurrent).toBe(true); + + const owned = byName.get("feature/owned"); + expect(owned).toBeDefined(); + expect(owned!.ownedByLaneId).toBe("lane-2"); + expect(owned!.ownedByLaneName).toBe("Owner Lane"); + expect(owned!.profiledInCurrentLane).toBe(false); + + const main = byName.get("main"); + expect(main).toBeDefined(); + expect(main!.ownedByLaneId).toBeNull(); + expect(main!.profiledInCurrentLane).toBe(false); + + const remoteOnly = byName.get("origin/feature/remote-only"); + expect(remoteOnly).toBeDefined(); + expect(remoteOnly!.isRemote).toBe(true); + expect(remoteOnly!.profiledInCurrentLane).toBe(false); + }); + + it("still returns branches when listing lanes throws (best-effort owner lookup)", async () => { + mockGit.runGitOrThrow.mockResolvedValue( + "refs/heads/main\tmain\t*\t\nrefs/heads/feature/x\tfeature/x\t \t", + ); + const { service } = makeServiceWithLanes({ listThrows: true }); + + const branches = await service.listBranches({ laneId: "lane-1" }); + expect(branches.length).toBeGreaterThan(0); + for (const branch of branches) { + expect(branch.ownedByLaneId).toBeNull(); + } + }); + + it("dedupes a remote ref when its local counterpart already exists", async () => { + mockGit.runGitOrThrow.mockResolvedValue( + [ + "refs/heads/feature/dup\tfeature/dup\t*\t", + "refs/remotes/origin/feature/dup\torigin/feature/dup\t \t", + ].join("\n"), + ); + const { service } = makeServiceWithLanes({}); + + const branches = await service.listBranches({ laneId: "lane-1" }); + expect(branches.filter((b) => b.name === "feature/dup")).toHaveLength(1); + expect(branches.find((b) => b.name === "origin/feature/dup")).toBeUndefined(); + }); + + it("filters refs/remotes/.../HEAD entries out of the result", async () => { + mockGit.runGitOrThrow.mockResolvedValue( + [ + "refs/heads/main\tmain\t*\t", + "refs/remotes/origin/HEAD\torigin/HEAD\t \t", + ].join("\n"), + ); + const { service } = makeServiceWithLanes({}); + + const branches = await service.listBranches({ laneId: "lane-1" }); + expect(branches.find((b) => b.name === "origin/HEAD")).toBeUndefined(); + }); + + it("attaches last-commit metadata and rejoins tab-containing subjects", async () => { + mockGit.runGitOrThrow.mockResolvedValue( + [ + [ + "refs/heads/feat/widget", + "feat/widget", + "*", + "origin/feat/widget", + "abc1234", + "2026-04-30T10:00:00+00:00", + "Arul Sharma", + "tweak\twidget alignment", + ].join("\t"), + [ + "refs/remotes/origin/feat/sidebar", + "origin/feat/sidebar", + " ", + "", + "def5678", + "2026-04-29T08:00:00+00:00", + "Jamie Lee", + "rebuild sidebar nav", + ].join("\t"), + ].join("\n"), + ); + + const { service } = makeServiceWithLanes({}); + const branches = await service.listBranches({ laneId: "lane-1" }); + const local = branches.find((b) => b.name === "feat/widget"); + expect(local).toBeDefined(); + expect(local!.lastCommitSha).toBe("abc1234"); + expect(local!.lastCommitDate).toBe("2026-04-30T10:00:00+00:00"); + expect(local!.lastCommitAuthor).toBe("Arul Sharma"); + expect(local!.lastCommitMessage).toBe("tweak\twidget alignment"); + + const remote = branches.find((b) => b.name === "origin/feat/sidebar"); + expect(remote).toBeDefined(); + expect(remote!.lastCommitSha).toBe("def5678"); + expect(remote!.lastCommitAuthor).toBe("Jamie Lee"); + expect(remote!.lastCommitMessage).toBe("rebuild sidebar nav"); + }); +}); + +describe("gitOperationsService.checkoutBranch", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("rejects empty branch names", async () => { + const { service } = makeServiceWithLanes({}); + await expect(service.checkoutBranch({ laneId: "lane-1", branchName: " " })) + .rejects.toThrow(/Branch name is required/); + }); + + it("delegates to laneService.switchBranch and forwards mode/startPoint/baseRef in op metadata", async () => { + mockGit.getHeadSha.mockResolvedValue("sha-pre"); + const operationStart = vi.fn().mockReturnValue({ operationId: "op-99" }); + const operationFinish = vi.fn(); + const switchBranch = vi.fn().mockResolvedValue({ lane: { id: "lane-1" }, previousBranchRef: "feature/old", activeWork: [] }); + + const service = createGitOperationsService({ + laneService: { + getLaneBaseAndBranch: vi.fn().mockReturnValue({ + baseRef: "main", + branchRef: "feature/old", + worktreePath: "/tmp/ade-lane", + laneType: "worktree", + }), + listBranchProfiles: vi.fn().mockReturnValue([]), + listBranchOwners: vi.fn().mockReturnValue([]), + switchBranch, + } as any, + operationService: { start: operationStart, finish: operationFinish } as any, + projectConfigService: { get: () => ({ effective: { ai: {} } }) } as any, + aiIntegrationService: { + getFeatureFlag: () => false, + getStatus: vi.fn(async () => ({ availableModelIds: [] })), + generateCommitMessage: vi.fn(), + } as any, + logger: makeStubLogger(), + }); + + await service.checkoutBranch({ + laneId: "lane-1", + branchName: "feature/new", + mode: "create", + startPoint: "main", + baseRef: "main", + acknowledgeActiveWork: true, + }); + + expect(switchBranch).toHaveBeenCalledWith({ + laneId: "lane-1", + branchName: "feature/new", + mode: "create", + startPoint: "main", + baseRef: "main", + acknowledgeActiveWork: true, + }); + + expect(operationStart).toHaveBeenCalledWith( + expect.objectContaining({ + laneId: "lane-1", + kind: "git_checkout_branch", + metadata: expect.objectContaining({ + reason: "checkout_branch", + branchName: "feature/new", + mode: "create", + startPoint: "main", + baseRef: "main", + }), + }), + ); + expect(operationFinish).toHaveBeenCalledWith( + expect.objectContaining({ operationId: "op-99", status: "succeeded" }), + ); + }); + + it("defaults mode to 'existing' and nulls metadata for omitted optional args", async () => { + mockGit.getHeadSha.mockResolvedValue("sha-pre"); + const operationStart = vi.fn().mockReturnValue({ operationId: "op-1" }); + const switchBranch = vi.fn().mockResolvedValue({ lane: { id: "lane-1" }, previousBranchRef: "main", activeWork: [] }); + + const service = createGitOperationsService({ + laneService: { + getLaneBaseAndBranch: vi.fn().mockReturnValue({ + baseRef: "main", branchRef: "main", worktreePath: "/tmp/ade-lane", laneType: "worktree", + }), + listBranchProfiles: vi.fn().mockReturnValue([]), + listBranchOwners: vi.fn().mockReturnValue([]), + switchBranch, + } as any, + operationService: { start: operationStart, finish: vi.fn() } as any, + projectConfigService: { get: () => ({ effective: { ai: {} } }) } as any, + aiIntegrationService: { + getFeatureFlag: () => false, + getStatus: vi.fn(async () => ({ availableModelIds: [] })), + generateCommitMessage: vi.fn(), + } as any, + logger: makeStubLogger(), + }); + + await service.checkoutBranch({ laneId: "lane-1", branchName: "feature/foo" }); + + expect(operationStart).toHaveBeenCalledWith( + expect.objectContaining({ + metadata: expect.objectContaining({ + mode: "existing", + startPoint: null, + baseRef: null, + }), + }), + ); + expect(switchBranch).toHaveBeenCalledWith({ laneId: "lane-1", branchName: "feature/foo" }); + }); +}); diff --git a/apps/desktop/src/main/services/ipc/registerIpc.ts b/apps/desktop/src/main/services/ipc/registerIpc.ts index b44b31bd4..70bb47a03 100644 --- a/apps/desktop/src/main/services/ipc/registerIpc.ts +++ b/apps/desktop/src/main/services/ipc/registerIpc.ts @@ -618,7 +618,6 @@ import type { CursorCloudRepository, CursorCloudOpenChatRequest, CursorCloudOpenChatResult, - CursorCloudStreamRunRequest, CursorCloudStreamRunResult, } from "../../../shared/types"; import type { Logger } from "../logging/logger"; @@ -9915,31 +9914,6 @@ export function registerIpc({ }); }; - // Re-run ApnsService.configure when we have both a stored key and valid config. - const reconfigureApnsIfReady = (): void => { - const ctx = getCtx(); - const effective = ctx.projectConfigService?.get?.()?.effective; - const apnsConfig = effective?.notifications?.apns ?? null; - if (!ctx.apnsService || !ctx.apnsKeyStore) return; - if (!apnsConfig?.enabled) return; - if (!apnsConfig.keyId || !apnsConfig.teamId || !apnsConfig.bundleId) return; - if (!ctx.apnsKeyStore.has()) return; - try { - const pem = ctx.apnsKeyStore.load(); - if (!pem) return; - ctx.apnsService.configure({ - keyP8Pem: pem, - keyId: apnsConfig.keyId, - teamId: apnsConfig.teamId, - bundleId: apnsConfig.bundleId, - env: apnsConfig.env === "production" ? "production" : "sandbox", - }); - } catch (error) { - // Surface to the caller via status; don't crash the handler. - console.warn("apns.reconfigure_failed", error); - } - }; - ipcMain.handle(IPC.notificationsApnsGetStatus, async (): Promise => { return readApnsStatus(); }); diff --git a/apps/desktop/src/main/services/lanes/laneService.branchSwitch.test.ts b/apps/desktop/src/main/services/lanes/laneService.branchSwitch.test.ts deleted file mode 100644 index 75507dfc2..000000000 --- a/apps/desktop/src/main/services/lanes/laneService.branchSwitch.test.ts +++ /dev/null @@ -1,634 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { openKvDb } from "../state/kvDb"; -import { createLaneService } from "./laneService"; - -vi.mock("../git/git", () => ({ - getHeadSha: vi.fn(), - runGit: vi.fn(), - runGitOrThrow: vi.fn(), -})); - -import { getHeadSha, runGit, runGitOrThrow } from "../git/git"; - -function createLogger() { - return { - debug: () => {}, - info: () => {}, - warn: () => {}, - error: () => {}, - } as any; -} - -const NOW = "2026-04-25T10:00:00.000Z"; - -function seedProject(db: any, args: { projectId: string; repoRoot: string }) { - db.run( - "insert into projects(id, root_path, display_name, default_base_ref, created_at, last_opened_at) values (?, ?, ?, ?, ?, ?)", - [args.projectId, args.repoRoot, "demo", "main", NOW, NOW], - ); -} - -function insertLane(db: any, args: { - id: string; - projectId: string; - name: string; - laneType: "primary" | "worktree"; - branchRef: string; - baseRef?: string; - worktreePath: string; - parentLaneId?: string | null; - status?: string; -}) { - db.run( - `insert into lanes( - id, project_id, name, description, lane_type, base_ref, branch_ref, worktree_path, - attached_root_path, is_edit_protected, parent_lane_id, color, icon, tags_json, status, created_at, archived_at - ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - [ - args.id, - args.projectId, - args.name, - null, - args.laneType, - args.baseRef ?? "main", - args.branchRef, - args.worktreePath, - null, - args.laneType === "primary" ? 1 : 0, - args.parentLaneId ?? null, - null, - null, - null, - args.status ?? "active", - NOW, - args.status === "archived" ? NOW : null, - ], - ); -} - -/** - * Make a generic runGit responder that returns success for the most common - * read-only ancillary calls performed by listLanes(). Test-specific behaviour - * can be layered on top. - */ -function makeRunGitResponder(custom?: (args: string[], opts: any) => { exitCode: number; stdout: string; stderr: string } | null) { - return async (args: string[], opts: any = {}) => { - if (custom) { - const v = custom(args, opts); - if (v) return v; - } - if (args[0] === "rev-parse" && args[1] === "--abbrev-ref" && args[2] === "HEAD") { - return { exitCode: 0, stdout: "main\n", stderr: "" }; - } - if (args[0] === "rev-parse" && args[1] === "--path-format=absolute" && args[2] === "--git-dir") { - return { exitCode: 1, stdout: "", stderr: "fatal: no git dir" }; - } - if (args[0] === "rev-parse" && args[1] === "--abbrev-ref" && args[2] === "--symbolic-full-name" && args[3] === "@{upstream}") { - return { exitCode: 1, stdout: "", stderr: "no upstream" }; - } - if (args[0] === "rev-parse" && args[1] === "@{upstream}") { - return { exitCode: 1, stdout: "", stderr: "no upstream" }; - } - if (args[0] === "rev-list" && args[1] === "--left-right" && args[2] === "--count") { - return { exitCode: 0, stdout: "0\t0\n", stderr: "" }; - } - if (args[0] === "status" && args[1] === "--porcelain=v1") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - return { exitCode: 1, stdout: "", stderr: `unhandled: ${args.join(" ")}` }; - }; -} - -function makeService(db: any, projectRoot: string, projectId: string) { - return createLaneService({ - db, - projectRoot, - projectId, - defaultBaseRef: "main", - worktreesDir: path.join(projectRoot, "worktrees"), - logger: createLogger(), - }); -} - -describe("laneService.listBranchProfiles", () => { - beforeEach(() => { - vi.mocked(getHeadSha).mockReset(); - vi.mocked(runGit).mockReset(); - vi.mocked(runGitOrThrow).mockReset(); - }); - - it("ensures and returns a profile for the lane's current branch_ref", async () => { - const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-bsw-list-profile-")); - const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); - try { - seedProject(db, { projectId: "proj-1", repoRoot }); - insertLane(db, { id: "lane-a", projectId: "proj-1", name: "Lane A", laneType: "worktree", branchRef: "feature/lane-a", worktreePath: path.join(repoRoot, "lane-a") }); - - vi.mocked(runGit).mockImplementation(makeRunGitResponder() as any); - - const service = makeService(db, repoRoot, "proj-1"); - const profiles = service.listBranchProfiles("lane-a"); - - expect(profiles).toHaveLength(1); - expect(profiles[0]?.branchRef).toBe("feature/lane-a"); - expect(profiles[0]?.laneId).toBe("lane-a"); - expect(profiles[0]?.baseRef).toBe("main"); - - // Calling again should not duplicate. - const second = service.listBranchProfiles("lane-a"); - expect(second).toHaveLength(1); - expect(second[0]?.id).toBe(profiles[0]?.id); - } finally { - db.close(); - fs.rmSync(repoRoot, { recursive: true, force: true }); - } - }); - - it("throws when the lane is missing", async () => { - const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-bsw-list-missing-")); - const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); - try { - seedProject(db, { projectId: "proj-1", repoRoot }); - const service = makeService(db, repoRoot, "proj-1"); - expect(() => service.listBranchProfiles("nonexistent")).toThrow(/Lane not found/); - } finally { - db.close(); - fs.rmSync(repoRoot, { recursive: true, force: true }); - } - }); -}); - -describe("laneService.updateBranchRef", () => { - beforeEach(() => { - vi.mocked(getHeadSha).mockReset(); - vi.mocked(runGit).mockReset(); - vi.mocked(runGitOrThrow).mockReset(); - }); - - it("updates the lane's branch_ref AND upserts a matching branch profile", async () => { - const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-bsw-update-bref-")); - const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); - try { - seedProject(db, { projectId: "proj-1", repoRoot }); - insertLane(db, { id: "lane-a", projectId: "proj-1", name: "Lane A", laneType: "worktree", branchRef: "feature/lane-a", worktreePath: path.join(repoRoot, "lane-a") }); - - vi.mocked(runGit).mockImplementation(makeRunGitResponder() as any); - - const service = makeService(db, repoRoot, "proj-1"); - - service.updateBranchRef("lane-a", "feature/renamed"); - - const updated = db.get<{ branch_ref: string }>( - "select branch_ref from lanes where id = ? and project_id = ?", - ["lane-a", "proj-1"], - ); - expect(updated?.branch_ref).toBe("feature/renamed"); - - const profiles = service.listBranchProfiles("lane-a"); - const refs = profiles.map((p) => p.branchRef); - expect(refs).toContain("feature/renamed"); - const renamed = profiles.find((p) => p.branchRef === "feature/renamed"); - expect(renamed?.lastCheckedOutAt).toBeTruthy(); - } finally { - db.close(); - fs.rmSync(repoRoot, { recursive: true, force: true }); - } - }); -}); - -describe("laneService.previewBranchSwitch", () => { - beforeEach(() => { - vi.mocked(getHeadSha).mockReset(); - vi.mocked(runGit).mockReset(); - vi.mocked(runGitOrThrow).mockReset(); - }); - - it("rejects when laneId is empty", async () => { - const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-bsw-prev-laneid-")); - const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); - try { - seedProject(db, { projectId: "proj-1", repoRoot }); - const service = makeService(db, repoRoot, "proj-1"); - await expect(service.previewBranchSwitch({ laneId: "", branchName: "x" })).rejects.toThrow(/laneId is required/); - } finally { - db.close(); - fs.rmSync(repoRoot, { recursive: true, force: true }); - } - }); - - it("rejects when branchName is empty", async () => { - const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-bsw-prev-branchname-")); - const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); - try { - seedProject(db, { projectId: "proj-1", repoRoot }); - insertLane(db, { id: "lane-a", projectId: "proj-1", name: "Lane A", laneType: "worktree", branchRef: "feature/lane-a", worktreePath: path.join(repoRoot, "lane-a") }); - const service = makeService(db, repoRoot, "proj-1"); - await expect(service.previewBranchSwitch({ laneId: "lane-a", branchName: " " })).rejects.toThrow(/Branch name is required/); - } finally { - db.close(); - fs.rmSync(repoRoot, { recursive: true, force: true }); - } - }); - - it("rejects when the lane is archived", async () => { - const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-bsw-prev-archived-")); - const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); - try { - seedProject(db, { projectId: "proj-1", repoRoot }); - insertLane(db, { - id: "lane-archived", projectId: "proj-1", name: "Archived", laneType: "worktree", - branchRef: "feature/old", worktreePath: path.join(repoRoot, "old"), status: "archived", - }); - const service = makeService(db, repoRoot, "proj-1"); - await expect(service.previewBranchSwitch({ laneId: "lane-archived", branchName: "main" })).rejects.toThrow(/archived/); - } finally { - db.close(); - fs.rmSync(repoRoot, { recursive: true, force: true }); - } - }); - - it("flags dirty worktree, duplicate owner, and active terminal sessions", async () => { - const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-bsw-prev-flags-")); - const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); - try { - seedProject(db, { projectId: "proj-1", repoRoot }); - insertLane(db, { id: "lane-src", projectId: "proj-1", name: "Source", laneType: "worktree", branchRef: "feature/src", worktreePath: path.join(repoRoot, "src") }); - insertLane(db, { id: "lane-other", projectId: "proj-1", name: "Other Lane", laneType: "worktree", branchRef: "feature/target", worktreePath: path.join(repoRoot, "other") }); - - // Active terminal session on lane-src. - db.run( - `insert into terminal_sessions(id, lane_id, tracked, title, started_at, status, transcript_path) - values (?, ?, ?, ?, ?, ?, ?)`, - ["term-1", "lane-src", 1, "shell", NOW, "running", path.join(repoRoot, "t.log")], - ); - // Active process_runtime row on lane-src. - db.run( - `insert into process_runtime(project_id, lane_id, process_key, status, readiness, updated_at) - values (?, ?, ?, ?, ?, ?)`, - ["proj-1", "lane-src", "vite", "running", "ready", NOW], - ); - - // Dirty worktree; target branch resolves locally to keep the same key as lane-other. - vi.mocked(runGit).mockImplementation(makeRunGitResponder((args, opts) => { - if (args[0] === "show-ref" && args[1] === "--verify" && args[2] === "--quiet") { - if (args[3] === "refs/heads/feature/target") return { exitCode: 0, stdout: "", stderr: "" }; - } - if (args[0] === "status" && args[1] === "--porcelain=v1" && opts.cwd === path.join(repoRoot, "src")) { - return { exitCode: 0, stdout: " M file.ts\n", stderr: "" }; - } - return null; - }) as any); - - const service = makeService(db, repoRoot, "proj-1"); - const preview = await service.previewBranchSwitch({ laneId: "lane-src", branchName: "feature/target" }); - - expect(preview.laneId).toBe("lane-src"); - expect(preview.dirty).toBe(true); - expect(preview.duplicateLaneId).toBe("lane-other"); - expect(preview.duplicateLaneName).toBe("Other Lane"); - expect(preview.activeWork.length).toBeGreaterThanOrEqual(2); - expect(preview.activeWork.some((w) => w.kind === "terminal")).toBe(true); - expect(preview.activeWork.some((w) => w.kind === "process")).toBe(true); - expect(preview.targetBranchRef).toBe("feature/target"); - expect(preview.mode).toBe("existing"); - } finally { - db.close(); - fs.rmSync(repoRoot, { recursive: true, force: true }); - } - }); - - it("strips remote prefix when only the remote ref exists", async () => { - const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-bsw-prev-remote-")); - const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); - try { - seedProject(db, { projectId: "proj-1", repoRoot }); - insertLane(db, { id: "lane-x", projectId: "proj-1", name: "X", laneType: "worktree", branchRef: "feature/x", worktreePath: path.join(repoRoot, "x") }); - - vi.mocked(runGit).mockImplementation(makeRunGitResponder((args) => { - if (args[0] === "show-ref" && args[1] === "--verify" && args[2] === "--quiet") { - if (args[3] === "refs/heads/origin/foo") return { exitCode: 1, stdout: "", stderr: "" }; - if (args[3] === "refs/remotes/origin/foo") return { exitCode: 0, stdout: "", stderr: "" }; - } - return null; - }) as any); - - const service = makeService(db, repoRoot, "proj-1"); - const preview = await service.previewBranchSwitch({ laneId: "lane-x", branchName: "origin/foo" }); - expect(preview.targetBranchRef).toBe("foo"); - expect(preview.dirty).toBe(false); - expect(preview.duplicateLaneId).toBeNull(); - } finally { - db.close(); - fs.rmSync(repoRoot, { recursive: true, force: true }); - } - }); - - it("returns mode=create without consulting refs when explicitly requested", async () => { - const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-bsw-prev-create-")); - const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); - try { - seedProject(db, { projectId: "proj-1", repoRoot }); - insertLane(db, { id: "lane-y", projectId: "proj-1", name: "Y", laneType: "worktree", branchRef: "feature/y", worktreePath: path.join(repoRoot, "y") }); - - const showRefCalls: string[] = []; - vi.mocked(runGit).mockImplementation(makeRunGitResponder((args) => { - if (args[0] === "show-ref") showRefCalls.push(args.join(" ")); - return null; - }) as any); - - const service = makeService(db, repoRoot, "proj-1"); - const preview = await service.previewBranchSwitch({ laneId: "lane-y", branchName: "feature/new", mode: "create" }); - - expect(preview.mode).toBe("create"); - expect(preview.targetBranchRef).toBe("feature/new"); - // create mode should NOT probe local/remote refs to resolve an existing branch. - expect(showRefCalls).toHaveLength(0); - } finally { - db.close(); - fs.rmSync(repoRoot, { recursive: true, force: true }); - } - }); -}); - -describe("laneService.switchBranch", () => { - beforeEach(() => { - vi.mocked(getHeadSha).mockReset(); - vi.mocked(runGit).mockReset(); - vi.mocked(runGitOrThrow).mockReset(); - }); - - it("refuses to switch when the lane has uncommitted changes", async () => { - const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-bsw-switch-dirty-")); - const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); - try { - seedProject(db, { projectId: "proj-1", repoRoot }); - insertLane(db, { id: "lane-d", projectId: "proj-1", name: "D", laneType: "worktree", branchRef: "feature/d", worktreePath: path.join(repoRoot, "d") }); - - vi.mocked(runGit).mockImplementation(makeRunGitResponder((args, opts) => { - if (args[0] === "show-ref" && args[3] === "refs/heads/main") return { exitCode: 0, stdout: "", stderr: "" }; - if (args[0] === "status" && args[1] === "--porcelain=v1" && opts.cwd === path.join(repoRoot, "d")) { - return { exitCode: 0, stdout: " M src/foo.ts\n", stderr: "" }; - } - return null; - }) as any); - - const service = makeService(db, repoRoot, "proj-1"); - await expect(service.switchBranch({ laneId: "lane-d", branchName: "main" })) - .rejects.toThrow(/uncommitted changes/); - } finally { - db.close(); - fs.rmSync(repoRoot, { recursive: true, force: true }); - } - }); - - it("refuses to switch to a branch that is already active in another lane", async () => { - const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-bsw-switch-dup-")); - const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); - try { - seedProject(db, { projectId: "proj-1", repoRoot }); - insertLane(db, { id: "lane-src", projectId: "proj-1", name: "Source", laneType: "worktree", branchRef: "feature/src", worktreePath: path.join(repoRoot, "src") }); - insertLane(db, { id: "lane-other", projectId: "proj-1", name: "Other Lane", laneType: "worktree", branchRef: "feature/duplicate", worktreePath: path.join(repoRoot, "other") }); - - vi.mocked(runGit).mockImplementation(makeRunGitResponder((args) => { - if (args[0] === "show-ref" && args[3] === "refs/heads/feature/duplicate") return { exitCode: 0, stdout: "", stderr: "" }; - return null; - }) as any); - - const service = makeService(db, repoRoot, "proj-1"); - await expect(service.switchBranch({ laneId: "lane-src", branchName: "feature/duplicate" })) - .rejects.toThrow(/already active in lane 'Other Lane'/); - } finally { - db.close(); - fs.rmSync(repoRoot, { recursive: true, force: true }); - } - }); - - it("refuses to switch when active work exists and acknowledgeActiveWork is not set", async () => { - const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-bsw-switch-active-")); - const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); - try { - seedProject(db, { projectId: "proj-1", repoRoot }); - insertLane(db, { id: "lane-a", projectId: "proj-1", name: "A", laneType: "worktree", branchRef: "feature/a", worktreePath: path.join(repoRoot, "a") }); - - db.run( - `insert into terminal_sessions(id, lane_id, tracked, title, started_at, status, transcript_path) - values (?, ?, ?, ?, ?, ?, ?)`, - ["t-1", "lane-a", 1, "shell", NOW, "running", path.join(repoRoot, "t.log")], - ); - - vi.mocked(runGit).mockImplementation(makeRunGitResponder((args) => { - if (args[0] === "show-ref" && args[3] === "refs/heads/main") return { exitCode: 0, stdout: "", stderr: "" }; - return null; - }) as any); - - const service = makeService(db, repoRoot, "proj-1"); - await expect(service.switchBranch({ laneId: "lane-a", branchName: "main" })) - .rejects.toThrow(/active sessions or processes/); - } finally { - db.close(); - fs.rmSync(repoRoot, { recursive: true, force: true }); - } - }); - - it("checks out an existing local branch and updates the lane row + branch profile", async () => { - const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-bsw-switch-existing-")); - const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); - try { - seedProject(db, { projectId: "proj-1", repoRoot }); - insertLane(db, { id: "lane-main", projectId: "proj-1", name: "Main", laneType: "primary", branchRef: "main", worktreePath: repoRoot }); - insertLane(db, { id: "lane-a", projectId: "proj-1", name: "A", laneType: "worktree", branchRef: "feature/a", worktreePath: path.join(repoRoot, "a") }); - - const checkoutCalls: string[][] = []; - vi.mocked(runGitOrThrow).mockImplementation(async (args: string[]) => { - if (args[0] === "checkout") checkoutCalls.push(args); - return { exitCode: 0, stdout: "", stderr: "" } as any; - }); - vi.mocked(runGit).mockImplementation(makeRunGitResponder((args) => { - if (args[0] === "show-ref" && args[3] === "refs/heads/feature/b") return { exitCode: 0, stdout: "", stderr: "" }; - return null; - }) as any); - - const service = makeService(db, repoRoot, "proj-1"); - const result = await service.switchBranch({ laneId: "lane-a", branchName: "feature/b" }); - - expect(result.previousBranchRef).toBe("feature/a"); - expect(result.lane.branchRef).toBe("feature/b"); - expect(result.lane.id).toBe("lane-a"); - expect(checkoutCalls.some((cmd) => cmd.includes("feature/b") && !cmd.includes("--track"))).toBe(true); - - const row = db.get<{ branch_ref: string }>( - "select branch_ref from lanes where id = ? and project_id = ?", - ["lane-a", "proj-1"], - ); - expect(row?.branch_ref).toBe("feature/b"); - - const profiles = service.listBranchProfiles("lane-a"); - expect(profiles.map((p) => p.branchRef)).toEqual(expect.arrayContaining(["feature/a", "feature/b"])); - } finally { - db.close(); - fs.rmSync(repoRoot, { recursive: true, force: true }); - } - }); - - it("creates a new branch via 'checkout -b' when mode='create'", async () => { - const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-bsw-switch-create-")); - const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); - try { - seedProject(db, { projectId: "proj-1", repoRoot }); - insertLane(db, { id: "lane-main", projectId: "proj-1", name: "Main", laneType: "primary", branchRef: "main", worktreePath: repoRoot }); - insertLane(db, { id: "lane-c", projectId: "proj-1", name: "C", laneType: "worktree", branchRef: "feature/c", worktreePath: path.join(repoRoot, "c") }); - - const checkoutCalls: string[][] = []; - vi.mocked(runGitOrThrow).mockImplementation(async (args: string[]) => { - if (args[0] === "checkout") checkoutCalls.push(args); - return { exitCode: 0, stdout: "", stderr: "" } as any; - }); - - vi.mocked(runGit).mockImplementation(makeRunGitResponder((args) => { - if (args[0] === "rev-parse" && args[1] === "--verify" && args[2] === "main") { - return { exitCode: 0, stdout: "sha-main\n", stderr: "" }; - } - if (args[0] === "rev-parse" && args[1] === "--verify" && args[2] === "feature/c") { - return { exitCode: 0, stdout: "sha-c\n", stderr: "" }; - } - if (args[0] === "show-ref" && args[3] === "refs/heads/feature/new") { - return { exitCode: 1, stdout: "", stderr: "" }; // does not exist yet - } - return null; - }) as any); - - const service = makeService(db, repoRoot, "proj-1"); - const result = await service.switchBranch({ - laneId: "lane-c", - branchName: "feature/new", - mode: "create", - baseRef: "main", - }); - - expect(result.previousBranchRef).toBe("feature/c"); - expect(result.lane.branchRef).toBe("feature/new"); - expect(checkoutCalls.some((cmd) => cmd[0] === "checkout" && cmd[1] === "-b" && cmd[2] === "feature/new")).toBe(true); - - const profile = service.listBranchProfiles("lane-c").find((p) => p.branchRef === "feature/new"); - expect(profile?.sourceBranchRef).toBe("feature/c"); - expect(profile?.baseRef).toBe("main"); - } finally { - db.close(); - fs.rmSync(repoRoot, { recursive: true, force: true }); - } - }); - - it("rejects mode='create' when baseRef is missing", async () => { - const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-bsw-switch-create-no-base-")); - const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); - try { - seedProject(db, { projectId: "proj-1", repoRoot }); - insertLane(db, { id: "lane-c", projectId: "proj-1", name: "C", laneType: "worktree", branchRef: "feature/c", worktreePath: path.join(repoRoot, "c") }); - - vi.mocked(runGit).mockImplementation(makeRunGitResponder() as any); - - const service = makeService(db, repoRoot, "proj-1"); - await expect(service.switchBranch({ laneId: "lane-c", branchName: "feature/new", mode: "create" })) - .rejects.toThrow(/Base branch is required/); - } finally { - db.close(); - fs.rmSync(repoRoot, { recursive: true, force: true }); - } - }); - - it("rejects mode='create' when the target branch already exists locally", async () => { - const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-bsw-switch-create-exists-")); - const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); - try { - seedProject(db, { projectId: "proj-1", repoRoot }); - insertLane(db, { id: "lane-c", projectId: "proj-1", name: "C", laneType: "worktree", branchRef: "feature/c", worktreePath: path.join(repoRoot, "c") }); - - vi.mocked(runGit).mockImplementation(makeRunGitResponder((args) => { - if (args[0] === "rev-parse" && args[1] === "--verify" && args[2] === "main") { - return { exitCode: 0, stdout: "sha\n", stderr: "" }; - } - if (args[0] === "show-ref" && args[3] === "refs/heads/feature/existing") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - return null; - }) as any); - - const service = makeService(db, repoRoot, "proj-1"); - await expect(service.switchBranch({ - laneId: "lane-c", - branchName: "feature/existing", - mode: "create", - baseRef: "main", - })).rejects.toThrow(/already exists/); - } finally { - db.close(); - fs.rmSync(repoRoot, { recursive: true, force: true }); - } - }); - - it("preserves PR rows whose head_branch matches the new branch and deletes stale ones", async () => { - // pull_requests.lane_id is NOT NULL, so stale PR rows whose head_branch - // no longer matches the lane's branch are DELETED (along with their - // child rows in pr_convergence_state / pr_pipeline_settings / - // pr_issue_inventory / pr_group_members), not nulled. - const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-bsw-switch-pr-detach-")); - const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); - try { - seedProject(db, { projectId: "proj-1", repoRoot }); - insertLane(db, { id: "lane-main", projectId: "proj-1", name: "Main", laneType: "primary", branchRef: "main", worktreePath: repoRoot }); - insertLane(db, { id: "lane-a", projectId: "proj-1", name: "A", laneType: "worktree", branchRef: "feature/a", worktreePath: path.join(repoRoot, "a") }); - - // PR already keyed to the new branch — should be left untouched. - db.run( - `insert into pull_requests( - id, project_id, lane_id, repo_owner, repo_name, github_pr_number, github_url, - title, state, base_branch, head_branch, additions, deletions, created_at, updated_at - ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - [ - "pr-keep", "proj-1", "lane-a", "acme", "ade", 1, "https://example.com/pr/1", - "keep", "open", "main", "feature/b", 0, 0, NOW, NOW, - ], - ); - // PR for the previous branch — should be deleted on switch. - db.run( - `insert into pull_requests( - id, project_id, lane_id, repo_owner, repo_name, github_pr_number, github_url, - title, state, base_branch, head_branch, additions, deletions, created_at, updated_at - ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - [ - "pr-stale", "proj-1", "lane-a", "acme", "ade", 2, "https://example.com/pr/2", - "stale", "open", "main", "feature/a", 0, 0, NOW, NOW, - ], - ); - - vi.mocked(runGitOrThrow).mockImplementation(async () => ({ exitCode: 0, stdout: "", stderr: "" } as any)); - vi.mocked(runGit).mockImplementation(makeRunGitResponder((args) => { - if (args[0] === "show-ref" && args[3] === "refs/heads/feature/b") return { exitCode: 0, stdout: "", stderr: "" }; - return null; - }) as any); - - const service = makeService(db, repoRoot, "proj-1"); - const result = await service.switchBranch({ laneId: "lane-a", branchName: "feature/b" }); - - expect(result.lane.branchRef).toBe("feature/b"); - - const keep = db.get<{ lane_id: string | null }>( - "select lane_id from pull_requests where id = ?", - ["pr-keep"], - ); - expect(keep?.lane_id).toBe("lane-a"); - - const stale = db.get<{ lane_id: string | null }>( - "select lane_id from pull_requests where id = ?", - ["pr-stale"], - ); - expect(stale).toBeNull(); - } finally { - db.close(); - fs.rmSync(repoRoot, { recursive: true, force: true }); - } - }); -}); diff --git a/apps/desktop/src/main/services/lanes/laneService.test.ts b/apps/desktop/src/main/services/lanes/laneService.test.ts index 31356065d..56b77b41a 100644 --- a/apps/desktop/src/main/services/lanes/laneService.test.ts +++ b/apps/desktop/src/main/services/lanes/laneService.test.ts @@ -2300,7 +2300,7 @@ describe("laneService missionId and laneRole", () => { throw new Error(`Unexpected git call: ${args.join(" ")}`); }); - vi.mocked(runGit).mockImplementation(async (args: string[], options: { cwd?: string } = {}) => { + vi.mocked(runGit).mockImplementation(async (args: string[]) => { const laneBranchGitStub = defaultLaneBranchGitStub(args); if (laneBranchGitStub) return laneBranchGitStub; if (args[0] === "push" && args[1] === "-u") { @@ -3510,3 +3510,604 @@ describe("laneService delete teardown + cancellation + streaming", () => { expect(risk.remoteBranchExists).toBe(true); }); }); + +// --------------------------------------------------------------------------- +// laneService - branchSwitch (merged from laneService.branchSwitch.test.ts) +// --------------------------------------------------------------------------- + +describe("laneService - branchSwitch", () => { + const BSW_NOW = "2026-04-25T10:00:00.000Z"; + + function seedBswProject(db: any, args: { projectId: string; repoRoot: string }) { + db.run( + "insert into projects(id, root_path, display_name, default_base_ref, created_at, last_opened_at) values (?, ?, ?, ?, ?, ?)", + [args.projectId, args.repoRoot, "demo", "main", BSW_NOW, BSW_NOW], + ); + } + + function insertBswLane(db: any, args: { + id: string; + projectId: string; + name: string; + laneType: "primary" | "worktree"; + branchRef: string; + baseRef?: string; + worktreePath: string; + parentLaneId?: string | null; + status?: string; + }) { + db.run( + `insert into lanes( + id, project_id, name, description, lane_type, base_ref, branch_ref, worktree_path, + attached_root_path, is_edit_protected, parent_lane_id, color, icon, tags_json, status, created_at, archived_at + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + args.id, + args.projectId, + args.name, + null, + args.laneType, + args.baseRef ?? "main", + args.branchRef, + args.worktreePath, + null, + args.laneType === "primary" ? 1 : 0, + args.parentLaneId ?? null, + null, + null, + null, + args.status ?? "active", + BSW_NOW, + args.status === "archived" ? BSW_NOW : null, + ], + ); + } + + function makeRunGitResponder(custom?: (args: string[], opts: any) => { exitCode: number; stdout: string; stderr: string } | null) { + return async (args: string[], opts: any = {}) => { + if (custom) { + const v = custom(args, opts); + if (v) return v; + } + if (args[0] === "rev-parse" && args[1] === "--abbrev-ref" && args[2] === "HEAD") { + return { exitCode: 0, stdout: "main\n", stderr: "" }; + } + if (args[0] === "rev-parse" && args[1] === "--path-format=absolute" && args[2] === "--git-dir") { + return { exitCode: 1, stdout: "", stderr: "fatal: no git dir" }; + } + if (args[0] === "rev-parse" && args[1] === "--abbrev-ref" && args[2] === "--symbolic-full-name" && args[3] === "@{upstream}") { + return { exitCode: 1, stdout: "", stderr: "no upstream" }; + } + if (args[0] === "rev-parse" && args[1] === "@{upstream}") { + return { exitCode: 1, stdout: "", stderr: "no upstream" }; + } + if (args[0] === "rev-list" && args[1] === "--left-right" && args[2] === "--count") { + return { exitCode: 0, stdout: "0\t0\n", stderr: "" }; + } + if (args[0] === "status" && args[1] === "--porcelain=v1") { + return { exitCode: 0, stdout: "", stderr: "" }; + } + return { exitCode: 1, stdout: "", stderr: `unhandled: ${args.join(" ")}` }; + }; + } + + function makeBswService(db: any, projectRoot: string, projectId: string) { + return createLaneService({ + db, + projectRoot, + projectId, + defaultBaseRef: "main", + worktreesDir: path.join(projectRoot, "worktrees"), + logger: createLogger(), + }); + } + + describe("listBranchProfiles", () => { + beforeEach(() => { + vi.mocked(getHeadSha).mockReset(); + vi.mocked(runGit).mockReset(); + vi.mocked(runGitOrThrow).mockReset(); + }); + + it("ensures and returns a profile for the lane's current branch_ref", async () => { + const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-bsw-list-profile-")); + const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); + try { + seedBswProject(db, { projectId: "proj-1", repoRoot }); + insertBswLane(db, { id: "lane-a", projectId: "proj-1", name: "Lane A", laneType: "worktree", branchRef: "feature/lane-a", worktreePath: path.join(repoRoot, "lane-a") }); + + vi.mocked(runGit).mockImplementation(makeRunGitResponder() as any); + + const service = makeBswService(db, repoRoot, "proj-1"); + const profiles = service.listBranchProfiles("lane-a"); + + expect(profiles).toHaveLength(1); + expect(profiles[0]?.branchRef).toBe("feature/lane-a"); + expect(profiles[0]?.laneId).toBe("lane-a"); + expect(profiles[0]?.baseRef).toBe("main"); + + const second = service.listBranchProfiles("lane-a"); + expect(second).toHaveLength(1); + expect(second[0]?.id).toBe(profiles[0]?.id); + } finally { + db.close(); + fs.rmSync(repoRoot, { recursive: true, force: true }); + } + }); + + it("throws when the lane is missing", async () => { + const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-bsw-list-missing-")); + const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); + try { + seedBswProject(db, { projectId: "proj-1", repoRoot }); + const service = makeBswService(db, repoRoot, "proj-1"); + expect(() => service.listBranchProfiles("nonexistent")).toThrow(/Lane not found/); + } finally { + db.close(); + fs.rmSync(repoRoot, { recursive: true, force: true }); + } + }); + }); + + describe("updateBranchRef", () => { + beforeEach(() => { + vi.mocked(getHeadSha).mockReset(); + vi.mocked(runGit).mockReset(); + vi.mocked(runGitOrThrow).mockReset(); + }); + + it("updates the lane's branch_ref AND upserts a matching branch profile", async () => { + const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-bsw-update-bref-")); + const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); + try { + seedBswProject(db, { projectId: "proj-1", repoRoot }); + insertBswLane(db, { id: "lane-a", projectId: "proj-1", name: "Lane A", laneType: "worktree", branchRef: "feature/lane-a", worktreePath: path.join(repoRoot, "lane-a") }); + + vi.mocked(runGit).mockImplementation(makeRunGitResponder() as any); + + const service = makeBswService(db, repoRoot, "proj-1"); + + service.updateBranchRef("lane-a", "feature/renamed"); + + const updated = db.get<{ branch_ref: string }>( + "select branch_ref from lanes where id = ? and project_id = ?", + ["lane-a", "proj-1"], + ); + expect(updated?.branch_ref).toBe("feature/renamed"); + + const profiles = service.listBranchProfiles("lane-a"); + const refs = profiles.map((p) => p.branchRef); + expect(refs).toContain("feature/renamed"); + const renamed = profiles.find((p) => p.branchRef === "feature/renamed"); + expect(renamed?.lastCheckedOutAt).toBeTruthy(); + } finally { + db.close(); + fs.rmSync(repoRoot, { recursive: true, force: true }); + } + }); + }); + + describe("previewBranchSwitch", () => { + beforeEach(() => { + vi.mocked(getHeadSha).mockReset(); + vi.mocked(runGit).mockReset(); + vi.mocked(runGitOrThrow).mockReset(); + }); + + it("rejects when laneId is empty", async () => { + const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-bsw-prev-laneid-")); + const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); + try { + seedBswProject(db, { projectId: "proj-1", repoRoot }); + const service = makeBswService(db, repoRoot, "proj-1"); + await expect(service.previewBranchSwitch({ laneId: "", branchName: "x" })).rejects.toThrow(/laneId is required/); + } finally { + db.close(); + fs.rmSync(repoRoot, { recursive: true, force: true }); + } + }); + + it("rejects when branchName is empty", async () => { + const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-bsw-prev-branchname-")); + const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); + try { + seedBswProject(db, { projectId: "proj-1", repoRoot }); + insertBswLane(db, { id: "lane-a", projectId: "proj-1", name: "Lane A", laneType: "worktree", branchRef: "feature/lane-a", worktreePath: path.join(repoRoot, "lane-a") }); + const service = makeBswService(db, repoRoot, "proj-1"); + await expect(service.previewBranchSwitch({ laneId: "lane-a", branchName: " " })).rejects.toThrow(/Branch name is required/); + } finally { + db.close(); + fs.rmSync(repoRoot, { recursive: true, force: true }); + } + }); + + it("rejects when the lane is archived", async () => { + const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-bsw-prev-archived-")); + const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); + try { + seedBswProject(db, { projectId: "proj-1", repoRoot }); + insertBswLane(db, { + id: "lane-archived", projectId: "proj-1", name: "Archived", laneType: "worktree", + branchRef: "feature/old", worktreePath: path.join(repoRoot, "old"), status: "archived", + }); + const service = makeBswService(db, repoRoot, "proj-1"); + await expect(service.previewBranchSwitch({ laneId: "lane-archived", branchName: "main" })).rejects.toThrow(/archived/); + } finally { + db.close(); + fs.rmSync(repoRoot, { recursive: true, force: true }); + } + }); + + it("flags dirty worktree, duplicate owner, and active terminal sessions", async () => { + const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-bsw-prev-flags-")); + const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); + try { + seedBswProject(db, { projectId: "proj-1", repoRoot }); + insertBswLane(db, { id: "lane-src", projectId: "proj-1", name: "Source", laneType: "worktree", branchRef: "feature/src", worktreePath: path.join(repoRoot, "src") }); + insertBswLane(db, { id: "lane-other", projectId: "proj-1", name: "Other Lane", laneType: "worktree", branchRef: "feature/target", worktreePath: path.join(repoRoot, "other") }); + + db.run( + `insert into terminal_sessions(id, lane_id, tracked, title, started_at, status, transcript_path) + values (?, ?, ?, ?, ?, ?, ?)`, + ["term-1", "lane-src", 1, "shell", BSW_NOW, "running", path.join(repoRoot, "t.log")], + ); + db.run( + `insert into process_runtime(project_id, lane_id, process_key, status, readiness, updated_at) + values (?, ?, ?, ?, ?, ?)`, + ["proj-1", "lane-src", "vite", "running", "ready", BSW_NOW], + ); + + vi.mocked(runGit).mockImplementation(makeRunGitResponder((args, opts) => { + if (args[0] === "show-ref" && args[1] === "--verify" && args[2] === "--quiet") { + if (args[3] === "refs/heads/feature/target") return { exitCode: 0, stdout: "", stderr: "" }; + } + if (args[0] === "status" && args[1] === "--porcelain=v1" && opts.cwd === path.join(repoRoot, "src")) { + return { exitCode: 0, stdout: " M file.ts\n", stderr: "" }; + } + return null; + }) as any); + + const service = makeBswService(db, repoRoot, "proj-1"); + const preview = await service.previewBranchSwitch({ laneId: "lane-src", branchName: "feature/target" }); + + expect(preview.laneId).toBe("lane-src"); + expect(preview.dirty).toBe(true); + expect(preview.duplicateLaneId).toBe("lane-other"); + expect(preview.duplicateLaneName).toBe("Other Lane"); + expect(preview.activeWork.length).toBeGreaterThanOrEqual(2); + expect(preview.activeWork.some((w) => w.kind === "terminal")).toBe(true); + expect(preview.activeWork.some((w) => w.kind === "process")).toBe(true); + expect(preview.targetBranchRef).toBe("feature/target"); + expect(preview.mode).toBe("existing"); + } finally { + db.close(); + fs.rmSync(repoRoot, { recursive: true, force: true }); + } + }); + + it("strips remote prefix when only the remote ref exists", async () => { + const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-bsw-prev-remote-")); + const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); + try { + seedBswProject(db, { projectId: "proj-1", repoRoot }); + insertBswLane(db, { id: "lane-x", projectId: "proj-1", name: "X", laneType: "worktree", branchRef: "feature/x", worktreePath: path.join(repoRoot, "x") }); + + vi.mocked(runGit).mockImplementation(makeRunGitResponder((args) => { + if (args[0] === "show-ref" && args[1] === "--verify" && args[2] === "--quiet") { + if (args[3] === "refs/heads/origin/foo") return { exitCode: 1, stdout: "", stderr: "" }; + if (args[3] === "refs/remotes/origin/foo") return { exitCode: 0, stdout: "", stderr: "" }; + } + return null; + }) as any); + + const service = makeBswService(db, repoRoot, "proj-1"); + const preview = await service.previewBranchSwitch({ laneId: "lane-x", branchName: "origin/foo" }); + expect(preview.targetBranchRef).toBe("foo"); + expect(preview.dirty).toBe(false); + expect(preview.duplicateLaneId).toBeNull(); + } finally { + db.close(); + fs.rmSync(repoRoot, { recursive: true, force: true }); + } + }); + + it("returns mode=create without consulting refs when explicitly requested", async () => { + const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-bsw-prev-create-")); + const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); + try { + seedBswProject(db, { projectId: "proj-1", repoRoot }); + insertBswLane(db, { id: "lane-y", projectId: "proj-1", name: "Y", laneType: "worktree", branchRef: "feature/y", worktreePath: path.join(repoRoot, "y") }); + + const showRefCalls: string[] = []; + vi.mocked(runGit).mockImplementation(makeRunGitResponder((args) => { + if (args[0] === "show-ref") showRefCalls.push(args.join(" ")); + return null; + }) as any); + + const service = makeBswService(db, repoRoot, "proj-1"); + const preview = await service.previewBranchSwitch({ laneId: "lane-y", branchName: "feature/new", mode: "create" }); + + expect(preview.mode).toBe("create"); + expect(preview.targetBranchRef).toBe("feature/new"); + expect(showRefCalls).toHaveLength(0); + } finally { + db.close(); + fs.rmSync(repoRoot, { recursive: true, force: true }); + } + }); + }); + + describe("switchBranch", () => { + beforeEach(() => { + vi.mocked(getHeadSha).mockReset(); + vi.mocked(runGit).mockReset(); + vi.mocked(runGitOrThrow).mockReset(); + }); + + it("refuses to switch when the lane has uncommitted changes", async () => { + const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-bsw-switch-dirty-")); + const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); + try { + seedBswProject(db, { projectId: "proj-1", repoRoot }); + insertBswLane(db, { id: "lane-d", projectId: "proj-1", name: "D", laneType: "worktree", branchRef: "feature/d", worktreePath: path.join(repoRoot, "d") }); + + vi.mocked(runGit).mockImplementation(makeRunGitResponder((args, opts) => { + if (args[0] === "show-ref" && args[3] === "refs/heads/main") return { exitCode: 0, stdout: "", stderr: "" }; + if (args[0] === "status" && args[1] === "--porcelain=v1" && opts.cwd === path.join(repoRoot, "d")) { + return { exitCode: 0, stdout: " M src/foo.ts\n", stderr: "" }; + } + return null; + }) as any); + + const service = makeBswService(db, repoRoot, "proj-1"); + await expect(service.switchBranch({ laneId: "lane-d", branchName: "main" })) + .rejects.toThrow(/uncommitted changes/); + } finally { + db.close(); + fs.rmSync(repoRoot, { recursive: true, force: true }); + } + }); + + it("refuses to switch to a branch that is already active in another lane", async () => { + const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-bsw-switch-dup-")); + const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); + try { + seedBswProject(db, { projectId: "proj-1", repoRoot }); + insertBswLane(db, { id: "lane-src", projectId: "proj-1", name: "Source", laneType: "worktree", branchRef: "feature/src", worktreePath: path.join(repoRoot, "src") }); + insertBswLane(db, { id: "lane-other", projectId: "proj-1", name: "Other Lane", laneType: "worktree", branchRef: "feature/duplicate", worktreePath: path.join(repoRoot, "other") }); + + vi.mocked(runGit).mockImplementation(makeRunGitResponder((args) => { + if (args[0] === "show-ref" && args[3] === "refs/heads/feature/duplicate") return { exitCode: 0, stdout: "", stderr: "" }; + return null; + }) as any); + + const service = makeBswService(db, repoRoot, "proj-1"); + await expect(service.switchBranch({ laneId: "lane-src", branchName: "feature/duplicate" })) + .rejects.toThrow(/already active in lane 'Other Lane'/); + } finally { + db.close(); + fs.rmSync(repoRoot, { recursive: true, force: true }); + } + }); + + it("refuses to switch when active work exists and acknowledgeActiveWork is not set", async () => { + const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-bsw-switch-active-")); + const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); + try { + seedBswProject(db, { projectId: "proj-1", repoRoot }); + insertBswLane(db, { id: "lane-a", projectId: "proj-1", name: "A", laneType: "worktree", branchRef: "feature/a", worktreePath: path.join(repoRoot, "a") }); + + db.run( + `insert into terminal_sessions(id, lane_id, tracked, title, started_at, status, transcript_path) + values (?, ?, ?, ?, ?, ?, ?)`, + ["t-1", "lane-a", 1, "shell", BSW_NOW, "running", path.join(repoRoot, "t.log")], + ); + + vi.mocked(runGit).mockImplementation(makeRunGitResponder((args) => { + if (args[0] === "show-ref" && args[3] === "refs/heads/main") return { exitCode: 0, stdout: "", stderr: "" }; + return null; + }) as any); + + const service = makeBswService(db, repoRoot, "proj-1"); + await expect(service.switchBranch({ laneId: "lane-a", branchName: "main" })) + .rejects.toThrow(/active sessions or processes/); + } finally { + db.close(); + fs.rmSync(repoRoot, { recursive: true, force: true }); + } + }); + + it("checks out an existing local branch and updates the lane row + branch profile", async () => { + const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-bsw-switch-existing-")); + const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); + try { + seedBswProject(db, { projectId: "proj-1", repoRoot }); + insertBswLane(db, { id: "lane-main", projectId: "proj-1", name: "Main", laneType: "primary", branchRef: "main", worktreePath: repoRoot }); + insertBswLane(db, { id: "lane-a", projectId: "proj-1", name: "A", laneType: "worktree", branchRef: "feature/a", worktreePath: path.join(repoRoot, "a") }); + + const checkoutCalls: string[][] = []; + vi.mocked(runGitOrThrow).mockImplementation(async (args: string[]) => { + if (args[0] === "checkout") checkoutCalls.push(args); + return { exitCode: 0, stdout: "", stderr: "" } as any; + }); + vi.mocked(runGit).mockImplementation(makeRunGitResponder((args) => { + if (args[0] === "show-ref" && args[3] === "refs/heads/feature/b") return { exitCode: 0, stdout: "", stderr: "" }; + return null; + }) as any); + + const service = makeBswService(db, repoRoot, "proj-1"); + const result = await service.switchBranch({ laneId: "lane-a", branchName: "feature/b" }); + + expect(result.previousBranchRef).toBe("feature/a"); + expect(result.lane.branchRef).toBe("feature/b"); + expect(result.lane.id).toBe("lane-a"); + expect(checkoutCalls.some((cmd) => cmd.includes("feature/b") && !cmd.includes("--track"))).toBe(true); + + const row = db.get<{ branch_ref: string }>( + "select branch_ref from lanes where id = ? and project_id = ?", + ["lane-a", "proj-1"], + ); + expect(row?.branch_ref).toBe("feature/b"); + + const profiles = service.listBranchProfiles("lane-a"); + expect(profiles.map((p) => p.branchRef)).toEqual(expect.arrayContaining(["feature/a", "feature/b"])); + } finally { + db.close(); + fs.rmSync(repoRoot, { recursive: true, force: true }); + } + }); + + it("creates a new branch via 'checkout -b' when mode='create'", async () => { + const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-bsw-switch-create-")); + const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); + try { + seedBswProject(db, { projectId: "proj-1", repoRoot }); + insertBswLane(db, { id: "lane-main", projectId: "proj-1", name: "Main", laneType: "primary", branchRef: "main", worktreePath: repoRoot }); + insertBswLane(db, { id: "lane-c", projectId: "proj-1", name: "C", laneType: "worktree", branchRef: "feature/c", worktreePath: path.join(repoRoot, "c") }); + + const checkoutCalls: string[][] = []; + vi.mocked(runGitOrThrow).mockImplementation(async (args: string[]) => { + if (args[0] === "checkout") checkoutCalls.push(args); + return { exitCode: 0, stdout: "", stderr: "" } as any; + }); + + vi.mocked(runGit).mockImplementation(makeRunGitResponder((args) => { + if (args[0] === "rev-parse" && args[1] === "--verify" && args[2] === "main") { + return { exitCode: 0, stdout: "sha-main\n", stderr: "" }; + } + if (args[0] === "rev-parse" && args[1] === "--verify" && args[2] === "feature/c") { + return { exitCode: 0, stdout: "sha-c\n", stderr: "" }; + } + if (args[0] === "show-ref" && args[3] === "refs/heads/feature/new") { + return { exitCode: 1, stdout: "", stderr: "" }; + } + return null; + }) as any); + + const service = makeBswService(db, repoRoot, "proj-1"); + const result = await service.switchBranch({ + laneId: "lane-c", + branchName: "feature/new", + mode: "create", + baseRef: "main", + }); + + expect(result.previousBranchRef).toBe("feature/c"); + expect(result.lane.branchRef).toBe("feature/new"); + expect(checkoutCalls.some((cmd) => cmd[0] === "checkout" && cmd[1] === "-b" && cmd[2] === "feature/new")).toBe(true); + + const profile = service.listBranchProfiles("lane-c").find((p) => p.branchRef === "feature/new"); + expect(profile?.sourceBranchRef).toBe("feature/c"); + expect(profile?.baseRef).toBe("main"); + } finally { + db.close(); + fs.rmSync(repoRoot, { recursive: true, force: true }); + } + }); + + it("rejects mode='create' when baseRef is missing", async () => { + const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-bsw-switch-create-no-base-")); + const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); + try { + seedBswProject(db, { projectId: "proj-1", repoRoot }); + insertBswLane(db, { id: "lane-c", projectId: "proj-1", name: "C", laneType: "worktree", branchRef: "feature/c", worktreePath: path.join(repoRoot, "c") }); + + vi.mocked(runGit).mockImplementation(makeRunGitResponder() as any); + + const service = makeBswService(db, repoRoot, "proj-1"); + await expect(service.switchBranch({ laneId: "lane-c", branchName: "feature/new", mode: "create" })) + .rejects.toThrow(/Base branch is required/); + } finally { + db.close(); + fs.rmSync(repoRoot, { recursive: true, force: true }); + } + }); + + it("rejects mode='create' when the target branch already exists locally", async () => { + const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-bsw-switch-create-exists-")); + const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); + try { + seedBswProject(db, { projectId: "proj-1", repoRoot }); + insertBswLane(db, { id: "lane-c", projectId: "proj-1", name: "C", laneType: "worktree", branchRef: "feature/c", worktreePath: path.join(repoRoot, "c") }); + + vi.mocked(runGit).mockImplementation(makeRunGitResponder((args) => { + if (args[0] === "rev-parse" && args[1] === "--verify" && args[2] === "main") { + return { exitCode: 0, stdout: "sha\n", stderr: "" }; + } + if (args[0] === "show-ref" && args[3] === "refs/heads/feature/existing") { + return { exitCode: 0, stdout: "", stderr: "" }; + } + return null; + }) as any); + + const service = makeBswService(db, repoRoot, "proj-1"); + await expect(service.switchBranch({ + laneId: "lane-c", + branchName: "feature/existing", + mode: "create", + baseRef: "main", + })).rejects.toThrow(/already exists/); + } finally { + db.close(); + fs.rmSync(repoRoot, { recursive: true, force: true }); + } + }); + + it("preserves PR rows whose head_branch matches the new branch and deletes stale ones", async () => { + const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-bsw-switch-pr-detach-")); + const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); + try { + seedBswProject(db, { projectId: "proj-1", repoRoot }); + insertBswLane(db, { id: "lane-main", projectId: "proj-1", name: "Main", laneType: "primary", branchRef: "main", worktreePath: repoRoot }); + insertBswLane(db, { id: "lane-a", projectId: "proj-1", name: "A", laneType: "worktree", branchRef: "feature/a", worktreePath: path.join(repoRoot, "a") }); + + db.run( + `insert into pull_requests( + id, project_id, lane_id, repo_owner, repo_name, github_pr_number, github_url, + title, state, base_branch, head_branch, additions, deletions, created_at, updated_at + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + "pr-keep", "proj-1", "lane-a", "acme", "ade", 1, "https://example.com/pr/1", + "keep", "open", "main", "feature/b", 0, 0, BSW_NOW, BSW_NOW, + ], + ); + db.run( + `insert into pull_requests( + id, project_id, lane_id, repo_owner, repo_name, github_pr_number, github_url, + title, state, base_branch, head_branch, additions, deletions, created_at, updated_at + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + "pr-stale", "proj-1", "lane-a", "acme", "ade", 2, "https://example.com/pr/2", + "stale", "open", "main", "feature/a", 0, 0, BSW_NOW, BSW_NOW, + ], + ); + + vi.mocked(runGitOrThrow).mockImplementation(async () => ({ exitCode: 0, stdout: "", stderr: "" } as any)); + vi.mocked(runGit).mockImplementation(makeRunGitResponder((args) => { + if (args[0] === "show-ref" && args[3] === "refs/heads/feature/b") return { exitCode: 0, stdout: "", stderr: "" }; + return null; + }) as any); + + const service = makeBswService(db, repoRoot, "proj-1"); + const result = await service.switchBranch({ laneId: "lane-a", branchName: "feature/b" }); + + expect(result.lane.branchRef).toBe("feature/b"); + + const keep = db.get<{ lane_id: string | null }>( + "select lane_id from pull_requests where id = ?", + ["pr-keep"], + ); + expect(keep?.lane_id).toBe("lane-a"); + + const stale = db.get<{ lane_id: string | null }>( + "select lane_id from pull_requests where id = ?", + ["pr-stale"], + ); + expect(stale).toBeNull(); + } finally { + db.close(); + fs.rmSync(repoRoot, { recursive: true, force: true }); + } + }); + }); +}); diff --git a/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.test.ts b/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.test.ts index d58a4c52a..ba9441a5a 100644 --- a/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.test.ts +++ b/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.test.ts @@ -139,6 +139,10 @@ function startServeProcess(args: { }); } +function removeTempDir(dir: string): void { + fs.rmSync(dir, { recursive: true, force: true }); +} + async function shutdownRuntime(socketPath: string): Promise { let client: RawRuntimeSocketClient | null = null; try { @@ -354,6 +358,8 @@ describe("local runtime connection pool", () => { else process.env.ADE_RUNTIME_SOCKET_PATH = originalEnv.ADE_RUNTIME_SOCKET_PATH; if (originalEnv.NODE_OPTIONS === undefined) delete process.env.NODE_OPTIONS; else process.env.NODE_OPTIONS = originalEnv.NODE_OPTIONS; + removeTempDir(projectRoot); + removeTempDir(adeHome); } }, 45_000); @@ -459,6 +465,8 @@ describe("local runtime connection pool", () => { else process.env.ADE_RUNTIME_SOCKET_PATH = originalEnv.ADE_RUNTIME_SOCKET_PATH; if (originalEnv.NODE_OPTIONS === undefined) delete process.env.NODE_OPTIONS; else process.env.NODE_OPTIONS = originalEnv.NODE_OPTIONS; + removeTempDir(projectRoot); + removeTempDir(adeHome); } }, 45_000); @@ -493,6 +501,7 @@ describe("local runtime connection pool", () => { ...baseEnv, ADE_CLI_VERSION: "0.0.0", ADE_RUNTIME_BUILD_HASH: expectedBuildHash!, + ADE_DEFAULT_ROLE: "cto", }, socketPath, }); @@ -535,6 +544,8 @@ describe("local runtime connection pool", () => { else process.env.ADE_RUNTIME_SOCKET_PATH = originalEnv.ADE_RUNTIME_SOCKET_PATH; if (originalEnv.NODE_OPTIONS === undefined) delete process.env.NODE_OPTIONS; else process.env.NODE_OPTIONS = originalEnv.NODE_OPTIONS; + removeTempDir(projectRoot); + removeTempDir(adeHome); } }, 45_000); @@ -644,6 +655,110 @@ describe("local runtime connection pool", () => { else process.env.ADE_RUNTIME_SOCKET_PATH = originalEnv.ADE_RUNTIME_SOCKET_PATH; if (originalEnv.NODE_OPTIONS === undefined) delete process.env.NODE_OPTIONS; else process.env.NODE_OPTIONS = originalEnv.NODE_OPTIONS; + removeTempDir(projectRoot); + removeTempDir(adeHome); + } + }, 45_000); + + it("replaces a same-version local daemon when its default role is not CTO", async () => { + const adeCliRoot = path.resolve(process.cwd(), "../ade-cli"); + const cliPath = path.join(adeCliRoot, "src", "cli.ts"); + const tsxLoaderPath = path.join(adeCliRoot, "node_modules", "tsx", "dist", "loader.mjs"); + expect(fs.existsSync(cliPath)).toBe(true); + expect(fs.existsSync(tsxLoaderPath)).toBe(true); + + const adeHome = fs.mkdtempSync(path.join(os.tmpdir(), "ade-local-runtime-role-")); + const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-local-runtime-role-project-")); + const socketPath = path.join(adeHome, "sock", "ade.sock"); + const expectedBuildHash = computeLocalRuntimeBuildHash(cliPath); + expect(expectedBuildHash).toBeTruthy(); + const originalEnv = { + ADE_CLI_JS: process.env.ADE_CLI_JS, + ADE_HOME: process.env.ADE_HOME, + ADE_RUNTIME_SOCKET_PATH: process.env.ADE_RUNTIME_SOCKET_PATH, + NODE_OPTIONS: process.env.NODE_OPTIONS, + }; + const baseEnv = { + ...process.env, + ADE_HOME: adeHome, + ADE_RUNTIME_SOCKET_PATH: socketPath, + NODE_OPTIONS: withTsxNodeOptions(originalEnv.NODE_OPTIONS, tsxLoaderPath), + }; + const oldDaemon = startServeProcess({ + cliPath, + cwd: adeCliRoot, + env: { + ...baseEnv, + ADE_CLI_VERSION: "1.0.0", + ADE_RUNTIME_BUILD_HASH: expectedBuildHash!, + ADE_DEFAULT_ROLE: "agent", + }, + socketPath, + }); + const oldPid = oldDaemon.pid!; + expect(oldPid).toBeGreaterThan(0); + const logger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; + let pool: LocalRuntimeConnectionPool | null = null; + + try { + await waitForRuntimeSocket(socketPath); + process.env.ADE_CLI_JS = cliPath; + process.env.ADE_HOME = adeHome; + process.env.ADE_RUNTIME_SOCKET_PATH = socketPath; + process.env.NODE_OPTIONS = baseEnv.NODE_OPTIONS; + + pool = new LocalRuntimeConnectionPool("1.0.0", logger as never, { disableSync: true }); + const registered = await pool.ensureProject(projectRoot); + expect(fs.realpathSync(registered.rootPath)).toBe(fs.realpathSync(projectRoot)); + + expect(logger.info).toHaveBeenCalledWith("local_runtime.role_mismatch_detected", expect.objectContaining({ + runtimeDefaultRole: "agent", + expectedDefaultRole: "cto", + runtimePid: oldPid, + })); + expect(logger.warn).toHaveBeenCalledWith("local_runtime.replacing_stale", expect.objectContaining({ + pid: oldPid, + socketPath, + })); + + const client = await RawRuntimeSocketClient.connect(socketPath); + try { + const initialized = await client.request("ade/initialize", { + protocolVersion: "2025-06-18", + clientName: "local-runtime-role-test", + identity: { role: "external", callerId: "local-runtime-role-test" }, + }); + expect(initialized).toMatchObject({ + runtimeInfo: { + version: "1.0.0", + buildHash: expectedBuildHash, + defaultRole: "cto", + }, + }); + } finally { + client.close(); + } + } finally { + pool?.dispose(); + await shutdownRuntime(socketPath); + if (!oldDaemon.killed) { + try { oldDaemon.kill("SIGKILL"); } catch {} + } + if (originalEnv.ADE_CLI_JS === undefined) delete process.env.ADE_CLI_JS; + else process.env.ADE_CLI_JS = originalEnv.ADE_CLI_JS; + if (originalEnv.ADE_HOME === undefined) delete process.env.ADE_HOME; + else process.env.ADE_HOME = originalEnv.ADE_HOME; + if (originalEnv.ADE_RUNTIME_SOCKET_PATH === undefined) delete process.env.ADE_RUNTIME_SOCKET_PATH; + else process.env.ADE_RUNTIME_SOCKET_PATH = originalEnv.ADE_RUNTIME_SOCKET_PATH; + if (originalEnv.NODE_OPTIONS === undefined) delete process.env.NODE_OPTIONS; + else process.env.NODE_OPTIONS = originalEnv.NODE_OPTIONS; + removeTempDir(projectRoot); + removeTempDir(adeHome); } }, 45_000); diff --git a/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.ts b/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.ts index d0ff14bc7..1b91669ec 100644 --- a/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.ts +++ b/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.ts @@ -196,21 +196,24 @@ function openSocketTransport(socketPath: string, timeoutMs = 3_000): Promise 0 ? Math.floor(pid) : null, }; } @@ -811,6 +814,19 @@ export class LocalRuntimeConnectionPool { runtimeInfo.pid, ); } + if (runtimeInfo.defaultRole !== "cto") { + this.logger.info("local_runtime.role_mismatch_detected", { + socketPath, + runtimeDefaultRole: runtimeInfo.defaultRole, + expectedDefaultRole: "cto", + runtimePid: runtimeInfo.pid, + }); + closeRuntimeClient(client); + throw new LocalRuntimeCompatibilityError( + `ADE service default role ${runtimeInfo.defaultRole ?? "missing"} does not match desktop role cto.`, + runtimeInfo.pid, + ); + } this.activeClient = client; client.onDisconnect((error) => { if (this.activeClient !== client) return; diff --git a/apps/desktop/src/main/services/macosVm/macosVmService.test.ts b/apps/desktop/src/main/services/macosVm/macosVmService.test.ts index 5979eb5fe..30a5bdb59 100644 --- a/apps/desktop/src/main/services/macosVm/macosVmService.test.ts +++ b/apps/desktop/src/main/services/macosVm/macosVmService.test.ts @@ -657,7 +657,8 @@ describe("createMacosVmService", () => { const runCommand = vi.fn(async (command: string, args: string[]) => { commands.push({ command, args }); if (command === "/usr/bin/curl") { - const outputPath = args.at(args.indexOf("--output") + 1); + const outputIndex = args.indexOf("--output"); + const outputPath = outputIndex >= 0 ? args.at(outputIndex + 1) : null; if (outputPath) fs.writeFileSync(outputPath, Buffer.from("ipsw")); return { exitCode: 0, signal: null, stdout: "", stderr: "" }; } @@ -728,7 +729,8 @@ describe("createMacosVmService", () => { const runCommand = vi.fn(async (command: string, args: string[]) => { commands.push({ command, args }); if (command === "/usr/bin/curl") { - const outputPath = args.at(args.indexOf("--output") + 1); + const outputIndex = args.indexOf("--output"); + const outputPath = outputIndex >= 0 ? args.at(outputIndex + 1) : null; if (outputPath) fs.writeFileSync(outputPath, Buffer.from("partial")); return { exitCode: 56, signal: null, stdout: "", stderr: "network reset" }; } diff --git a/apps/desktop/src/main/services/macosVm/macosVmService.ts b/apps/desktop/src/main/services/macosVm/macosVmService.ts index 64b4efff3..7acf3d76e 100644 --- a/apps/desktop/src/main/services/macosVm/macosVmService.ts +++ b/apps/desktop/src/main/services/macosVm/macosVmService.ts @@ -2550,7 +2550,7 @@ export function createMacosVmService(args: CreateMacosVmServiceArgs) { // 2. Sweep stale share entries so the next start mounts a clean share set. const beforeStart = loadRecords().find((entry) => entry.laneId === lane.id) ?? record; const liveEntries = (beforeStart.shareEntries ?? []).filter((entry) => entry.state === "live"); - const sweptRecord = upsertRecord({ + upsertRecord({ ...beforeStart, shareEntries: liveEntries, }); diff --git a/apps/desktop/src/main/services/missions/missionPreflightService.ts b/apps/desktop/src/main/services/missions/missionPreflightService.ts index e279b4b2c..796a2ac6a 100644 --- a/apps/desktop/src/main/services/missions/missionPreflightService.ts +++ b/apps/desktop/src/main/services/missions/missionPreflightService.ts @@ -123,7 +123,6 @@ export function createMissionPreflightService(args: { computerUseArtifactBrokerService?: ComputerUseArtifactBrokerService | null; }) { const { - projectRoot, missionService, laneService, aiIntegrationService, diff --git a/apps/desktop/src/main/services/missions/missionService.ts b/apps/desktop/src/main/services/missions/missionService.ts index c5288c5d0..a39adc188 100644 --- a/apps/desktop/src/main/services/missions/missionService.ts +++ b/apps/desktop/src/main/services/missions/missionService.ts @@ -2350,7 +2350,6 @@ export function createMissionService({ } ); if (metadata.warning) warnings.push(metadata.warning); - const launchMetadata = isRecord(metadata.value?.launch) ? metadata.value.launch : null; const plannerPlan = isRecord(metadata.value?.plannerPlan) ? (metadata.value.plannerPlan as PlannerPlan) : null; diff --git a/apps/desktop/src/main/services/onboarding/onboardingService.ts b/apps/desktop/src/main/services/onboarding/onboardingService.ts index 6a3401a3c..80d7ab20c 100644 --- a/apps/desktop/src/main/services/onboarding/onboardingService.ts +++ b/apps/desktop/src/main/services/onboarding/onboardingService.ts @@ -435,35 +435,6 @@ export function createOnboardingService(args: { const coerceVariant = (variant: OnboardingTourVariant | undefined): OnboardingTourVariant => variant === "highlights" ? "highlights" : "full"; - const updateTourEntry = ( - tourId: string, - patch: Partial, - ): OnboardingTourProgress => { - const id = tourId.trim(); - if (!id) return getTourProgress(); - const current = getTourProgress(); - const entry = current.tours[id] ?? { ...EMPTY_TOUR_ENTRY }; - const nextEntry: OnboardingTourEntry = { ...entry, ...patch }; - // Mirror legacy writes onto the "full" variant so variant-aware readers - // stay in sync without requiring every call site to migrate at once. - const existingVariant = current.tourVariants[id] ?? emptyTourEntryV2(); - const mirrored: OnboardingTourEntryV2 = { - full: { ...nextEntry }, - highlights: existingVariant.highlights, - }; - return writeTourProgress({ - ...current, - tours: { - ...current.tours, - [id]: nextEntry, - }, - tourVariants: { - ...current.tourVariants, - [id]: mirrored, - }, - }); - }; - const updateTourVariantEntry = ( tourId: string, variant: OnboardingTourVariant, diff --git a/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.test.ts b/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.test.ts index 57b6fa76c..40b229e87 100644 --- a/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.test.ts +++ b/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.test.ts @@ -2,7 +2,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { describe, expect, it, vi } from "vitest"; -import type { PackDeltaDigestV1, PackExport, PackType, PhaseCard } from "../../../shared/types"; +import type { PhaseCard } from "../../../shared/types"; import { openKvDb } from "../state/kvDb"; import { createMissionService } from "../missions/missionService"; import { createOrchestratorService } from "./orchestratorService"; @@ -35,22 +35,6 @@ function createLogger() { } as any; } -function buildExport(packKey: string, packType: PackType, level: "lite" | "standard" | "deep"): PackExport { - return { - packKey, - packType, - level, - header: {} as any, - content: `${packKey}:${level}`, - approxTokens: 24, - maxTokens: 500, - truncated: false, - warnings: [], - clipReason: null, - omittedSections: null - }; -} - function phaseCard(overrides: Partial & Pick): PhaseCard { return { id: `phase-${overrides.phaseKey}`, @@ -1228,64 +1212,6 @@ async function createFixture(args: { }) } as any; - const packService = { - getLaneExport: async ({ laneId: targetLaneId, level }: { laneId: string; level: "lite" | "standard" | "deep" }) => - buildExport(`lane:${targetLaneId}`, "lane", level), - getProjectExport: async ({ level }: { level: "lite" | "standard" | "deep" }) => buildExport("project", "project", level), - getHeadVersion: ({ packKey }: { packKey: string }) => ({ - packKey, - packType: packKey.startsWith("lane:") ? "lane" : "project", - versionId: `${packKey}-v1`, - versionNumber: 1, - contentHash: `hash-${packKey}`, - updatedAt: now - }), - getDeltaDigest: async (): Promise => ({ - packKey: `lane:${laneId}`, - packType: "lane", - since: { - sinceVersionId: null, - sinceTimestamp: now, - baselineVersionId: null, - baselineVersionNumber: null, - baselineCreatedAt: null - }, - newVersion: { - packKey: `lane:${laneId}`, - packType: "lane", - versionId: `lane:${laneId}-v1`, - versionNumber: 1, - contentHash: "hash", - updatedAt: now - }, - changedSections: [], - highImpactEvents: [], - blockers: [], - conflicts: null, - decisionState: { - recommendedExportLevel: "standard", - reasons: [] - }, - handoffSummary: "none", - clipReason: null, - omittedSections: null - }), - refreshMissionPack: async ({ missionId }: { missionId: string }) => ({ - packKey: `mission:${missionId}`, - packType: "mission", - path: path.join(projectRoot, ".ade", "packs", "missions", missionId, "mission_pack.md"), - exists: true, - deterministicUpdatedAt: now, - narrativeUpdatedAt: null, - lastHeadSha: null, - versionId: `mission-${missionId}-v1`, - versionNumber: 1, - contentHash: `hash-mission-${missionId}`, - metadata: null, - body: "# Mission Pack" - }) - } as any; - const orchestratorService = createOrchestratorService({ db, projectId, @@ -6973,6 +6899,114 @@ describe("aiOrchestratorService", () => { } }); + it("keeps intervention_required when step sync opens a failed-step intervention", async () => { + const fixture = await createFixture(); + try { + const mission = fixture.missionService.create({ + prompt: "Keep mission status aligned with interventions created during sync.", + laneId: fixture.laneId, + plannedSteps: [ + { + index: 0, + title: "Implement risky change", + detail: "Make the update", + kind: "implementation", + metadata: { stepType: "implementation" } + } + ] + }); + const missionStep = fixture.missionService.get(mission.id)?.steps[0]; + if (!missionStep) throw new Error("Expected mission step"); + const started = fixture.orchestratorService.startRun({ + missionId: mission.id, + steps: [ + { + stepKey: "implement-risky-change", + title: missionStep.title, + stepIndex: 0, + dependencyStepKeys: [], + executorKind: "manual", + missionStepId: missionStep.id, + metadata: { stepType: "implementation" } + } + ] + }); + const runId = started.run.id; + fixture.missionService.update({ + missionId: mission.id, + status: "in_progress", + }); + const failedAt = new Date().toISOString(); + fixture.db.run( + `update orchestrator_runs set status = 'active', updated_at = ? where id = ?`, + [failedAt, runId], + ); + fixture.db.run( + `update orchestrator_steps set status = 'failed', completed_at = ?, updated_at = ? where run_id = ?`, + [failedAt, failedAt, runId], + ); + + await fixture.aiOrchestratorService.syncMissionFromRun(runId, "step_failed"); + + const refreshed = fixture.missionService.get(mission.id); + expect(refreshed?.status).toBe("intervention_required"); + expect(refreshed?.steps[0]?.status).toBe("failed"); + expect(refreshed?.interventions.some((entry) => + entry.status === "open" && entry.interventionType === "failed_step" + )).toBe(true); + } finally { + fixture.dispose(); + } + }); + + it("keeps intervention_required when a failed run opens a failed-step intervention", async () => { + const fixture = await createFixture(); + try { + const mission = fixture.missionService.create({ + prompt: "Keep terminal failed runs recoverable when sync opens an intervention.", + laneId: fixture.laneId, + plannedSteps: [ + { + index: 0, + title: "Implement risky change", + detail: "Make the update", + kind: "implementation", + metadata: { stepType: "implementation" } + } + ] + }); + const missionStep = fixture.missionService.get(mission.id)?.steps[0]; + expect(missionStep?.id).toBeTruthy(); + + const launch = await fixture.aiOrchestratorService.startMissionRun({ + missionId: mission.id, + runMode: "manual", + defaultExecutorKind: "manual" + }); + if (!launch.started) throw new Error("Expected mission run to start"); + const runId = launch.started.run.id; + const failedAt = new Date().toISOString(); + fixture.db.run( + `update orchestrator_runs set status = 'failed', updated_at = ? where id = ?`, + [failedAt, runId], + ); + fixture.db.run( + `update orchestrator_steps set status = 'failed', completed_at = ?, updated_at = ? where run_id = ?`, + [failedAt, failedAt, runId], + ); + + await fixture.aiOrchestratorService.syncMissionFromRun(runId, "run_failed"); + + const refreshed = fixture.missionService.get(mission.id); + expect(refreshed?.status).toBe("intervention_required"); + expect(refreshed?.interventions.some((entry) => + entry.status === "open" && entry.interventionType === "failed_step" + )).toBe(true); + } finally { + fixture.dispose(); + } + }); + it("applies steering directives onto active run steps for worker prompt guidance", async () => { const fixture = await createFixture(); try { diff --git a/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.ts b/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.ts index 81d04bc30..678fed3be 100644 --- a/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.ts +++ b/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.ts @@ -6974,10 +6974,19 @@ Check all worker statuses and continue managing the mission from here. Read work const mission = missionService.get(graph.run.missionId); if (!mission) return; const missionStepById = new Map(mission.steps.map((step) => [step.id, step])); + const resolveMissionStepForRunStep = (runStep: OrchestratorRunGraph["steps"][number]) => { + if (runStep.missionStepId) { + const byId = missionStepById.get(runStep.missionStepId); + if (byId) return byId; + } + return mission.steps.find((step) => { + const metadata = isRecord(step.metadata) ? step.metadata : null; + return metadata?.orchestratorStepId === runStep.id || metadata?.stepKey === runStep.stepKey; + }) ?? mission.steps.find((step) => step.title === runStep.title) ?? null; + }; for (const runStep of graph.steps) { - if (!runStep.missionStepId) continue; - const missionStep = missionStepById.get(runStep.missionStepId); + const missionStep = resolveMissionStepForRunStep(runStep); if (!missionStep) continue; const nextStatus = mapOrchestratorStepStatus(runStep.status); if (missionStep.status === nextStatus) continue; @@ -7024,6 +7033,39 @@ Check all worker statuses and continue managing the mission from here. Read work } }; + const ensureTerminalFailedRunIntervention = (graph: OrchestratorRunGraph) => { + if (graph.run.status !== "failed") return; + const mission = missionService.get(graph.run.missionId); + if (!mission) return; + if (mission.interventions.some((entry) => entry.status === "open" && entry.interventionType === "failed_step")) { + return; + } + + const missionStepById = new Map(mission.steps.map((step) => [step.id, step])); + const failedRunStep = graph.steps.find((step) => step.status === "failed"); + const failedMissionStep = failedRunStep?.missionStepId + ? missionStepById.get(failedRunStep.missionStepId) ?? null + : mission.steps.find((step) => step.status === "failed") ?? null; + if (!failedRunStep && !failedMissionStep) return; + const failedStepTitle = failedMissionStep?.title ?? failedRunStep?.title ?? "Run step"; + + missionService.addIntervention({ + missionId: mission.id, + interventionType: "failed_step", + title: `Step failed: ${failedStepTitle}`, + body: `Synchronized from failed orchestrator run ${graph.run.id.slice(0, 8)}.`, + requestedAction: "Review the failure and decide whether to continue, retry, or cancel.", + metadata: { + runId: graph.run.id, + stepId: failedMissionStep?.id ?? null, + stepKey: failedRunStep?.stepKey ?? null, + orchestratorStepId: failedRunStep?.id ?? null, + reasonCode: "terminal_run_failed_step", + } + }); + transitionMissionStatus(mission.id, "intervention_required", { allowTerminalRestart: true }); + }; + // TERMINAL_PHASE_STEP_STATUSES — imported from missionLifecycle const syncMissionPhaseFromRun = (graph: OrchestratorRunGraph, _reason: string) => { @@ -7064,13 +7106,15 @@ Check all worker statuses and continue managing the mission from here. Read work if (!mission) return; syncMissionStepsFromRun(graph); + ensureTerminalFailedRunIntervention(graph); syncMissionPhaseFromRun(graph, reason); const refreshed = missionService.get(mission.id) ?? mission; - const nextMissionStatus = options?.nextMissionStatus ?? deriveMissionStatusFromRun(graph, refreshed); + let nextMissionStatus = options?.nextMissionStatus ?? deriveMissionStatusFromRun(graph, refreshed); if (nextMissionStatus === "completed") { let workersSpawned = false; - const finalizationPolicy = resolveMissionFinalizationPolicyForMission(mission.id); - const missionBaseBranch = await resolveMissionBaseBranch(mission.id); + const missionForFinalization = missionService.get(mission.id) ?? refreshed; + const finalizationPolicy = resolveMissionFinalizationPolicyForMission(missionForFinalization.id); + const missionBaseBranch = await resolveMissionBaseBranch(missionForFinalization.id); // ── Guard: skip duplicate finalization ────────────────────── // If a previous sync already completed finalization or is actively @@ -7136,7 +7180,7 @@ Check all worker statuses and continue managing the mission from here. Read work }, { graph }); const result = await finalizeMissionToResultLane({ - mission, + mission: missionForFinalization, runId, missionBaseBranch: missionBaseBranch ?? "main", }); @@ -7227,8 +7271,9 @@ Check all worker statuses and continue managing the mission from here. Read work ? await readMissionStateDocument({ projectRoot, runId }).catch(() => null) : null; if (stateDocAfterFinalization?.finalization) { + const missionForCloseout = missionService.get(mission.id) ?? missionForFinalization; const closeoutRequirements = buildMissionCloseoutRequirements({ - mission, + mission: missionForCloseout, graph, policy: stateDocAfterFinalization.finalization.policy, finalization: stateDocAfterFinalization.finalization, @@ -7290,10 +7335,22 @@ Check all worker statuses and continue managing the mission from here. Read work }); } } else if (nextMissionStatus === "failed") { - transitionMissionStatus(mission.id, "failed", { - lastError: extractRunFailureMessage(graph) - }); + const latestMission = missionService.get(mission.id) ?? refreshed; + const latestStatus = graph.steps.some((step) => step.status === "failed") || latestMission.steps.some((step) => step.status === "failed") + ? "intervention_required" + : deriveMissionStatusFromRun(graph, latestMission); + if (latestStatus === "intervention_required") { + nextMissionStatus = latestStatus; + transitionMissionStatus(mission.id, latestStatus, { allowTerminalRestart: true }); + } else { + transitionMissionStatus(mission.id, "failed", { + lastError: extractRunFailureMessage(graph) + }); + } } else { + if (!options?.nextMissionStatus) { + nextMissionStatus = deriveMissionStatusFromRun(graph, missionService.get(mission.id) ?? refreshed); + } transitionMissionStatus(mission.id, nextMissionStatus); } logger.debug("ai_orchestrator.sync_completed", { diff --git a/apps/desktop/src/main/services/orchestrator/coordinatorAgent.ts b/apps/desktop/src/main/services/orchestrator/coordinatorAgent.ts index 15e748c65..c66a2a50f 100644 --- a/apps/desktop/src/main/services/orchestrator/coordinatorAgent.ts +++ b/apps/desktop/src/main/services/orchestrator/coordinatorAgent.ts @@ -38,7 +38,6 @@ import { getLocalProviderDefaultEndpoint, resolveModelDescriptor, resolveProviderGroupForModel, - type LocalProviderFamily, } from "../../../shared/modelRegistry"; import { inspectLocalProvider } from "../ai/localModelDiscovery"; import type { DiscoveredLocalModelEntry } from "../opencode/openCodeRuntime"; diff --git a/apps/desktop/src/main/services/orchestrator/coordinatorTools.ts b/apps/desktop/src/main/services/orchestrator/coordinatorTools.ts index 13f09ba5d..9d3442609 100644 --- a/apps/desktop/src/main/services/orchestrator/coordinatorTools.ts +++ b/apps/desktop/src/main/services/orchestrator/coordinatorTools.ts @@ -209,16 +209,6 @@ export const COORDINATOR_TOOL_NAMES = [ "check_finalization_status", ] as const; -const COORDINATOR_OBSERVATION_TOOL_NAMES = [ - "get_mission", - "get_run_graph", - "get_step_output", - "get_worker_states", - "get_timeline", - "get_final_diff", - "stream_events", -] as const; - export type PlannerLaunchFailureCategory = | "run_context_bug" | "provider_unreachable" @@ -860,7 +850,6 @@ export function createCoordinatorToolSet(deps: { const missionLaneId = typeof deps.missionLaneId === "string" && deps.missionLaneId.trim().length > 0 ? deps.missionLaneId.trim() : null; - const resolvedProjectRoot = path.resolve(projectRoot); const resolvedWorkspaceRoot = path.resolve(workspaceRoot); const resolvedWorkspaceRootReal = fs.existsSync(resolvedWorkspaceRoot) ? fs.realpathSync(resolvedWorkspaceRoot) @@ -2400,19 +2389,6 @@ export function createCoordinatorToolSet(deps: { return !isTaskShellStep(step) && (stepPhaseKey === "planning" || stepPhaseName === "planning"); } - function phaseHasCompletionEligibleStep( - phase: PhaseCard, - stepsForPhase: (phase: PhaseCard) => OrchestratorStep[], - ): boolean { - const phaseKey = phase.phaseKey.trim().toLowerCase(); - const phaseName = phase.name.trim().toLowerCase(); - const phaseSteps = filterExecutionSteps(stepsForPhase(phase)); - if (phaseKey === "planning" || phaseName === "planning") { - return phaseSteps.some((step) => isPlanningExecutionStep(step) && step.status === "succeeded"); - } - return phaseSteps.some((step) => TERMINAL_STEP_STATUSES.has(step.status)); - } - function phaseHasSuccessfulCompletion( phase: PhaseCard, stepsForPhase: (phase: PhaseCard) => OrchestratorStep[], diff --git a/apps/desktop/src/main/services/orchestrator/delegationContracts.ts b/apps/desktop/src/main/services/orchestrator/delegationContracts.ts index 3852fcd0c..e9db32ae9 100644 --- a/apps/desktop/src/main/services/orchestrator/delegationContracts.ts +++ b/apps/desktop/src/main/services/orchestrator/delegationContracts.ts @@ -9,7 +9,7 @@ import type { OrchestratorRunGraph, OrchestratorStep, } from "../../../shared/types"; -import { asRecord, TERMINAL_STEP_STATUSES } from "./orchestratorContext"; +import { asRecord } from "./orchestratorContext"; import { nowIso } from "../shared/utils"; type DelegationFailure = NonNullable; diff --git a/apps/desktop/src/main/services/orchestrator/executionPolicy.ts b/apps/desktop/src/main/services/orchestrator/executionPolicy.ts index 501db0896..93a595d87 100644 --- a/apps/desktop/src/main/services/orchestrator/executionPolicy.ts +++ b/apps/desktop/src/main/services/orchestrator/executionPolicy.ts @@ -31,7 +31,7 @@ import { DEFAULT_INTEGRATION_PR_POLICY } from "./orchestratorConstants"; -import { getDefaultModelDescriptor, getModelById, resolveModelDescriptor, resolveProviderGroupForModel } from "../../../shared/modelRegistry"; +import { getDefaultModelDescriptor, resolveModelDescriptor, resolveProviderGroupForModel } from "../../../shared/modelRegistry"; import { TERMINAL_STEP_STATUSES, filterExecutionSteps, isDisplayOnlyTaskStep } from "./orchestratorContext"; // ───────────────────────────────────────────────────── diff --git a/apps/desktop/src/main/services/orchestrator/orchestratorContext.ts b/apps/desktop/src/main/services/orchestrator/orchestratorContext.ts index 2c20a158a..bc04cdfb3 100644 --- a/apps/desktop/src/main/services/orchestrator/orchestratorContext.ts +++ b/apps/desktop/src/main/services/orchestrator/orchestratorContext.ts @@ -46,7 +46,6 @@ import type { AgentChatEventEnvelope, TeamManifest, ExecutionPlanPreview, - OrchestratorStep, OrchestratorWorkerRole, RecoveryLoopPolicy, RecoveryLoopState, @@ -927,7 +926,6 @@ export function deriveMissionStatusFromRun(graph: OrchestratorRunGraph, mission: if (graph.run.status === "succeeded") { return "completed"; } - if (graph.run.status === "failed") return "failed"; if (graph.run.status === "canceled") return "canceled"; const hasBlockingIntervention = mission.interventions.some((entry) => { @@ -937,6 +935,7 @@ export function deriveMissionStatusFromRun(graph: OrchestratorRunGraph, mission: return metadata?.canProceedWithoutAnswer !== true; }); if (hasBlockingIntervention) return "intervention_required"; + if (graph.run.status === "failed") return "failed"; if ( graph.run.status === "active" || graph.run.status === "bootstrapping" diff --git a/apps/desktop/src/main/services/orchestrator/orchestratorQueries.ts b/apps/desktop/src/main/services/orchestrator/orchestratorQueries.ts index 7897f6b8d..e174c3e5f 100644 --- a/apps/desktop/src/main/services/orchestrator/orchestratorQueries.ts +++ b/apps/desktop/src/main/services/orchestrator/orchestratorQueries.ts @@ -14,7 +14,6 @@ import type { OrchestratorClaim, OrchestratorClaimScope, OrchestratorClaimState, - OrchestratorContextProfileId, OrchestratorContextPolicyProfile, OrchestratorContextSnapshot, OrchestratorContextSnapshotCursor, diff --git a/apps/desktop/src/main/services/orchestrator/orchestratorService.ts b/apps/desktop/src/main/services/orchestrator/orchestratorService.ts index 9198e8b3f..58c70be4c 100644 --- a/apps/desktop/src/main/services/orchestrator/orchestratorService.ts +++ b/apps/desktop/src/main/services/orchestrator/orchestratorService.ts @@ -2719,10 +2719,6 @@ export function createOrchestratorService({ return { blocked: true, recoveryMode: null }; }; - const hasOpenBlockingInterventionForRun = (run: OrchestratorRun): boolean => { - return getRunInterventionGateState(run).blocked; - }; - const ensurePlannerQuestionIntervention = (args: { missionId: string; runId: string; diff --git a/apps/desktop/src/main/services/state/kvDb.laneWorktreeLockMigration.test.ts b/apps/desktop/src/main/services/state/kvDb.laneWorktreeLockMigration.test.ts deleted file mode 100644 index 560aec395..000000000 --- a/apps/desktop/src/main/services/state/kvDb.laneWorktreeLockMigration.test.ts +++ /dev/null @@ -1,123 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { createRequire } from "node:module"; -import { describe, expect, it } from "vitest"; -import { createLaneWorktreeLockService } from "../lanes/laneWorktreeLockService"; -import { openKvDb } from "./kvDb"; - -const require = createRequire(import.meta.url); - -type RawDb = { - exec: (sql: string) => void; - prepare: (sql: string) => { run: (...params: unknown[]) => void }; - close: () => void; -}; - -function createLogger() { - return { - debug: () => {}, - info: () => {}, - warn: () => {}, - error: () => {}, - } as const; -} - -function makeDbPath(prefix: string): string { - const root = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); - return path.join(root, ".ade", "kv.sqlite"); -} - -describe("kvDb lane worktree lock schema migration", () => { - it("repairs legacy lock tables before lane lock upserts run", async () => { - const dbPath = makeDbPath("ade-kvdb-lane-lock-legacy-"); - const worktreePath = path.join(path.dirname(dbPath), "worktree"); - fs.mkdirSync(worktreePath, { recursive: true }); - fs.mkdirSync(path.dirname(dbPath), { recursive: true }); - - const { DatabaseSync } = require("node:sqlite") as { DatabaseSync: new (path: string) => RawDb }; - const rawDb = new DatabaseSync(dbPath); - rawDb.exec(` - create table lane_worktree_locks ( - worktree_key text not null, - worktree_path text not null, - lane_id text not null, - owner_kind text not null, - owner_pr_id text, - owner_session_id text, - owner_proposal_id text, - owner_label text not null, - token text not null, - created_at text not null, - heartbeat_at text not null, - expires_at text not null - ); - `); - const insert = rawDb.prepare(` - insert into lane_worktree_locks ( - worktree_key, worktree_path, lane_id, owner_kind, owner_pr_id, - owner_session_id, owner_proposal_id, owner_label, token, - created_at, heartbeat_at, expires_at - ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `); - const key = fs.realpathSync.native(worktreePath); - const expiredAt = "2026-01-01T00:00:00.000Z"; - insert.run(key, key, "lane-old", "path_to_merge", "pr-old", null, null, "Old stale lock 1", "token-1", expiredAt, expiredAt, expiredAt); - insert.run(key, key, "lane-old", "path_to_merge", "pr-old", null, null, "Old stale lock 2", "token-2", expiredAt, expiredAt, expiredAt); - rawDb.close(); - - const db = await openKvDb(dbPath, createLogger() as any); - try { - expect( - db.get<{ name: string }>( - "select name from sqlite_master where type = 'index' and name = 'idx_lane_worktree_locks_worktree_key_unique' limit 1", - )?.name, - ).toBe("idx_lane_worktree_locks_worktree_key_unique"); - expect(db.get<{ count: number }>("select count(1) as count from lane_worktree_locks where worktree_key = ?", [key])?.count).toBe(1); - - const service = createLaneWorktreeLockService({ db, logger: createLogger() }); - const acquired = service.acquire({ - laneId: "lane-new", - worktreePath, - ownerKind: "path_to_merge", - ownerPrId: "pr-new", - ownerLabel: "Path to Merge for PR #123", - }); - - expect(acquired.token).toBeTruthy(); - expect(acquired.lock.worktreeKey).toBe(key); - expect(acquired.lock.laneId).toBe("lane-new"); - expect(db.get<{ count: number }>("select count(1) as count from lane_worktree_locks where worktree_key = ?", [key])?.count).toBe(1); - } finally { - db.close(); - } - }); -}); - -describe("lane worktree locks", () => { - it("returns the actual number of rows released", async () => { - const dbPath = makeDbPath("ade-kvdb-lane-lock-release-"); - const worktreePath = path.join(path.dirname(dbPath), "worktree"); - fs.mkdirSync(worktreePath, { recursive: true }); - - const db = await openKvDb(dbPath, createLogger() as any); - try { - const service = createLaneWorktreeLockService({ db, logger: createLogger() }); - const acquired = service.acquire({ - laneId: "lane-1", - worktreePath, - ownerKind: "path_to_merge", - ownerPrId: "pr-1", - ownerLabel: "Path to Merge for PR #1", - }); - - expect(service.release({ ownerKind: "path_to_merge", ownerPrId: "missing" })).toBe(0); - expect(service.getActiveForLane("lane-1")).toHaveLength(1); - expect(service.release({ token: "missing-token" })).toBe(0); - expect(service.release({ token: acquired.token })).toBe(1); - expect(service.release({ token: acquired.token })).toBe(0); - } finally { - db.close(); - } - }); -}); diff --git a/apps/desktop/src/main/services/state/kvDb.migrations.test.ts b/apps/desktop/src/main/services/state/kvDb.migrations.test.ts new file mode 100644 index 000000000..2d3171723 --- /dev/null +++ b/apps/desktop/src/main/services/state/kvDb.migrations.test.ts @@ -0,0 +1,802 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { createRequire } from "node:module"; +import { describe, expect, it } from "vitest"; +import { createLaneWorktreeLockService } from "../lanes/laneWorktreeLockService"; +import { openKvDb } from "./kvDb"; + +const require = createRequire(import.meta.url); + +type RawDb = { + exec: (sql: string) => void; + prepare: (sql: string) => { run: (...params: unknown[]) => void }; + close: () => void; +}; + +function createLogger() { + return { + debug: () => {}, + info: () => {}, + warn: () => {}, + error: () => {}, + } as any; +} + +function listColumnNames(db: Awaited>, table: string): string[] { + const rows = db.all<{ name: string }>(`pragma table_info(${table})`); + return rows.map((row) => String(row.name ?? "")).filter(Boolean); +} + +function makeDbPath(prefix: string): string { + const root = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + return path.join(root, ".ade", "kv.sqlite"); +} + +function expectTables(db: Awaited>, tables: readonly string[]): void { + for (const table of tables) { + const hit = db.get<{ name: string }>( + "select name from sqlite_master where type = 'table' and name = ? limit 1", + [table], + ); + expect(hit?.name).toBe(table); + } +} + +function expectIndexes(db: Awaited>, indexes: readonly string[]): void { + for (const indexName of indexes) { + const hit = db.get<{ name: string }>( + "select name from sqlite_master where type = 'index' and name = ? limit 1", + [indexName], + ); + expect(hit?.name).toBe(indexName); + } +} + +describe("kvDb migrations - orchestrator schema bootstrap", () => { + it("creates Phase 1.5 context hardening tables and indexes", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-kvdb-orchestrator-")); + const dbPath = path.join(root, "ade.db"); + const db = await openKvDb(dbPath, createLogger()); + + const expectedTables = [ + "orchestrator_runs", + "orchestrator_steps", + "orchestrator_attempts", + "orchestrator_attempt_runtime", + "orchestrator_runtime_events", + "orchestrator_claims", + "orchestrator_context_snapshots", + "mission_step_handoffs", + "orchestrator_timeline_events", + "orchestrator_gate_reports", + "orchestrator_reflections", + "orchestrator_retrospectives", + "orchestrator_retrospective_trends", + "orchestrator_reflection_pattern_stats", + "orchestrator_reflection_pattern_sources", + "orchestrator_lane_decisions", + "orchestrator_ai_decisions", + ]; + + expectTables(db, expectedTables); + + expect(listColumnNames(db, "orchestrator_runs")).toEqual( + expect.arrayContaining([ + "id", + "project_id", + "mission_id", + "status", + "context_profile", + "scheduler_state", + "runtime_cursor_json", + "last_error", + "metadata_json", + "created_at", + "updated_at", + ]), + ); + + expect(listColumnNames(db, "orchestrator_steps")).toEqual( + expect.arrayContaining([ + "id", + "run_id", + "project_id", + "mission_step_id", + "step_key", + "status", + "join_policy", + "dependency_step_ids_json", + "retry_limit", + "retry_count", + ]), + ); + + expect(listColumnNames(db, "orchestrator_attempts")).toEqual( + expect.arrayContaining([ + "id", + "run_id", + "step_id", + "project_id", + "attempt_number", + "status", + "executor_kind", + "tracked_session_enforced", + "context_profile", + "context_snapshot_id", + "error_class", + "result_envelope_json", + ]), + ); + + expect(listColumnNames(db, "orchestrator_attempt_runtime")).toEqual( + expect.arrayContaining([ + "attempt_id", + "session_id", + "runtime_state", + "last_signal_at", + "last_output_preview", + "last_preview_digest", + "digest_since_ms", + "repeat_count", + "last_waiting_intervention_at_ms", + "last_event_heartbeat_at_ms", + "last_waiting_notified_at_ms", + "updated_at", + ]), + ); + + expect(listColumnNames(db, "orchestrator_runtime_events")).toEqual( + expect.arrayContaining([ + "id", + "project_id", + "run_id", + "step_id", + "attempt_id", + "session_id", + "event_type", + "event_key", + "occurred_at", + "payload_json", + "created_at", + ]), + ); + + expect(listColumnNames(db, "orchestrator_claims")).toEqual( + expect.arrayContaining([ + "id", + "project_id", + "run_id", + "step_id", + "attempt_id", + "owner_id", + "scope_kind", + "scope_value", + "state", + "heartbeat_at", + "expires_at", + "policy_json", + ]), + ); + + expect(listColumnNames(db, "orchestrator_context_snapshots")).toEqual( + expect.arrayContaining([ + "id", + "project_id", + "run_id", + "step_id", + "attempt_id", + "snapshot_type", + "context_profile", + "cursor_json", + "created_at", + ]), + ); + + expect(listColumnNames(db, "mission_step_handoffs")).toEqual( + expect.arrayContaining([ + "id", + "project_id", + "mission_id", + "mission_step_id", + "run_id", + "step_id", + "attempt_id", + "handoff_type", + "producer", + "payload_json", + "created_at", + ]), + ); + + expect(listColumnNames(db, "orchestrator_timeline_events")).toEqual( + expect.arrayContaining([ + "id", + "project_id", + "run_id", + "step_id", + "attempt_id", + "claim_id", + "event_type", + "reason", + "detail_json", + "created_at", + ]), + ); + + expect(listColumnNames(db, "orchestrator_gate_reports")).toEqual( + expect.arrayContaining(["id", "project_id", "generated_at", "report_json"]), + ); + + expect(listColumnNames(db, "orchestrator_reflections")).toEqual( + expect.arrayContaining([ + "id", + "project_id", + "mission_id", + "run_id", + "agent_role", + "phase", + "signal_type", + "observation", + "recommendation", + "context", + "occurred_at", + "created_at", + "schema_version", + ]), + ); + + expect(listColumnNames(db, "orchestrator_retrospectives")).toEqual( + expect.arrayContaining([ + "id", + "project_id", + "mission_id", + "run_id", + "generated_at", + "final_status", + "payload_json", + "schema_version", + "created_at", + ]), + ); + + expect(listColumnNames(db, "orchestrator_retrospective_trends")).toEqual( + expect.arrayContaining([ + "id", + "project_id", + "mission_id", + "run_id", + "retrospective_id", + "source_mission_id", + "source_run_id", + "source_retrospective_id", + "pain_point_key", + "pain_point_label", + "status", + "previous_pain_score", + "current_pain_score", + "created_at", + ]), + ); + + expect(listColumnNames(db, "orchestrator_reflection_pattern_stats")).toEqual( + expect.arrayContaining([ + "id", + "project_id", + "pattern_key", + "pattern_label", + "occurrence_count", + "first_seen_retrospective_id", + "first_seen_run_id", + "last_seen_retrospective_id", + "last_seen_run_id", + "created_at", + "updated_at", + ]), + ); + + expect(listColumnNames(db, "orchestrator_reflection_pattern_sources")).toEqual( + expect.arrayContaining([ + "id", + "project_id", + "pattern_stat_id", + "retrospective_id", + "mission_id", + "run_id", + "created_at", + ]), + ); + + expect(listColumnNames(db, "orchestrator_lane_decisions")).toEqual( + expect.arrayContaining([ + "id", + "project_id", + "mission_id", + "run_id", + "step_id", + "step_key", + "lane_id", + "decision_type", + "validator_outcome", + "rule_hits_json", + "rationale", + "metadata_json", + "created_at", + ]), + ); + + expect(listColumnNames(db, "orchestrator_ai_decisions")).toEqual( + expect.arrayContaining([ + "id", + "project_id", + "mission_id", + "run_id", + "step_id", + "attempt_id", + "call_type", + "provider", + "model", + "timeout_cap_ms", + "decision_json", + "action_trace_json", + "validation_json", + "rationale", + "fallback_used", + "failure_reason", + "duration_ms", + "prompt_tokens", + "completion_tokens", + "created_at", + ]), + ); + + const expectedIndexes = [ + "idx_orchestrator_runs_project_status", + "idx_orchestrator_runs_mission", + "idx_orchestrator_runs_project_updated", + "idx_orchestrator_steps_run_status", + "idx_orchestrator_steps_project_status", + "idx_orchestrator_steps_run_order", + "idx_orchestrator_attempts_run_status", + "idx_orchestrator_attempts_step_status", + "idx_orchestrator_attempts_project_created", + "idx_orchestrator_attempt_runtime_session", + "idx_orchestrator_attempt_runtime_updated", + "idx_orchestrator_runtime_events_run_occurred", + "idx_orchestrator_runtime_events_attempt_occurred", + "idx_orchestrator_runtime_events_session_occurred", + "idx_orchestrator_runtime_events_project_key", + "idx_orchestrator_claims_run_state", + "idx_orchestrator_claims_scope_state", + "idx_orchestrator_claims_expires", + "idx_orchestrator_claims_active_scope", + "idx_orchestrator_context_snapshots_run_created", + "idx_orchestrator_context_snapshots_attempt", + "idx_mission_step_handoffs_mission_created", + "idx_mission_step_handoffs_step_created", + "idx_mission_step_handoffs_attempt", + "idx_orchestrator_timeline_run_created", + "idx_orchestrator_timeline_attempt", + "idx_orchestrator_timeline_project_created", + "idx_orchestrator_gate_reports_project_generated", + "idx_orchestrator_reflections_run_occurred", + "idx_orchestrator_reflections_mission", + "idx_orchestrator_retrospectives_mission_generated", + "idx_orchestrator_retrospective_trends_mission_created", + "idx_orchestrator_retrospective_trends_run_created", + "idx_orchestrator_reflection_pattern_stats_count", + "idx_orchestrator_reflection_pattern_sources_pattern", + "idx_orchestrator_reflection_pattern_sources_mission", + "idx_orchestrator_lane_decisions_mission_created", + "idx_orchestrator_lane_decisions_run_created", + "idx_orchestrator_lane_decisions_step_created", + "idx_orchestrator_lane_decisions_lane_created", + "idx_orchestrator_ai_decisions_mission_created", + "idx_orchestrator_ai_decisions_run_created", + "idx_orchestrator_ai_decisions_step_created", + "idx_orchestrator_ai_decisions_project_category_created", + "idx_orchestrator_ai_decisions_created", + ]; + + expectIndexes(db, expectedIndexes); + + const activeScopeSql = db.get<{ sql: string | null }>( + "select sql from sqlite_master where type = 'index' and name = 'idx_orchestrator_claims_active_scope' limit 1", + ); + expect((activeScopeSql?.sql ?? "").toLowerCase()).toContain("where state = 'active'"); + + db.close(); + }); +}); + +describe("kvDb migrations - lane worktree lock schema", () => { + it("repairs legacy lock tables before lane lock upserts run", async () => { + const dbPath = makeDbPath("ade-kvdb-lane-lock-legacy-"); + const worktreePath = path.join(path.dirname(dbPath), "worktree"); + fs.mkdirSync(worktreePath, { recursive: true }); + fs.mkdirSync(path.dirname(dbPath), { recursive: true }); + + const { DatabaseSync } = require("node:sqlite") as { DatabaseSync: new (path: string) => RawDb }; + const rawDb = new DatabaseSync(dbPath); + rawDb.exec(` + create table lane_worktree_locks ( + worktree_key text not null, + worktree_path text not null, + lane_id text not null, + owner_kind text not null, + owner_pr_id text, + owner_session_id text, + owner_proposal_id text, + owner_label text not null, + token text not null, + created_at text not null, + heartbeat_at text not null, + expires_at text not null + ); + `); + const insert = rawDb.prepare(` + insert into lane_worktree_locks ( + worktree_key, worktree_path, lane_id, owner_kind, owner_pr_id, + owner_session_id, owner_proposal_id, owner_label, token, + created_at, heartbeat_at, expires_at + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `); + const key = fs.realpathSync.native(worktreePath); + const expiredAt = "2026-01-01T00:00:00.000Z"; + insert.run(key, key, "lane-old", "path_to_merge", "pr-old", null, null, "Old stale lock 1", "token-1", expiredAt, expiredAt, expiredAt); + insert.run(key, key, "lane-old", "path_to_merge", "pr-old", null, null, "Old stale lock 2", "token-2", expiredAt, expiredAt, expiredAt); + rawDb.close(); + + const db = await openKvDb(dbPath, createLogger()); + try { + expect( + db.get<{ name: string }>( + "select name from sqlite_master where type = 'index' and name = 'idx_lane_worktree_locks_worktree_key_unique' limit 1", + )?.name, + ).toBe("idx_lane_worktree_locks_worktree_key_unique"); + expect(db.get<{ count: number }>("select count(1) as count from lane_worktree_locks where worktree_key = ?", [key])?.count).toBe(1); + + const service = createLaneWorktreeLockService({ db, logger: createLogger() }); + const acquired = service.acquire({ + laneId: "lane-new", + worktreePath, + ownerKind: "path_to_merge", + ownerPrId: "pr-new", + ownerLabel: "Path to Merge for PR #123", + }); + + expect(acquired.token).toBeTruthy(); + expect(acquired.lock.worktreeKey).toBe(key); + expect(acquired.lock.laneId).toBe("lane-new"); + expect(db.get<{ count: number }>("select count(1) as count from lane_worktree_locks where worktree_key = ?", [key])?.count).toBe(1); + } finally { + db.close(); + } + }); + + it("returns the actual number of rows released", async () => { + const dbPath = makeDbPath("ade-kvdb-lane-lock-release-"); + const worktreePath = path.join(path.dirname(dbPath), "worktree"); + fs.mkdirSync(worktreePath, { recursive: true }); + + const db = await openKvDb(dbPath, createLogger()); + try { + const service = createLaneWorktreeLockService({ db, logger: createLogger() }); + const acquired = service.acquire({ + laneId: "lane-1", + worktreePath, + ownerKind: "path_to_merge", + ownerPrId: "pr-1", + ownerLabel: "Path to Merge for PR #1", + }); + + expect(service.release({ ownerKind: "path_to_merge", ownerPrId: "missing" })).toBe(0); + expect(service.getActiveForLane("lane-1")).toHaveLength(1); + expect(service.release({ token: "missing-token" })).toBe(0); + expect(service.release({ token: acquired.token })).toBe(1); + expect(service.release({ token: acquired.token })).toBe(0); + } finally { + db.close(); + } + }); +}); + +describe("kvDb migrations - pipeline settings", () => { + it("backfills legacy default-shaped PtM settings without touching customized rows", async () => { + const dbPath = makeDbPath("ade-kvdb-pipeline-settings-legacy-"); + fs.mkdirSync(path.dirname(dbPath), { recursive: true }); + + const { DatabaseSync } = require("node:sqlite") as { DatabaseSync: new (path: string) => RawDb }; + const rawDb = new DatabaseSync(dbPath); + rawDb.exec(` + create table pr_pipeline_settings ( + pr_id text primary key, + auto_merge integer not null default 0, + merge_method text not null default 'repo_default', + max_rounds integer not null default 5, + on_rebase_needed text not null default 'pause', + conflict_strategy text not null default 'pause', + force_finalize_mode text not null default 'off', + force_finalize_require_no_ci_failures integer not null default 1, + early_merge_on_green integer not null default 1, + auto_agent_provider text, + auto_agent_model text, + auto_agent_reasoning_effort text, + auto_agent_permission_mode text, + auto_agent_confidence_threshold real, + at_cap_policy text, + at_cap_wait_minutes integer, + at_cap_ci_retry_max integer, + force_merge_requires_confirmation integer, + updated_at text not null + ); + `); + const insert = rawDb.prepare(` + insert into pr_pipeline_settings ( + pr_id, auto_merge, merge_method, max_rounds, on_rebase_needed, + conflict_strategy, force_finalize_mode, force_finalize_require_no_ci_failures, + early_merge_on_green, at_cap_policy, updated_at + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `); + insert.run("pr-legacy", 0, "repo_default", 5, "pause", "pause", "off", 1, 1, null, "2026-05-01T00:00:00.000Z"); + insert.run("pr-custom", 0, "squash", 5, "pause", "pause", "off", 1, 1, "stop", "2026-05-01T00:00:00.000Z"); + rawDb.close(); + + const db = await openKvDb(dbPath, createLogger()); + try { + const legacy = db.get<{ + auto_merge: number; + force_finalize_mode: string; + at_cap_policy: string | null; + }>( + "select auto_merge, force_finalize_mode, at_cap_policy from pr_pipeline_settings where pr_id = ?", + ["pr-legacy"], + ); + expect(legacy).toEqual({ + auto_merge: 1, + force_finalize_mode: "conditional", + at_cap_policy: "ci_retry_once", + }); + + const custom = db.get<{ + auto_merge: number; + force_finalize_mode: string; + at_cap_policy: string | null; + }>( + "select auto_merge, force_finalize_mode, at_cap_policy from pr_pipeline_settings where pr_id = ?", + ["pr-custom"], + ); + expect(custom).toEqual({ + auto_merge: 0, + force_finalize_mode: "off", + at_cap_policy: "stop", + }); + + db.run( + "update pr_pipeline_settings set auto_merge = 0, force_finalize_mode = 'off', at_cap_policy = 'stop' where pr_id = ?", + ["pr-legacy"], + ); + } finally { + db.close(); + } + + const reopened = await openKvDb(dbPath, createLogger()); + try { + const legacyAfterUserOverride = reopened.get<{ + auto_merge: number; + force_finalize_mode: string; + at_cap_policy: string | null; + }>( + "select auto_merge, force_finalize_mode, at_cap_policy from pr_pipeline_settings where pr_id = ?", + ["pr-legacy"], + ); + expect(legacyAfterUserOverride).toEqual({ + auto_merge: 0, + force_finalize_mode: "off", + at_cap_policy: "stop", + }); + } finally { + reopened.close(); + } + }); +}); + +describe("kvDb migrations - mission schema", () => { + it("creates mission tables and key indexes", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-kvdb-missions-")); + const dbPath = path.join(root, "ade.db"); + const db = await openKvDb(dbPath, createLogger()); + + const expectedTables = [ + "missions", + "mission_steps", + "mission_events", + "mission_artifacts", + "mission_interventions", + ]; + + expectTables(db, expectedTables); + + expect(listColumnNames(db, "missions")).toEqual( + expect.arrayContaining([ + "id", + "project_id", + "lane_id", + "title", + "prompt", + "status", + "priority", + "execution_mode", + "target_machine_id", + "outcome_summary", + "last_error", + "metadata_json", + "created_at", + "updated_at", + "started_at", + "completed_at", + ]), + ); + + expect(listColumnNames(db, "mission_steps")).toEqual( + expect.arrayContaining(["mission_id", "step_index", "status", "started_at", "completed_at"]), + ); + + expect(listColumnNames(db, "lanes")).toEqual(expect.arrayContaining(["folder"])); + + expect(listColumnNames(db, "pr_groups")).toEqual( + expect.arrayContaining(["name", "auto_rebase", "ci_gating", "target_branch"]), + ); + + expect(listColumnNames(db, "integration_proposals")).toEqual( + expect.arrayContaining([ + "title", + "body", + "draft", + "integration_lane_name", + "status", + "integration_lane_id", + "preferred_integration_lane_id", + "merge_into_head_sha", + "resolution_state_json", + "pairwise_results_json", + "lane_summaries_json", + ]), + ); + + const expectedIndexes = [ + "idx_missions_project_updated", + "idx_mission_steps_mission_index", + "idx_mission_events_mission_created", + "idx_mission_artifacts_mission_created", + "idx_mission_interventions_mission_status", + ]; + + expectIndexes(db, expectedIndexes); + + db.close(); + }); +}); + +describe("kvDb migrations - worker agent schema", () => { + it("creates W2 worker tables and indexes", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-kvdb-workers-")); + const dbPath = path.join(root, "ade.db"); + const db = await openKvDb(dbPath, createLogger()); + + const expectedTables = [ + "worker_agents", + "worker_agent_revisions", + "worker_agent_cost_events", + "worker_agent_task_sessions", + "worker_agent_runs", + ]; + + expectTables(db, expectedTables); + + expect(listColumnNames(db, "worker_agents")).toEqual( + expect.arrayContaining([ + "id", + "project_id", + "slug", + "name", + "role", + "reports_to", + "capabilities_json", + "status", + "adapter_type", + "adapter_config_json", + "runtime_config_json", + "budget_monthly_cents", + "spent_monthly_cents", + "last_heartbeat_at", + "created_at", + "updated_at", + "deleted_at", + ]), + ); + + expect(listColumnNames(db, "worker_agent_revisions")).toEqual( + expect.arrayContaining([ + "id", + "project_id", + "agent_id", + "before_json", + "after_json", + "changed_keys_json", + "had_redactions", + "actor", + "created_at", + ]), + ); + + expect(listColumnNames(db, "worker_agent_cost_events")).toEqual( + expect.arrayContaining([ + "id", + "project_id", + "agent_id", + "run_id", + "session_id", + "provider", + "model_id", + "input_tokens", + "output_tokens", + "cost_cents", + "estimated", + "source", + "occurred_at", + "created_at", + ]), + ); + + expect(listColumnNames(db, "worker_agent_task_sessions")).toEqual( + expect.arrayContaining([ + "id", + "project_id", + "agent_id", + "adapter_type", + "task_key", + "payload_json", + "cleared_at", + "created_at", + "updated_at", + ]), + ); + + expect(listColumnNames(db, "worker_agent_runs")).toEqual( + expect.arrayContaining([ + "id", + "project_id", + "agent_id", + "status", + "wakeup_reason", + "task_key", + "issue_key", + "execution_run_id", + "execution_locked_at", + "context_json", + "result_json", + "error_message", + "started_at", + "finished_at", + "created_at", + "updated_at", + ]), + ); + + const expectedIndexes = [ + "idx_worker_agents_project", + "idx_worker_agents_project_active", + "idx_worker_agent_revisions_agent", + "idx_worker_agent_task_sessions_lookup", + "idx_worker_agent_runs_agent", + "idx_worker_agent_runs_status", + "idx_worker_agent_cost_events_agent", + "idx_worker_agent_cost_events_month", + ]; + + expectIndexes(db, expectedIndexes); + + db.close(); + }); +}); diff --git a/apps/desktop/src/main/services/state/kvDb.missionsMigration.test.ts b/apps/desktop/src/main/services/state/kvDb.missionsMigration.test.ts deleted file mode 100644 index 85f431baa..000000000 --- a/apps/desktop/src/main/services/state/kvDb.missionsMigration.test.ts +++ /dev/null @@ -1,110 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { describe, expect, it } from "vitest"; -import { openKvDb } from "./kvDb"; - -function createLogger() { - return { - debug: () => {}, - info: () => {}, - warn: () => {}, - error: () => {} - } as any; -} - -function listColumnNames(db: Awaited>, table: string): string[] { - const rows = db.all<{ name: string }>(`pragma table_info(${table})`); - return rows.map((row) => String(row.name ?? "")).filter(Boolean); -} - -describe("kvDb mission schema migration", () => { - it("creates mission tables and key indexes", async () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-kvdb-missions-")); - const dbPath = path.join(root, "ade.db"); - const db = await openKvDb(dbPath, createLogger()); - - const expectedTables = [ - "missions", - "mission_steps", - "mission_events", - "mission_artifacts", - "mission_interventions" - ]; - - for (const table of expectedTables) { - const hit = db.get<{ name: string }>( - "select name from sqlite_master where type = 'table' and name = ? limit 1", - [table] - ); - expect(hit?.name).toBe(table); - } - - expect(listColumnNames(db, "missions")).toEqual( - expect.arrayContaining([ - "id", - "project_id", - "lane_id", - "title", - "prompt", - "status", - "priority", - "execution_mode", - "target_machine_id", - "outcome_summary", - "last_error", - "metadata_json", - "created_at", - "updated_at", - "started_at", - "completed_at" - ]) - ); - - expect(listColumnNames(db, "mission_steps")).toEqual( - expect.arrayContaining(["mission_id", "step_index", "status", "started_at", "completed_at"]) - ); - - expect(listColumnNames(db, "lanes")).toEqual( - expect.arrayContaining(["folder"]) - ); - - expect(listColumnNames(db, "pr_groups")).toEqual( - expect.arrayContaining(["name", "auto_rebase", "ci_gating", "target_branch"]) - ); - - expect(listColumnNames(db, "integration_proposals")).toEqual( - expect.arrayContaining([ - "title", - "body", - "draft", - "integration_lane_name", - "status", - "integration_lane_id", - "preferred_integration_lane_id", - "merge_into_head_sha", - "resolution_state_json", - "pairwise_results_json", - "lane_summaries_json" - ]) - ); - - const expectedIndexes = [ - "idx_missions_project_updated", - "idx_mission_steps_mission_index", - "idx_mission_events_mission_created", - "idx_mission_artifacts_mission_created", - "idx_mission_interventions_mission_status", - ]; - - for (const indexName of expectedIndexes) { - const hit = db.get<{ name: string }>( - "select name from sqlite_master where type = 'index' and name = ? limit 1", - [indexName] - ); - expect(hit?.name).toBe(indexName); - } - - db.close(); - }); -}); diff --git a/apps/desktop/src/main/services/state/kvDb.orchestratorMigration.test.ts b/apps/desktop/src/main/services/state/kvDb.orchestratorMigration.test.ts deleted file mode 100644 index fa3f7e60e..000000000 --- a/apps/desktop/src/main/services/state/kvDb.orchestratorMigration.test.ts +++ /dev/null @@ -1,392 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { describe, expect, it } from "vitest"; -import { openKvDb } from "./kvDb"; - -function createLogger() { - return { - debug: () => {}, - info: () => {}, - warn: () => {}, - error: () => {} - } as any; -} - -function listColumnNames(db: Awaited>, table: string): string[] { - const rows = db.all<{ name: string }>(`pragma table_info(${table})`); - return rows.map((row) => String(row.name ?? "")).filter(Boolean); -} - -describe("kvDb orchestrator schema bootstrap", () => { - it("creates Phase 1.5 context hardening tables and indexes", async () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-kvdb-orchestrator-")); - const dbPath = path.join(root, "ade.db"); - const db = await openKvDb(dbPath, createLogger()); - - const expectedTables = [ - "orchestrator_runs", - "orchestrator_steps", - "orchestrator_attempts", - "orchestrator_attempt_runtime", - "orchestrator_runtime_events", - "orchestrator_claims", - "orchestrator_context_snapshots", - "mission_step_handoffs", - "orchestrator_timeline_events", - "orchestrator_gate_reports", - "orchestrator_reflections", - "orchestrator_retrospectives", - "orchestrator_retrospective_trends", - "orchestrator_reflection_pattern_stats", - "orchestrator_reflection_pattern_sources", - "orchestrator_lane_decisions", - "orchestrator_ai_decisions" - ]; - - for (const table of expectedTables) { - const hit = db.get<{ name: string }>( - "select name from sqlite_master where type = 'table' and name = ? limit 1", - [table] - ); - expect(hit?.name).toBe(table); - } - - expect(listColumnNames(db, "orchestrator_runs")).toEqual( - expect.arrayContaining([ - "id", - "project_id", - "mission_id", - "status", - "context_profile", - "scheduler_state", - "runtime_cursor_json", - "last_error", - "metadata_json", - "created_at", - "updated_at" - ]) - ); - - expect(listColumnNames(db, "orchestrator_steps")).toEqual( - expect.arrayContaining([ - "id", - "run_id", - "project_id", - "mission_step_id", - "step_key", - "status", - "join_policy", - "dependency_step_ids_json", - "retry_limit", - "retry_count" - ]) - ); - - expect(listColumnNames(db, "orchestrator_attempts")).toEqual( - expect.arrayContaining([ - "id", - "run_id", - "step_id", - "project_id", - "attempt_number", - "status", - "executor_kind", - "tracked_session_enforced", - "context_profile", - "context_snapshot_id", - "error_class", - "result_envelope_json" - ]) - ); - - expect(listColumnNames(db, "orchestrator_attempt_runtime")).toEqual( - expect.arrayContaining([ - "attempt_id", - "session_id", - "runtime_state", - "last_signal_at", - "last_output_preview", - "last_preview_digest", - "digest_since_ms", - "repeat_count", - "last_waiting_intervention_at_ms", - "last_event_heartbeat_at_ms", - "last_waiting_notified_at_ms", - "updated_at" - ]) - ); - - expect(listColumnNames(db, "orchestrator_runtime_events")).toEqual( - expect.arrayContaining([ - "id", - "project_id", - "run_id", - "step_id", - "attempt_id", - "session_id", - "event_type", - "event_key", - "occurred_at", - "payload_json", - "created_at" - ]) - ); - - expect(listColumnNames(db, "orchestrator_claims")).toEqual( - expect.arrayContaining([ - "id", - "project_id", - "run_id", - "step_id", - "attempt_id", - "owner_id", - "scope_kind", - "scope_value", - "state", - "heartbeat_at", - "expires_at", - "policy_json" - ]) - ); - - expect(listColumnNames(db, "orchestrator_context_snapshots")).toEqual( - expect.arrayContaining([ - "id", - "project_id", - "run_id", - "step_id", - "attempt_id", - "snapshot_type", - "context_profile", - "cursor_json", - "created_at" - ]) - ); - - expect(listColumnNames(db, "mission_step_handoffs")).toEqual( - expect.arrayContaining([ - "id", - "project_id", - "mission_id", - "mission_step_id", - "run_id", - "step_id", - "attempt_id", - "handoff_type", - "producer", - "payload_json", - "created_at" - ]) - ); - - expect(listColumnNames(db, "orchestrator_timeline_events")).toEqual( - expect.arrayContaining([ - "id", - "project_id", - "run_id", - "step_id", - "attempt_id", - "claim_id", - "event_type", - "reason", - "detail_json", - "created_at" - ]) - ); - - expect(listColumnNames(db, "orchestrator_gate_reports")).toEqual( - expect.arrayContaining([ - "id", - "project_id", - "generated_at", - "report_json" - ]) - ); - - expect(listColumnNames(db, "orchestrator_reflections")).toEqual( - expect.arrayContaining([ - "id", - "project_id", - "mission_id", - "run_id", - "agent_role", - "phase", - "signal_type", - "observation", - "recommendation", - "context", - "occurred_at", - "created_at", - "schema_version" - ]) - ); - - expect(listColumnNames(db, "orchestrator_retrospectives")).toEqual( - expect.arrayContaining([ - "id", - "project_id", - "mission_id", - "run_id", - "generated_at", - "final_status", - "payload_json", - "schema_version", - "created_at" - ]) - ); - - expect(listColumnNames(db, "orchestrator_retrospective_trends")).toEqual( - expect.arrayContaining([ - "id", - "project_id", - "mission_id", - "run_id", - "retrospective_id", - "source_mission_id", - "source_run_id", - "source_retrospective_id", - "pain_point_key", - "pain_point_label", - "status", - "previous_pain_score", - "current_pain_score", - "created_at" - ]) - ); - - expect(listColumnNames(db, "orchestrator_reflection_pattern_stats")).toEqual( - expect.arrayContaining([ - "id", - "project_id", - "pattern_key", - "pattern_label", - "occurrence_count", - "first_seen_retrospective_id", - "first_seen_run_id", - "last_seen_retrospective_id", - "last_seen_run_id", - "created_at", - "updated_at" - ]) - ); - - expect(listColumnNames(db, "orchestrator_reflection_pattern_sources")).toEqual( - expect.arrayContaining([ - "id", - "project_id", - "pattern_stat_id", - "retrospective_id", - "mission_id", - "run_id", - "created_at" - ]) - ); - - expect(listColumnNames(db, "orchestrator_lane_decisions")).toEqual( - expect.arrayContaining([ - "id", - "project_id", - "mission_id", - "run_id", - "step_id", - "step_key", - "lane_id", - "decision_type", - "validator_outcome", - "rule_hits_json", - "rationale", - "metadata_json", - "created_at" - ]) - ); - - expect(listColumnNames(db, "orchestrator_ai_decisions")).toEqual( - expect.arrayContaining([ - "id", - "project_id", - "mission_id", - "run_id", - "step_id", - "attempt_id", - "call_type", - "provider", - "model", - "timeout_cap_ms", - "decision_json", - "action_trace_json", - "validation_json", - "rationale", - "fallback_used", - "failure_reason", - "duration_ms", - "prompt_tokens", - "completion_tokens", - "created_at" - ]) - ); - - const expectedIndexes = [ - "idx_orchestrator_runs_project_status", - "idx_orchestrator_runs_mission", - "idx_orchestrator_runs_project_updated", - "idx_orchestrator_steps_run_status", - "idx_orchestrator_steps_project_status", - "idx_orchestrator_steps_run_order", - "idx_orchestrator_attempts_run_status", - "idx_orchestrator_attempts_step_status", - "idx_orchestrator_attempts_project_created", - "idx_orchestrator_attempt_runtime_session", - "idx_orchestrator_attempt_runtime_updated", - "idx_orchestrator_runtime_events_run_occurred", - "idx_orchestrator_runtime_events_attempt_occurred", - "idx_orchestrator_runtime_events_session_occurred", - "idx_orchestrator_runtime_events_project_key", - "idx_orchestrator_claims_run_state", - "idx_orchestrator_claims_scope_state", - "idx_orchestrator_claims_expires", - "idx_orchestrator_claims_active_scope", - "idx_orchestrator_context_snapshots_run_created", - "idx_orchestrator_context_snapshots_attempt", - "idx_mission_step_handoffs_mission_created", - "idx_mission_step_handoffs_step_created", - "idx_mission_step_handoffs_attempt", - "idx_orchestrator_timeline_run_created", - "idx_orchestrator_timeline_attempt", - "idx_orchestrator_timeline_project_created", - "idx_orchestrator_gate_reports_project_generated", - "idx_orchestrator_reflections_run_occurred", - "idx_orchestrator_reflections_mission", - "idx_orchestrator_retrospectives_mission_generated", - "idx_orchestrator_retrospective_trends_mission_created", - "idx_orchestrator_retrospective_trends_run_created", - "idx_orchestrator_reflection_pattern_stats_count", - "idx_orchestrator_reflection_pattern_sources_pattern", - "idx_orchestrator_reflection_pattern_sources_mission", - "idx_orchestrator_lane_decisions_mission_created", - "idx_orchestrator_lane_decisions_run_created", - "idx_orchestrator_lane_decisions_step_created", - "idx_orchestrator_lane_decisions_lane_created", - "idx_orchestrator_ai_decisions_mission_created", - "idx_orchestrator_ai_decisions_run_created", - "idx_orchestrator_ai_decisions_step_created", - "idx_orchestrator_ai_decisions_project_category_created", - "idx_orchestrator_ai_decisions_created" - ]; - - for (const indexName of expectedIndexes) { - const hit = db.get<{ name: string }>( - "select name from sqlite_master where type = 'index' and name = ? limit 1", - [indexName] - ); - expect(hit?.name).toBe(indexName); - } - - const activeScopeSql = db.get<{ sql: string | null }>( - "select sql from sqlite_master where type = 'index' and name = 'idx_orchestrator_claims_active_scope' limit 1" - ); - expect((activeScopeSql?.sql ?? "").toLowerCase()).toContain("where state = 'active'"); - - db.close(); - }); -}); diff --git a/apps/desktop/src/main/services/state/kvDb.pipelineSettingsMigration.test.ts b/apps/desktop/src/main/services/state/kvDb.pipelineSettingsMigration.test.ts deleted file mode 100644 index b2f6eaf10..000000000 --- a/apps/desktop/src/main/services/state/kvDb.pipelineSettingsMigration.test.ts +++ /dev/null @@ -1,128 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { createRequire } from "node:module"; -import { describe, expect, it } from "vitest"; -import { openKvDb } from "./kvDb"; - -const require = createRequire(import.meta.url); - -type RawDb = { - exec: (sql: string) => void; - prepare: (sql: string) => { run: (...params: unknown[]) => void }; - close: () => void; -}; - -function createLogger() { - return { - debug: () => {}, - info: () => {}, - warn: () => {}, - error: () => {}, - } as const; -} - -function makeDbPath(prefix: string): string { - const root = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); - return path.join(root, ".ade", "kv.sqlite"); -} - -describe("kvDb pipeline settings migration", () => { - it("backfills legacy default-shaped PtM settings without touching customized rows", async () => { - const dbPath = makeDbPath("ade-kvdb-pipeline-settings-legacy-"); - fs.mkdirSync(path.dirname(dbPath), { recursive: true }); - - const { DatabaseSync } = require("node:sqlite") as { DatabaseSync: new (path: string) => RawDb }; - const rawDb = new DatabaseSync(dbPath); - rawDb.exec(` - create table pr_pipeline_settings ( - pr_id text primary key, - auto_merge integer not null default 0, - merge_method text not null default 'repo_default', - max_rounds integer not null default 5, - on_rebase_needed text not null default 'pause', - conflict_strategy text not null default 'pause', - force_finalize_mode text not null default 'off', - force_finalize_require_no_ci_failures integer not null default 1, - early_merge_on_green integer not null default 1, - auto_agent_provider text, - auto_agent_model text, - auto_agent_reasoning_effort text, - auto_agent_permission_mode text, - auto_agent_confidence_threshold real, - at_cap_policy text, - at_cap_wait_minutes integer, - at_cap_ci_retry_max integer, - force_merge_requires_confirmation integer, - updated_at text not null - ); - `); - const insert = rawDb.prepare(` - insert into pr_pipeline_settings ( - pr_id, auto_merge, merge_method, max_rounds, on_rebase_needed, - conflict_strategy, force_finalize_mode, force_finalize_require_no_ci_failures, - early_merge_on_green, at_cap_policy, updated_at - ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `); - insert.run("pr-legacy", 0, "repo_default", 5, "pause", "pause", "off", 1, 1, null, "2026-05-01T00:00:00.000Z"); - insert.run("pr-custom", 0, "squash", 5, "pause", "pause", "off", 1, 1, "stop", "2026-05-01T00:00:00.000Z"); - rawDb.close(); - - const db = await openKvDb(dbPath, createLogger() as any); - try { - const legacy = db.get<{ - auto_merge: number; - force_finalize_mode: string; - at_cap_policy: string | null; - }>( - "select auto_merge, force_finalize_mode, at_cap_policy from pr_pipeline_settings where pr_id = ?", - ["pr-legacy"], - ); - expect(legacy).toEqual({ - auto_merge: 1, - force_finalize_mode: "conditional", - at_cap_policy: "ci_retry_once", - }); - - const custom = db.get<{ - auto_merge: number; - force_finalize_mode: string; - at_cap_policy: string | null; - }>( - "select auto_merge, force_finalize_mode, at_cap_policy from pr_pipeline_settings where pr_id = ?", - ["pr-custom"], - ); - expect(custom).toEqual({ - auto_merge: 0, - force_finalize_mode: "off", - at_cap_policy: "stop", - }); - - db.run( - "update pr_pipeline_settings set auto_merge = 0, force_finalize_mode = 'off', at_cap_policy = 'stop' where pr_id = ?", - ["pr-legacy"], - ); - } finally { - db.close(); - } - - const reopened = await openKvDb(dbPath, createLogger() as any); - try { - const legacyAfterUserOverride = reopened.get<{ - auto_merge: number; - force_finalize_mode: string; - at_cap_policy: string | null; - }>( - "select auto_merge, force_finalize_mode, at_cap_policy from pr_pipeline_settings where pr_id = ?", - ["pr-legacy"], - ); - expect(legacyAfterUserOverride).toEqual({ - auto_merge: 0, - force_finalize_mode: "off", - at_cap_policy: "stop", - }); - } finally { - reopened.close(); - } - }); -}); diff --git a/apps/desktop/src/main/services/state/kvDb.ts b/apps/desktop/src/main/services/state/kvDb.ts index 836495695..b18c2631c 100644 --- a/apps/desktop/src/main/services/state/kvDb.ts +++ b/apps/desktop/src/main/services/state/kvDb.ts @@ -956,17 +956,6 @@ type MigrationDb = { get: = Record>(sql: string, params?: SqlValue[]) => T | null; }; -function wrapRawDb(db: DatabaseSyncType): MigrationDb { - return { - run: (sql: string, params: SqlValue[] = []) => { - runStatement(db, sql, params); - }, - get: = Record>(sql: string, params: SqlValue[] = []) => { - return getRow(db, sql, params); - }, - }; -} - function parseAlterTableTarget(sql: string): string | null { const match = sql.match(/^\s*alter\s+table\s+([`"'[\]A-Za-z0-9_]+)\s+add\s+column\s+/i); if (!match?.[1]) return null; diff --git a/apps/desktop/src/main/services/state/kvDb.workerAgentsMigration.test.ts b/apps/desktop/src/main/services/state/kvDb.workerAgentsMigration.test.ts deleted file mode 100644 index 36a76987a..000000000 --- a/apps/desktop/src/main/services/state/kvDb.workerAgentsMigration.test.ts +++ /dev/null @@ -1,154 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { describe, expect, it } from "vitest"; -import { openKvDb } from "./kvDb"; - -function createLogger() { - return { - debug: () => {}, - info: () => {}, - warn: () => {}, - error: () => {} - } as any; -} - -function listColumnNames(db: Awaited>, table: string): string[] { - const rows = db.all<{ name: string }>(`pragma table_info(${table})`); - return rows.map((row) => String(row.name ?? "")).filter(Boolean); -} - -describe("kvDb worker agent schema migration", () => { - it("creates W2 worker tables and indexes", async () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-kvdb-workers-")); - const dbPath = path.join(root, "ade.db"); - const db = await openKvDb(dbPath, createLogger()); - - const expectedTables = [ - "worker_agents", - "worker_agent_revisions", - "worker_agent_cost_events", - "worker_agent_task_sessions", - "worker_agent_runs", - ]; - - for (const table of expectedTables) { - const hit = db.get<{ name: string }>( - "select name from sqlite_master where type = 'table' and name = ? limit 1", - [table] - ); - expect(hit?.name).toBe(table); - } - - expect(listColumnNames(db, "worker_agents")).toEqual( - expect.arrayContaining([ - "id", - "project_id", - "slug", - "name", - "role", - "reports_to", - "capabilities_json", - "status", - "adapter_type", - "adapter_config_json", - "runtime_config_json", - "budget_monthly_cents", - "spent_monthly_cents", - "last_heartbeat_at", - "created_at", - "updated_at", - "deleted_at", - ]) - ); - - expect(listColumnNames(db, "worker_agent_revisions")).toEqual( - expect.arrayContaining([ - "id", - "project_id", - "agent_id", - "before_json", - "after_json", - "changed_keys_json", - "had_redactions", - "actor", - "created_at", - ]) - ); - - expect(listColumnNames(db, "worker_agent_cost_events")).toEqual( - expect.arrayContaining([ - "id", - "project_id", - "agent_id", - "run_id", - "session_id", - "provider", - "model_id", - "input_tokens", - "output_tokens", - "cost_cents", - "estimated", - "source", - "occurred_at", - "created_at", - ]) - ); - - expect(listColumnNames(db, "worker_agent_task_sessions")).toEqual( - expect.arrayContaining([ - "id", - "project_id", - "agent_id", - "adapter_type", - "task_key", - "payload_json", - "cleared_at", - "created_at", - "updated_at", - ]) - ); - - expect(listColumnNames(db, "worker_agent_runs")).toEqual( - expect.arrayContaining([ - "id", - "project_id", - "agent_id", - "status", - "wakeup_reason", - "task_key", - "issue_key", - "execution_run_id", - "execution_locked_at", - "context_json", - "result_json", - "error_message", - "started_at", - "finished_at", - "created_at", - "updated_at", - ]) - ); - - const expectedIndexes = [ - "idx_worker_agents_project", - "idx_worker_agents_project_active", - "idx_worker_agent_revisions_agent", - "idx_worker_agent_task_sessions_lookup", - "idx_worker_agent_runs_agent", - "idx_worker_agent_runs_status", - "idx_worker_agent_cost_events_agent", - "idx_worker_agent_cost_events_month", - ]; - - for (const indexName of expectedIndexes) { - const hit = db.get<{ name: string }>( - "select name from sqlite_master where type = 'index' and name = ? limit 1", - [indexName] - ); - expect(hit?.name).toBe(indexName); - } - - db.close(); - }); -}); diff --git a/apps/desktop/src/main/services/usage/usageTrackingService.ts b/apps/desktop/src/main/services/usage/usageTrackingService.ts index e20e3c3e8..ea47e9113 100644 --- a/apps/desktop/src/main/services/usage/usageTrackingService.ts +++ b/apps/desktop/src/main/services/usage/usageTrackingService.ts @@ -40,7 +40,6 @@ const DEFAULT_POLL_INTERVAL_MS = 2 * 60_000; // 2 min const MIN_POLL_INTERVAL_MS = 60_000; // 1 min const MAX_POLL_INTERVAL_MS = 15 * 60_000; // 15 min const COST_CACHE_TTL_MS = 60_000; // 60s -const CODEX_TOKEN_REFRESH_DAYS = 8; const CODEX_CLI_RPC_TIMEOUT_MS = 10_000; const LOCAL_COST_SCAN_MAX_FILES = 200; const LOCAL_COST_SCAN_MAX_FILE_BYTES = 8 * 1024 * 1024; diff --git a/apps/desktop/src/renderer/components/app/AppShell.tsx b/apps/desktop/src/renderer/components/app/AppShell.tsx index 99e5e72e0..1b295d43f 100644 --- a/apps/desktop/src/renderer/components/app/AppShell.tsx +++ b/apps/desktop/src/renderer/components/app/AppShell.tsx @@ -22,7 +22,6 @@ import { import { TabBackground } from "../ui/TabBackground"; import { LaneAccentDot } from "../lanes/LaneAccentDot"; import { useAppStore } from "../../state/appStore"; -import { useOnboardingStore } from "../../state/onboardingStore"; import { Button } from "../ui/Button"; import type { AiSettingsStatus, @@ -1085,7 +1084,6 @@ export function AppShell({ children }: { children: React.ReactNode }) { location.pathname === "/project" && onboardingStatusLoading; const hideSidebar = isOnboardingRoute || shouldHoldProjectRouteForOnboarding; - const tourActive = useOnboardingStore((s) => s.activeTourId != null); const staleCliNoticeAgeHours = staleCliNotice ? getStaleRunningCliSessionAgeHours({ status: "running", diff --git a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx index dc731605e..8c2eaddc3 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx @@ -721,6 +721,8 @@ export function AgentChatComposer({ sdkSlashCommands = [], modelId, availableModelIds, + constrainModelSelection = false, + modelUnavailableMessage, providerAuthStatus, onRuntimeCatalogRefreshed, reasoningEffort, @@ -745,13 +747,10 @@ export function AgentChatComposer({ opencodePermissionMode, droidPermissionMode, cursorModeSnapshot, - executionMode, - computerUseSnapshot, iosElementContextItems = [], appControlContextItems = [], builtInBrowserContextItems = [], macosVmContextItems = [], - executionModeOptions = [], modelSelectionLocked = false, permissionModeLocked = false, hideNativeControls = false, @@ -773,7 +772,6 @@ export function AgentChatComposer({ onAddContextAttachment, onRemoveContextAttachment, onSearchAttachments, - onExecutionModeChange, onInteractionModeChange, onClaudeModeChange, onClaudePermissionModeChange, @@ -791,7 +789,6 @@ export function AgentChatComposer({ onRemoveMacosVmContext, onClearEvents, promptSuggestion, - chatHasMessages = false, pendingSteers = [], onCancelSteer, onEditSteer, @@ -843,6 +840,8 @@ export function AgentChatComposer({ sdkSlashCommands?: AgentChatSlashCommand[]; modelId: string; availableModelIds?: string[]; + constrainModelSelection?: boolean; + modelUnavailableMessage?: string; providerAuthStatus?: Partial>; onRuntimeCatalogRefreshed?: (provider: AgentChatModelCatalogRefreshProvider) => void; reasoningEffort: string | null; @@ -991,8 +990,6 @@ export function AgentChatComposer({ const [selectedBuiltInBrowserContextId, setSelectedBuiltInBrowserContextId] = useState(null); const [selectedMacosVmContextId, setSelectedMacosVmContextId] = useState(null); - const [hoveredClaudeMode, setHoveredClaudeMode] = useState(null); - const [hoveredCodexPreset, setHoveredCodexPreset] = useState | null>(null); const [claudeModePickerOpen, setClaudeModePickerOpen] = useState(false); const claudeModePickerRef = useRef(null); const [codexPresetPickerOpen, setCodexPresetPickerOpen] = useState(false); @@ -1068,15 +1065,16 @@ export function AgentChatComposer({ textareaRef.current?.focus({ preventScroll: true }); }, [resizeTextarea, shouldAutofocus, useRichComposer]); useEffect(() => { + const objectPreviewUrls = objectPreviewUrlsRef.current; return () => { if (clipboardImagePasteFallbackTimerRef.current != null) { window.clearTimeout(clipboardImagePasteFallbackTimerRef.current); clipboardImagePasteFallbackTimerRef.current = null; } if (typeof URL !== "undefined" && typeof URL.revokeObjectURL === "function") { - for (const url of objectPreviewUrlsRef.current) URL.revokeObjectURL(url); + for (const url of objectPreviewUrls) URL.revokeObjectURL(url); } - objectPreviewUrlsRef.current.clear(); + objectPreviewUrls.clear(); }; }, []); useEffect(() => { @@ -1849,12 +1847,10 @@ export function AgentChatComposer({ const target = event.target as Element | null; if (target?.closest?.("[data-codex-preset-picker-dropdown]")) return; setCodexPresetPickerOpen(false); - setHoveredCodexPreset(null); }; const handleKey = (event: KeyboardEvent) => { if (event.key === "Escape") { setCodexPresetPickerOpen(false); - setHoveredCodexPreset(null); } }; window.addEventListener("mousedown", handleClick); @@ -1873,12 +1869,10 @@ export function AgentChatComposer({ const target = event.target as Element | null; if (target?.closest?.("[data-claude-mode-picker-dropdown]")) return; setClaudeModePickerOpen(false); - setHoveredClaudeMode(null); }; const handleKey = (event: KeyboardEvent) => { if (event.key === "Escape") { setClaudeModePickerOpen(false); - setHoveredClaudeMode(null); } }; window.addEventListener("mousedown", handleClick); @@ -2032,12 +2026,7 @@ export function AgentChatComposer({ onClick={() => { applyClaudeMode(option.value); setClaudeModePickerOpen(false); - setHoveredClaudeMode(null); }} - onMouseEnter={() => setHoveredClaudeMode(option.value)} - onMouseLeave={() => setHoveredClaudeMode(null)} - onFocus={() => setHoveredClaudeMode(option.value)} - onBlur={() => setHoveredClaudeMode(null)} className={cn( "flex w-full items-center gap-2 px-3 py-1.5 text-left font-sans text-[length:calc(var(--chat-font-size)*11/14)] transition-colors", active ? cn(tone.activeBg, tone.activeText) : "text-fg/72", @@ -2136,12 +2125,7 @@ export function AgentChatComposer({ onClick={() => { applyCodexPreset(option.value as Exclude); setCodexPresetPickerOpen(false); - setHoveredCodexPreset(null); }} - onMouseEnter={() => setHoveredCodexPreset(option.value as Exclude)} - onMouseLeave={() => setHoveredCodexPreset(null)} - onFocus={() => setHoveredCodexPreset(option.value as Exclude)} - onBlur={() => setHoveredCodexPreset(null)} className={cn( "flex w-full items-center gap-2 px-3 py-1.5 text-left font-sans text-[length:calc(var(--chat-font-size)*11/14)] transition-colors", active ? `${colors.activeBg} text-fg/88` : "text-fg/72 hover:bg-white/[0.04]", @@ -2360,15 +2344,12 @@ export function AgentChatComposer({ ); }, [ claudeSelectionMode, - claudePermissionMode, claudeModePickerOpen, codexPresetPickerOpen, applyCodexPreset, codexPreset, codexPresetOptions, codexCustomSummary, - hoveredClaudeMode, - hoveredCodexPreset, nativeControlsDisabled, hideNativeControls, onClaudeModeChange, @@ -2670,11 +2651,11 @@ export function AgentChatComposer({ return; } if (busy || !modelId || (!draft.trim().length && !hasContextSelection && contextAttachmentCount === 0)) { - if (!busy && !modelId) onSubmitBlocked?.("Select a model first"); + if (!busy && !modelId) onSubmitBlocked?.(modelUnavailableMessage ?? "Select a model first"); return; } onSubmit(); - }, [appControlContextItems.length, attachments, builtInBrowserContextItems.length, busy, contextAttachmentCount, contextAttachments, cursorCloudAvailable, cursorCloudCanLaunch, cursorCloudLaunchModeOpen, draft, iosElementContextItems.length, macosVmContextItems.length, modelId, onDraftChange, onSubmit, onSubmitBlocked, onSubmitToCloud, pendingImageAttachments.length, pendingInput, parallelChatMode, parallelLaunchBusy, parallelModelSlots.length]); + }, [appControlContextItems.length, attachments, builtInBrowserContextItems.length, busy, contextAttachmentCount, contextAttachments, cursorCloudAvailable, cursorCloudCanLaunch, cursorCloudLaunchModeOpen, draft, iosElementContextItems.length, macosVmContextItems.length, modelId, modelUnavailableMessage, onDraftChange, onSubmit, onSubmitBlocked, onSubmitToCloud, pendingImageAttachments.length, pendingInput, parallelChatMode, parallelLaunchBusy, parallelModelSlots.length]); const pendingQuestionCount = getPendingInputQuestionCount(pendingInput); const showPendingInputOptionsHint = hasPendingInputOptions(pendingInput); @@ -2759,7 +2740,7 @@ export function AgentChatComposer({ if (draft.trim().length === 0 && attachments.length === 0 && contextAttachmentCount === 0) return "Add a message or at least one attachment"; return "Send to all lanes"; } - if (!modelId) return "Select a model first"; + if (!modelId) return modelUnavailableMessage ?? "Select a model first"; if (!draft.trim().length && allowAttachmentOnlySubmit && attachments.length > 0) return "Send attached files"; if (!draft.trim().length && contextAttachmentCount > 0) return "Send attached issue context"; if (!draft.trim().length && hasAppControlContext) return "Send selected App Control context"; @@ -3417,6 +3398,7 @@ export function AgentChatComposer({ onChange={(next) => onParallelSlotModelChange?.(parallelConfiguringIndex, next)} surfaceKey={`chat-composer-parallel-${parallelConfiguringIndex}`} {...(availableModelIds ? { availableModelIds } : {})} + constrainToAvailableModelIds={constrainModelSelection} {...(providerAuthStatus ? { providerAuthStatus } : {})} {...(onOpenAiSettings ? { onOpenSignIn: onOpenAiSettings } : {})} {...(onRuntimeCatalogRefreshed ? { onRuntimeCatalogRefreshed } : {})} @@ -3442,6 +3424,7 @@ export function AgentChatComposer({ onChange={onModelChange} surfaceKey="chat-composer" {...(availableModelIds ? { availableModelIds } : {})} + constrainToAvailableModelIds={constrainModelSelection} {...(providerAuthStatus ? { providerAuthStatus } : {})} {...(onOpenAiSettings ? { onOpenSignIn: onOpenAiSettings } : {})} {...(onRuntimeCatalogRefreshed ? { onRuntimeCatalogRefreshed } : {})} diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.submit.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.submit.test.tsx index ffba158ba..443229d15 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.submit.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.submit.test.tsx @@ -7,6 +7,7 @@ import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom"; import type { AgentChatEventEnvelope, AgentChatEventHistorySnapshot, + AgentChatModelCatalog, AgentChatParallelLaunchState, AgentChatSession, AgentChatSessionSummary, @@ -17,6 +18,10 @@ import type { import { getModelById } from "../../../shared/modelRegistry"; import { invalidateAiDiscoveryCache } from "../../lib/aiDiscoveryCache"; import { useAppStore } from "../../state/appStore"; +import { + rememberRuntimeCatalog, + resetModelPickerRuntimeCatalogForTests, +} from "../shared/ModelPicker/runtimeCatalogCache"; import { AgentChatPane, isMatchingOptimisticUserMessage, @@ -159,6 +164,62 @@ function buildStatusStartedTranscript(sessionId: string): string { })}\n`; } +function seedRuntimeModelCatalog(): void { + rememberRuntimeCatalog({ + fetchedAt: "2026-05-22T00:00:00.000Z", + groups: [ + { + key: "codex", + displayName: "Codex", + providers: [{ + key: "codex", + displayName: "Codex", + badgeColor: "#60A5FA", + modelCount: 1, + subsections: [{ + key: "default", + label: "Codex", + models: [{ + id: "openai/gpt-5.4", + runtimeModelId: "gpt-5.4", + provider: "codex", + providerKey: "codex", + groupKey: "codex", + displayName: "GPT-5.4", + isDefault: false, + isAvailable: true, + }], + }], + }], + }, + { + key: "claude", + displayName: "Claude", + providers: [{ + key: "claude", + displayName: "Claude", + badgeColor: "#D97706", + modelCount: 1, + subsections: [{ + key: "default", + label: "Claude", + models: [{ + id: "anthropic/claude-sonnet-4-6", + runtimeModelId: "claude-sonnet-4-6", + provider: "claude", + providerKey: "claude", + groupKey: "claude", + displayName: "Claude Sonnet 4.6", + isDefault: true, + isAvailable: true, + }], + }], + }], + }, + ], + } as AgentChatModelCatalog, { mode: "cached" }); +} + function buildPendingInputTranscript(sessionId: string): string { return `${JSON.stringify({ sessionId, @@ -417,6 +478,7 @@ let iosEventListener: ((event: { type: string; chatSessionId?: string; laneId?: beforeEach(() => { invalidateAiDiscoveryCache(); + resetModelPickerRuntimeCatalogForTests(); window.localStorage.clear(); window.sessionStorage.clear(); iosEventListener = null; @@ -430,6 +492,7 @@ beforeEach(() => { afterEach(() => { cleanup(); invalidateAiDiscoveryCache(); + resetModelPickerRuntimeCatalogForTests(); Object.defineProperty(window.navigator, "platform", { configurable: true, value: originalNavigatorPlatform, @@ -763,6 +826,98 @@ describe("AgentChatPane companion drawers", () => { }); describe("AgentChatPane submit recovery", () => { + it("uses the model override as the constrained draft picker list", async () => { + installAdeMocks({ sessions: [] }); + seedRuntimeModelCatalog(); + + renderParallelDraftPane({ + availableModelIdsOverride: ["anthropic/claude-sonnet-4-6"], + }); + + expect(await screen.findByText("Start a new conversation")).toBeTruthy(); + const includedModelLabel = getModelById("anthropic/claude-sonnet-4-6")?.displayName ?? "Claude Sonnet 4.6"; + const trigger = await screen.findByRole("button", { name: /^Select model/ }); + fireEvent.pointerDown(trigger, { button: 0 }); + fireEvent.click(trigger); + + fireEvent.click(await screen.findByRole("tab", { name: /^Anthropic$/i })); + const includedModel = await screen.findByRole("option", { name: new RegExp(escapeRegExp(includedModelLabel), "i") }); + expect(includedModel.getAttribute("aria-disabled")).not.toBe("true"); + + fireEvent.click(await screen.findByRole("tab", { name: /^OpenAI$/i })); + const excludedModelLabel = getModelById("openai/gpt-5.4")?.displayName ?? "GPT-5.4"; + expect(screen.queryByRole("option", { name: new RegExp(escapeRegExp(excludedModelLabel), "i") })).toBeNull(); + }); + + it("blocks draft submit when the constrained model list no longer contains the selected model", async () => { + const { create, send } = installAdeMocks({ sessions: [] }); + const launchConfigKey = [ + "ade.chat.lastLaunchConfig.v1", + "/tmp/project-under-test", + "lane-1", + "standard", + "chat", + ].map(encodeURIComponent).join(":"); + window.localStorage.setItem(launchConfigKey, JSON.stringify({ + version: 1, + modelId: "openai/gpt-5.4", + updatedAt: "2026-05-20T12:00:00.000Z", + controls: { + interactionMode: "default", + claudePermissionMode: "default", + codexApprovalPolicy: "on-request", + codexSandbox: "workspace-write", + codexConfigSource: "flags", + opencodePermissionMode: "edit", + droidPermissionMode: "auto-low", + cursorModeId: "agent", + cursorConfigValues: {}, + }, + })); + + renderParallelDraftPane({ + availableModelIdsOverride: [], + }); + + const modelLabel = getModelById("openai/gpt-5.4")?.displayName ?? "GPT-5.4"; + expect(await screen.findByRole("button", { name: new RegExp(`current: ${escapeRegExp(modelLabel)}`, "i") })).toBeTruthy(); + const textbox = await screen.findByRole("textbox"); + fireEvent.change(textbox, { target: { value: "This must not launch with a stale model." } }); + fireEvent.click(await screen.findByRole("button", { name: "Send" })); + + expect(await screen.findByText("No models are available for this chat surface.")).toBeTruthy(); + expect(create).not.toHaveBeenCalled(); + expect(send).not.toHaveBeenCalled(); + }); + + it("blocks active session submit when a constrained list excludes the current model", async () => { + const session = buildSession("stale-model-session", { + title: "Stale model chat", + model: "gpt-5.4", + modelId: "openai/gpt-5.4", + }); + const { send } = installAdeMocks({ sessions: [session] }); + seedDrawerStore(); + + render( + + + , + ); + + const textbox = await screen.findByRole("textbox"); + fireEvent.change(textbox, { target: { value: "This should not send with a stale model." } }); + fireEvent.click(await screen.findByRole("button", { name: "Send" })); + + expect(await screen.findByText("Select an available model for this chat surface before sending.")).toBeTruthy(); + expect(send).not.toHaveBeenCalled(); + }); + it("hydrates a draft chat from the last launched config before first send", async () => { const { create } = installAdeMocks({ sessions: [] }); const launchConfigKey = [ diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx index fff295770..1b79f2c62 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx @@ -1165,6 +1165,8 @@ const CODEX_CONFIG_SOURCES: readonly AgentChatCodexConfigSource[] = ["flags", "c const OPENCODE_PERMISSION_MODES: readonly AgentChatOpenCodePermissionMode[] = ["plan", "edit", "full-auto"]; const DROID_PERMISSION_MODES: readonly AgentChatDroidPermissionMode[] = ["read-only", "auto-low", "auto-medium", "auto-high"]; const EXECUTION_MODES: readonly AgentChatExecutionMode[] = ["focused", "parallel", "subagents", "teams"]; +const EMPTY_CHAT_EVENTS: AgentChatEventEnvelope[] = []; +const EMPTY_REASONING_TIERS: string[] = []; function isRecord(value: unknown): value is Record { return value != null && typeof value === "object" && !Array.isArray(value); @@ -2255,7 +2257,7 @@ export function AgentChatPane({ return lane?.worktreePath ?? projectRoot; }, [laneId, lanes, projectRoot]); - const selectedEvents = selectedSessionId ? eventsBySession[selectedSessionId] ?? [] : []; + const selectedEvents = selectedSessionId ? eventsBySession[selectedSessionId] ?? EMPTY_CHAT_EVENTS : EMPTY_CHAT_EVENTS; const optimisticOutgoingMessageRef = useRef(null); const selectedEventsForDisplay = useMemo(() => { const shouldRenderOptimistic = @@ -2681,7 +2683,7 @@ export function AgentChatPane({ }, [pendingInputsBySession, selectedSessionId]); const pendingSteers = selectedSessionId ? (pendingSteersBySession[selectedSessionId] ?? []) : []; const selectedModelDesc = resolveModelDescriptorWithRuntimeCatalog(modelId) ?? getModelById(modelId); - const reasoningTiers = selectedModelDesc?.reasoningTiers ?? []; + const reasoningTiers = selectedModelDesc?.reasoningTiers ?? EMPTY_REASONING_TIERS; const localRuntimeState = useMemo(() => { const provider = selectedModelDesc?.authTypes.includes("local") ? (selectedModelDesc.family as LocalProviderFamily) @@ -3011,15 +3013,20 @@ export function AgentChatPane({ const chipsJson = JSON.stringify(presentation?.chips ?? []); const resolvedChips = useMemo(() => JSON.parse(chipsJson) as ChatSurfaceChip[], [chipsJson]); - // Keep all configured models selectable, and always include the active session model. - // All models are available regardless of surface — the runtime handles provider transitions. + // Keep configured models selectable unless a caller explicitly constrains + // this surface. Unconstrained sessions keep their active model visible even + // when it has fallen out of the discovered catalog. + const modelSelectionConstrained = availableModelIdsOverride != null; const effectiveAvailableModelIds = useMemo(() => { + const sourceAvailableModelIds = availableModelIdsOverride ?? availableModelIds; const base = filterChatModelIdsForSession({ - availableModelIds, + availableModelIds: sourceAvailableModelIds, activeSessionModelId: selectedSessionModelId, hasConversation: selectedEvents.length > 0, + includeActiveSessionModel: !modelSelectionConstrained, policy: modelSwitchPolicy, }); + if (modelSelectionConstrained) return base; const catalog = getSharedRuntimeCatalog(); if (!catalog) return base; const runtimeIds = descriptorsFromAgentChatModelCatalog(catalog).availableModelIds; @@ -3027,7 +3034,7 @@ export function AgentChatPane({ const merged = new Set(base); for (const id of runtimeIds) merged.add(id); return [...merged]; - }, [availableModelIds, modelSwitchPolicy, selectedSessionModelId, selectedEvents.length, runtimeCatalogVersion]); + }, [availableModelIds, availableModelIdsOverride, modelSelectionConstrained, modelSwitchPolicy, selectedSessionModelId, selectedEvents.length, runtimeCatalogVersion]); const modelPickerProviderAuthStatus = useMemo( () => (aiStatus ? familiesFromStatus(aiStatus) : undefined), [aiStatus], @@ -3036,6 +3043,16 @@ export function AgentChatPane({ () => effectiveAvailableModelIds.filter((id) => id.startsWith("cursor/")), [effectiveAvailableModelIds], ); + const constrainedModelSelectionError = useMemo(() => { + if (!modelSelectionConstrained) return null; + if (!effectiveAvailableModelIds.length) { + return "No models are available for this chat surface."; + } + if (modelId && !effectiveAvailableModelIds.includes(modelId)) { + return "Select an available model for this chat surface before sending."; + } + return null; + }, [effectiveAvailableModelIds, modelId, modelSelectionConstrained]); const cursorCloudAvailable = Boolean(laneId) && (selectedSession?.provider === "cursor" || (typeof modelId === "string" && modelId.startsWith("cursor/"))); // Launch-to-cloud is only allowed for a fresh chat: no events yet AND not already promoted to a @@ -3779,23 +3796,25 @@ export function AgentChatPane({ }, [initialSessionSummary, lockSessionId, refreshAvailableModels, refreshSessions]); useEffect(() => { - if (loading || !availableModelIds.length) return; + const selectableModelIds = modelSelectionConstrained ? effectiveAvailableModelIds : availableModelIds; + if (loading || !selectableModelIds.length) return; // If the user hasn't picked a model yet, don't auto-select one. if (!modelId) return; - if (availableModelIds.includes(modelId)) return; + if (selectableModelIds.includes(modelId)) return; + if (modelSelectionConstrained) return; // Runtime catalog can surface Cursor/Droid SDK models before ai status catches up. if (isKnownSelectableChatModelId(modelId) || resolveModelDescriptorWithRuntimeCatalog(modelId)) return; - if (selectedSessionModelId) { + if (selectedSessionModelId && selectableModelIds.includes(selectedSessionModelId)) { setModelId(selectedSessionModelId); return; } const preferred = readLastUsedModelId(); - if (preferred && availableModelIds.includes(preferred)) { + if (preferred && selectableModelIds.includes(preferred)) { setModelId(preferred); } else { - setModelId(availableModelIds[0]!); + setModelId(selectableModelIds[0]!); } - }, [loading, availableModelIds, modelId, selectedSessionModelId]); + }, [loading, availableModelIds, effectiveAvailableModelIds, modelId, modelSelectionConstrained, selectedSessionModelId]); useEffect(() => { if (!reasoningTiers.length) { @@ -4726,6 +4745,9 @@ export function AgentChatPane({ targetLaneId: string, options: { select?: boolean; notify?: boolean; notifyOptions?: AgentChatSessionCreatedOptions } = {}, ): Promise => { + if (constrainedModelSelectionError) { + throw new Error(constrainedModelSelectionError); + } const desc = resolveModelDescriptorWithRuntimeCatalog(modelId) ?? getModelById(modelId); const permissionDesc = getModelDescriptorForPermissionMode(modelId); const provider = resolveChatRuntimeProvider(desc); @@ -4806,13 +4828,17 @@ export function AgentChatPane({ if (options.notify) notifySessionCreated(created, options.notifyOptions); if (targetLaneId === laneId) void refreshSessions().catch(() => {}); return created; - }, [buildNativeControlPayload, codexFastMode, currentNativeControls, executionMode, initialNativeControls, iosSimulatorOpen, laneId, modelId, notifySessionCreated, reasoningEffort, refreshSessions, touchSession]); + }, [buildNativeControlPayload, codexFastMode, constrainedModelSelectionError, currentNativeControls, executionMode, initialNativeControls, iosSimulatorOpen, laneId, modelId, notifySessionCreated, reasoningEffort, refreshSessions, touchSession]); const createSession = useCallback(async (): Promise => { if (createSessionPromiseRef.current) { return createSessionPromiseRef.current; } if (!laneId) return null; + if (constrainedModelSelectionError) { + setError(constrainedModelSelectionError); + return null; + } const createPromise = createSessionForLane(laneId, { select: true, notify: true }) .then((created) => created.id); createSessionPromiseRef.current = createPromise; @@ -4823,7 +4849,7 @@ export function AgentChatPane({ createSessionPromiseRef.current = null; } } - }, [createSessionForLane, laneId]); + }, [constrainedModelSelectionError, createSessionForLane, laneId]); const buildDraftLaunchSnapshotForCurrentState = useCallback((): DraftLaunchSnapshot | null => { const text = draft.trim(); @@ -4977,6 +5003,10 @@ export function AgentChatPane({ return; } if (selectedSessionId || workDraftKind !== "chat") return; + if (constrainedModelSelectionError) { + setError(constrainedModelSelectionError); + return; + } if (!modelId) { setError("Select a model first"); return; @@ -5072,6 +5102,7 @@ export function AgentChatPane({ backgroundLaunchBusy, buildDraftLaunchSnapshotForCurrentState, busy, + constrainedModelSelectionError, createSessionForLane, draftLaunchTargetIsAutoCreate, executionMode, @@ -5265,6 +5296,17 @@ export function AgentChatPane({ setError("All parallel lanes must have a model selected."); return; } + if (modelSelectionConstrained) { + if (!effectiveAvailableModelIds.length) { + setError("No models are available for this chat surface."); + return; + } + const unavailableSlot = parallelModelSlots.find((slot) => !effectiveAvailableModelIds.includes(slot.modelId)); + if (unavailableSlot) { + setError("Each parallel lane must use an available model for this chat surface."); + return; + } + } const modelKeys = parallelModelSlots.map((s) => s.modelId); if (new Set(modelKeys).size !== modelKeys.length) { setError("Each parallel lane needs a different model."); @@ -5460,6 +5502,10 @@ export function AgentChatPane({ } if (draftLaunchTargetIsAutoCreate && selectedSessionId == null && workDraftKind === "chat") { + if (constrainedModelSelectionError) { + setError(constrainedModelSelectionError); + return; + } if (!modelId) { setError("Select a model first"); return; @@ -5476,6 +5522,10 @@ export function AgentChatPane({ && !lockSessionId && !draftLaunchTargetIsAutoCreate ) { + if (constrainedModelSelectionError) { + setError(constrainedModelSelectionError); + return; + } if (!modelId) { setError("Select a model first"); return; @@ -5484,6 +5534,10 @@ export function AgentChatPane({ return; } + if (constrainedModelSelectionError) { + setError(constrainedModelSelectionError); + return; + } if (!modelId) { setError("Select a model first"); return; @@ -5835,17 +5889,20 @@ export function AgentChatPane({ buildNativeControlPayload, busy, codexFastMode, + constrainedModelSelectionError, createSession, currentNativeControls, contextAttachments, draft, draftLaunchTargetIsAutoCreate, + effectiveAvailableModelIds, executionMode, hasComputerUseSelectionChanged, interactionMode, laneId, launchDraftChat, launchModeEditable, + modelSelectionConstrained, modelId, patchSessionSummary, projectTransitionBlocksChat, @@ -6999,6 +7056,8 @@ export function AgentChatPane({ sdkSlashCommands={sdkSlashCommands} modelId={modelId} availableModelIds={effectiveAvailableModelIds} + constrainModelSelection={modelSelectionConstrained} + modelUnavailableMessage={constrainedModelSelectionError ?? undefined} providerAuthStatus={modelPickerProviderAuthStatus} onRuntimeCatalogRefreshed={() => { setRuntimeCatalogVersion((version) => version + 1); @@ -7064,11 +7123,15 @@ export function AgentChatPane({ onOpenLinearSettings={openLinearSettings} onModelChange={(nextModelId) => { const modelAllowed = - !effectiveAvailableModelIds.length - || effectiveAvailableModelIds.includes(nextModelId) - || isKnownSelectableChatModelId(nextModelId) - || Boolean(resolveModelDescriptorWithRuntimeCatalog(nextModelId)); - if (selectedSessionModelId && !modelAllowed) { + modelSelectionConstrained + ? effectiveAvailableModelIds.includes(nextModelId) + : ( + !effectiveAvailableModelIds.length + || effectiveAvailableModelIds.includes(nextModelId) + || isKnownSelectableChatModelId(nextModelId) + || Boolean(resolveModelDescriptorWithRuntimeCatalog(nextModelId)) + ); + if (!modelAllowed) { return; } if (isPersistentIdentitySurface && sessionMutationKind) { @@ -7316,6 +7379,7 @@ export function AgentChatPane({ }); }} onParallelSlotModelChange={(index, nextModelId) => { + if (modelSelectionConstrained && !effectiveAvailableModelIds.includes(nextModelId)) return; const desc = resolveModelDescriptorWithRuntimeCatalog(nextModelId) ?? getModelById(nextModelId); const tiers = desc?.reasoningTiers ?? []; const preferred = readLastUsedReasoningEffort({ laneId, modelId: nextModelId }); diff --git a/apps/desktop/src/renderer/components/cto/CtoPage.tsx b/apps/desktop/src/renderer/components/cto/CtoPage.tsx index 17e3cb30f..ef0444be6 100644 --- a/apps/desktop/src/renderer/components/cto/CtoPage.tsx +++ b/apps/desktop/src/renderer/components/cto/CtoPage.tsx @@ -61,12 +61,6 @@ function splitTrimmed(value: string): string[] { .filter(Boolean); } -function summarizeText(value: string | null | undefined, fallback: string): string { - const normalized = value?.trim(); - if (!normalized) return fallback; - return normalized.length > 180 ? `${normalized.slice(0, 177).trimEnd()}...` : normalized; -} - function statusDotCls(status: AgentStatus): string { switch (status) { case "running": return "bg-info animate-pulse"; diff --git a/apps/desktop/src/renderer/components/cto/LinearSyncPanel.tsx b/apps/desktop/src/renderer/components/cto/LinearSyncPanel.tsx index b4bf67ca4..ba95fe895 100644 --- a/apps/desktop/src/renderer/components/cto/LinearSyncPanel.tsx +++ b/apps/desktop/src/renderer/components/cto/LinearSyncPanel.tsx @@ -5,7 +5,6 @@ import type { CtoFlowPolicyRevision, LaneSummary, LinearConnectionStatus, - LinearIngressEventRecord, LinearIngressStatus, LinearSyncResolutionAction, LinearWorkflowMatchCandidate, @@ -259,7 +258,6 @@ export function LinearSyncPanel({ lanes, selectedLaneId }: { lanes: LaneSummary[ const [revisions, setRevisions] = useState([]); const [agents, setAgents] = useState([]); const [ingressStatus, setIngressStatus] = useState(null); - const [ingressEvents, setIngressEvents] = useState([]); const [loading, setLoading] = useState(false); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); @@ -302,7 +300,7 @@ export function LinearSyncPanel({ lanes, selectedLaneId }: { lanes: LaneSummary[ const loadRuntimeState = useCallback(async () => { if (!window.ade?.cto) return; - const [dash, q, nextIngressStatus, nextIngressEvents] = await Promise.all([ + const [dash, q, nextIngressStatus] = await Promise.all([ window.ade.cto.getLinearSyncDashboard(), window.ade.cto.listLinearSyncQueue(), window.ade.cto.getLinearIngressStatus().catch( @@ -312,12 +310,10 @@ export function LinearSyncPanel({ lanes, selectedLaneId }: { lanes: LaneSummary[ reconciliation: { enabled: true, intervalSec: 30, lastRunAt: null }, }) ), - window.ade.cto.listLinearIngressEvents({ limit: 12 }).catch(async (): Promise => []), ]); setDashboard(dash); setQueue(q); setIngressStatus(nextIngressStatus); - setIngressEvents(nextIngressEvents); }, []); const loadRunDetail = useCallback(async (runId: string | null) => { @@ -493,7 +489,6 @@ export function LinearSyncPanel({ lanes, selectedLaneId }: { lanes: LaneSummary[ try { const ensured = await window.ade.cto.ensureLinearWebhook({ force: true }); setIngressStatus(ensured); - setIngressEvents(await window.ade.cto.listLinearIngressEvents({ limit: 12 })); setStatusNote("Linear webhook ingress is configured and listening for real-time events."); } catch (err) { setError(err instanceof Error ? err.message : "Unable to ensure the Linear webhook."); diff --git a/apps/desktop/src/renderer/components/cto/TeamPanel.tsx b/apps/desktop/src/renderer/components/cto/TeamPanel.tsx index 25f55d76d..750023186 100644 --- a/apps/desktop/src/renderer/components/cto/TeamPanel.tsx +++ b/apps/desktop/src/renderer/components/cto/TeamPanel.tsx @@ -1,6 +1,5 @@ import React, { useState } from "react"; import { - PencilSimple, Trash, ArrowCounterClockwise, Lightning, @@ -12,12 +11,10 @@ import type { AdapterType, AgentConfigRevision, AgentSessionLogEntry, - HeartbeatPolicy, WorkerAgentRun, } from "../../../shared/types"; import { Button } from "../ui/Button"; import { PaneHeader } from "../ui/PaneHeader"; -import { cn } from "../ui/cn"; import { AgentStatusBadge } from "./shared/AgentStatusBadge"; import { WorkerActivityFeed } from "./WorkerActivityFeed"; diff --git a/apps/desktop/src/renderer/components/cto/pipeline/config/CloseoutConfig.tsx b/apps/desktop/src/renderer/components/cto/pipeline/config/CloseoutConfig.tsx index 324a26849..301185cec 100644 --- a/apps/desktop/src/renderer/components/cto/pipeline/config/CloseoutConfig.tsx +++ b/apps/desktop/src/renderer/components/cto/pipeline/config/CloseoutConfig.tsx @@ -3,7 +3,6 @@ import type { LinearWorkflowCloseoutPolicy } from "../../../../../shared/types/l import { selectCls, labelCls, inputCls, textareaCls } from "../../shared/designTokens"; import { ISSUE_STATE_LABELS, - ARTIFACT_MODE_LABELS, REVIEW_READY_WHEN_LABELS, fieldLabel, fieldDescription, diff --git a/apps/desktop/src/renderer/components/history/eventTaxonomy.ts b/apps/desktop/src/renderer/components/history/eventTaxonomy.ts index dd2bd2377..1e0ce2aa9 100644 --- a/apps/desktop/src/renderer/components/history/eventTaxonomy.ts +++ b/apps/desktop/src/renderer/components/history/eventTaxonomy.ts @@ -1,5 +1,3 @@ -import type { ComponentType } from "react"; - // ── Event Categories ───────────────────────────────────────────── export type EventCategory = | "git" diff --git a/apps/desktop/src/renderer/components/history/useTimelineLayout.ts b/apps/desktop/src/renderer/components/history/useTimelineLayout.ts index c68632d13..d545ecc91 100644 --- a/apps/desktop/src/renderer/components/history/useTimelineLayout.ts +++ b/apps/desktop/src/renderer/components/history/useTimelineLayout.ts @@ -8,7 +8,6 @@ import type { TimelineEvent, WIPNode, } from "./timelineTypes"; -import type { NodeShape } from "./eventTaxonomy"; import { getLaneTrackColor } from "./eventTaxonomy"; // ── Constants ──────────────────────────────────────────────────── diff --git a/apps/desktop/src/renderer/components/lanes/LaneGitActionsPane.tsx b/apps/desktop/src/renderer/components/lanes/LaneGitActionsPane.tsx index 7dfb23ee5..c3ecbec39 100644 --- a/apps/desktop/src/renderer/components/lanes/LaneGitActionsPane.tsx +++ b/apps/desktop/src/renderer/components/lanes/LaneGitActionsPane.tsx @@ -376,16 +376,6 @@ function getPullModeSummary(mode: GitSyncMode): string { : "Rebase replays your local commits on top of the remote branch for a cleaner history."; } -function getPushSummary(syncStatus: GitUpstreamSyncStatus | null): string { - if (syncStatus?.upstreamState === "missing") { - return "The configured remote branch is missing. Push only if you need to recreate it."; - } - if (syncStatus?.hasUpstream === false) { - return "Publish lane creates the remote branch and connects this lane to it."; - } - return "Push sends your local commits to the tracked remote branch."; -} - function getAmendSummary(amendCommit: boolean): string { return amendCommit ? "Amend is on. Your next commit will replace the latest commit instead of creating a new one." @@ -672,8 +662,6 @@ export function LaneGitActionsPane({ const hiddenUnstagedChangeCount = Math.max(0, changes.unstaged.length - visibleUnstagedChanges.length); const responsiveMode = getResponsiveMode(paneWidth); const maxVisibleStashes = responsiveMode === "wide" ? 2 : 3; - const actionGridColumns = - responsiveMode === "wide" ? "repeat(3, minmax(0, 1fr))" : responsiveMode === "medium" ? "repeat(2, minmax(0, 1fr))" : "1fr"; currentLaneIdRef.current = laneId; const isViewingLane = useCallback((targetLaneId: string | null) => currentLaneIdRef.current === targetLaneId, []); @@ -1397,12 +1385,9 @@ export function LaneGitActionsPane({ const mergeConflictState = conflictState?.inProgress && conflictState.kind === "merge" ? conflictState : null; const pullBlockedByConflict = Boolean(conflictState?.inProgress); const headerDotColor = getLaneHeaderDotColor(lane); - const pushButtonTitle = upstreamMissing ? "Recreate remote branch" : syncStatus?.hasUpstream === false ? "Publish lane" : "Push to remote"; const rebaseConflictParentLaneId = autoRebaseStatus?.parentLaneId ?? lane?.parentLaneId ?? null; - const isGeneratingCommitMessage = busyAction === AUTO_GENERATE_COMMIT_ACTION; const commitButtonLabel = getCommitButtonLabel({ busyAction, amendCommit }); const commitHelperText = getCommitHelperText({ commitMessage, commitMessageAi }); - const primaryPushLabel = upstreamMissing ? "Recreate remote" : syncStatus?.hasUpstream === false ? "Publish lane" : "Push to remote"; const syncButtonDisabled = !laneId || busyAction != null || lane?.status.behind === 0 || lane?.status.dirty; const syncButtonTitle = useMemo(() => { if (!laneId) return "Sync is unavailable until you select a child lane."; diff --git a/apps/desktop/src/renderer/components/lanes/LanesPage.tsx b/apps/desktop/src/renderer/components/lanes/LanesPage.tsx index 21a60f076..15ddd288d 100644 --- a/apps/desktop/src/renderer/components/lanes/LanesPage.tsx +++ b/apps/desktop/src/renderer/components/lanes/LanesPage.tsx @@ -1282,10 +1282,6 @@ export function LanesPage({ active = true }: { active?: boolean } = {}) { /* ---- Lane management actions ---- */ - const currentLaneBranch = useMemo( - () => laneBranches.find((branch) => branch.isCurrent)?.name ?? branchLane?.branchRef ?? "", - [laneBranches, branchLane?.branchRef] - ); const localLaneBranches = useMemo(() => { const q = branchSearchQuery.toLowerCase(); return laneBranches.filter((branch) => !branch.isRemote && (!q || branch.name.toLowerCase().includes(q))); diff --git a/apps/desktop/src/renderer/components/missions/missionThreadEventAdapter.ts b/apps/desktop/src/renderer/components/missions/missionThreadEventAdapter.ts index 1dc76ca96..39868de22 100644 --- a/apps/desktop/src/renderer/components/missions/missionThreadEventAdapter.ts +++ b/apps/desktop/src/renderer/components/missions/missionThreadEventAdapter.ts @@ -55,17 +55,6 @@ function readDelegationContract( return record as Extract["contract"]; } -function normalizeApprovalKind(value: string | null): "command" | "file_change" | "tool_call" { - switch (value) { - case "command": - case "file_change": - case "tool_call": - return value; - default: - return "tool_call"; - } -} - function normalizeCommandStatus(value: string | null): "running" | "completed" | "failed" { switch (value) { case "completed": @@ -76,16 +65,6 @@ function normalizeCommandStatus(value: string | null): "running" | "completed" | } } -function normalizeFileChangeKind(value: string | null): "create" | "modify" | "delete" { - switch (value) { - case "create": - case "delete": - return value; - default: - return "modify"; - } -} - function normalizePlanStepStatus(value: unknown): "pending" | "in_progress" | "completed" | "failed" { const status = typeof value === "string" ? value : ""; switch (status) { @@ -123,8 +102,6 @@ function normalizeDeliveryState(value: unknown): "queued" | "delivered" | "faile } } -type UserAttachment = NonNullable["attachments"]>[number]; - function resolveSessionId(message: OrchestratorChatMessage, structuredStream: Record | null): string { return ( readString(structuredStream?.sessionId) diff --git a/apps/desktop/src/renderer/components/settings/AiFeaturesSection.test.tsx b/apps/desktop/src/renderer/components/settings/AiFeaturesSection.test.tsx new file mode 100644 index 000000000..df2da854b --- /dev/null +++ b/apps/desktop/src/renderer/components/settings/AiFeaturesSection.test.tsx @@ -0,0 +1,131 @@ +/* @vitest-environment jsdom */ + +import { afterEach, describe, expect, it, vi } from "vitest"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { MemoryRouter, useLocation } from "react-router-dom"; +import { AiFeaturesSection } from "./AiFeaturesSection"; + +const modelPickerProps = vi.hoisted(() => + [] as Array<{ surfaceKey: string; onOpenSignIn?: () => void }>, +); + +vi.mock("../shared/ModelPicker/ModelPicker", () => { + return { + ModelPicker: (props: { surfaceKey: string; onOpenSignIn?: () => void }) => { + modelPickerProps.push(props); + return ( + + ); + }, + }; +}); + +vi.mock("../shared/ModelPicker/ReasoningEffortPicker", () => ({ + ReasoningEffortPicker: () => null, +})); + +function LocationProbe() { + const location = useLocation(); + return
{`${location.pathname}${location.search}${location.hash}`}
; +} + +function installAdeMocks() { + (window as any).ade = { + ai: { + getStatus: vi.fn().mockResolvedValue({ + mode: "subscription", + availableProviders: { + claude: { + binary: { present: false, source: "missing", path: null }, + auth: { ready: false, mode: "none", detail: null }, + }, + codex: true, + cursor: false, + droid: false, + }, + models: { claude: [], codex: [], cursor: [], droid: [] }, + features: [ + { feature: "terminal_summaries", enabled: true, dailyUsage: 0 }, + { feature: "pr_descriptions", enabled: true, dailyUsage: 0 }, + { feature: "commit_messages", enabled: true, dailyUsage: 0 }, + ], + detectedAuth: [{ type: "cli-subscription", cli: "codex", authenticated: true }], + availableModelIds: ["openai/gpt-5.4"], + }), + updateConfig: vi.fn().mockResolvedValue(undefined), + }, + projectConfig: { + get: vi.fn().mockResolvedValue({ + effective: { + ai: { + featureModelOverrides: { + terminal_summaries: "openai/gpt-5.4", + pr_descriptions: "openai/gpt-5.4", + commit_messages: "openai/gpt-5.4", + }, + sessionIntelligence: { + titles: { + enabled: true, + modelId: "openai/gpt-5.4", + refreshOnComplete: true, + }, + }, + }, + }, + }), + }, + }; +} + +afterEach(() => { + cleanup(); + modelPickerProps.length = 0; + delete (window as any).ade; +}); + +describe("AiFeaturesSection", () => { + it("passes provider setup actions to every AI feature model picker", async () => { + installAdeMocks(); + + render( + + + , + ); + const expectedSurfaceKeys = [ + "ai-feature-terminal_summaries", + "ai-feature-pr_descriptions", + "ai-feature-commit_messages", + "ai-feature-chat-auto-title", + ]; + await waitFor(() => { + for (const surfaceKey of expectedSurfaceKeys) { + expect( + modelPickerProps.find((props) => props.surfaceKey === surfaceKey) + ?.onOpenSignIn, + `${surfaceKey} should route setup to AI providers`, + ).toEqual(expect.any(Function)); + } + }); + }); + + it("routes the chat auto-title provider setup action to the AI providers settings section", async () => { + installAdeMocks(); + + render( + + + + , + ); + + await screen.findByRole("button", { name: "Set up ai-feature-chat-auto-title" }); + fireEvent.click(screen.getByRole("button", { name: "Set up ai-feature-chat-auto-title" })); + + await waitFor(() => { + expect(screen.getByTestId("location").textContent).toBe("/settings?tab=ai#ai-providers"); + }); + }); +}); diff --git a/apps/desktop/src/renderer/components/settings/AiFeaturesSection.tsx b/apps/desktop/src/renderer/components/settings/AiFeaturesSection.tsx index 0fd3d6c2b..f667bbf3f 100644 --- a/apps/desktop/src/renderer/components/settings/AiFeaturesSection.tsx +++ b/apps/desktop/src/renderer/components/settings/AiFeaturesSection.tsx @@ -421,6 +421,7 @@ export function AiFeaturesSection() { onChange={(modelId) => void handleModelChange(feature.key, modelId)} surfaceKey={`ai-feature-${feature.key}`} availableModelIds={availableModelIds} + onOpenSignIn={openAiProvidersSettings} disabled={!enabled} /> @@ -296,7 +293,6 @@ export function ChatAppearancePreview({ accentColor={claudeAccent} shellGeometry={shellGeometry} chromeTint={chromeTint} - rootStyle={rootStyle} > @@ -305,7 +301,6 @@ export function ChatAppearancePreview({ accentColor={opencodeAccent} shellGeometry={shellGeometry} chromeTint={chromeTint} - rootStyle={rootStyle} > diff --git a/apps/desktop/src/renderer/components/settings/LaneTemplatesSection.tsx b/apps/desktop/src/renderer/components/settings/LaneTemplatesSection.tsx index 89de780f1..9460e0d76 100644 --- a/apps/desktop/src/renderer/components/settings/LaneTemplatesSection.tsx +++ b/apps/desktop/src/renderer/components/settings/LaneTemplatesSection.tsx @@ -6,7 +6,6 @@ import { LABEL_STYLE, outlineButton, primaryButton, - dangerButton, cardStyle, recessedStyle, } from "../lanes/laneDesignTokens"; diff --git a/apps/desktop/src/renderer/components/settings/ProvidersSection.tsx b/apps/desktop/src/renderer/components/settings/ProvidersSection.tsx index 89ae08912..89da86d8c 100644 --- a/apps/desktop/src/renderer/components/settings/ProvidersSection.tsx +++ b/apps/desktop/src/renderer/components/settings/ProvidersSection.tsx @@ -120,19 +120,6 @@ const groupLabelStyle: React.CSSProperties = { color: COLORS.textSecondary, }; -const sectionLabelStyle: React.CSSProperties = { - ...LABEL_STYLE, - fontSize: 11, - marginBottom: 10, -}; - -function CliLogo({ cli }: { cli: CliName }) { - if (cli === "claude") return ; - if (cli === "cursor") return ; - if (cli === "droid") return ; - return ; -} - const SOURCE_BADGE_MAP: Record = { store: { color: COLORS.success, label: "Local Store" }, env: { color: COLORS.info, label: "Environment" }, diff --git a/apps/desktop/src/renderer/components/settings/ProxyAndPreviewSection.tsx b/apps/desktop/src/renderer/components/settings/ProxyAndPreviewSection.tsx index 57cf34255..e21b9faf0 100644 --- a/apps/desktop/src/renderer/components/settings/ProxyAndPreviewSection.tsx +++ b/apps/desktop/src/renderer/components/settings/ProxyAndPreviewSection.tsx @@ -314,16 +314,6 @@ export function ProxyAndPreviewSection() { // Initial data fetch // ----------------------------------------------------------------------- - const fetchProxyStatus = useCallback(async () => { - try { - const status = await window.ade.lanes.proxyGetStatus(); - setProxyStatus(status); - setProxyError(status.error ?? null); - } catch (err) { - setProxyError(err instanceof Error ? err.message : String(err)); - } - }, []); - const fetchOAuthStatus = useCallback(async () => { try { const status = await window.ade.lanes.oauthGetStatus(); @@ -335,23 +325,10 @@ export function ProxyAndPreviewSection() { } }, [syncAdvancedDrafts]); - const fetchOAuthSessions = useCallback(async () => { - try { - const nextSessions = await window.ade.lanes.oauthListSessions(); - setSessions( - [...nextSessions].sort((a, b) => b.createdAt.localeCompare(a.createdAt)), - ); - } catch (err) { - setOAuthError(err instanceof Error ? err.message : String(err)); - } - }, []); - useEffect(() => { let cancelled = false; // Fetch initial data with cancellation awareness. - // We inline the fetch logic here rather than calling fetchProxyStatus() etc. - // because those functions always call setState and don't check cancellation. void Promise.all([ window.ade.lanes.proxyGetStatus().then((status) => { if (cancelled) return; diff --git a/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPicker.tsx b/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPicker.tsx index fea02d294..e1b86a19a 100644 --- a/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPicker.tsx +++ b/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPicker.tsx @@ -40,6 +40,7 @@ export type ModelPickerProps = { providerAuthStatus?: Partial>; onOpenSignIn?: () => void; onRuntimeCatalogRefreshed?: (provider: AgentChatModelCatalogRefreshProvider) => void; + constrainToAvailableModelIds?: boolean; fastModeActive?: boolean; onFastModeToggle?: (next: boolean) => void; fastModeSupported?: boolean; @@ -60,6 +61,7 @@ export const ModelPicker = memo(function ModelPicker({ providerAuthStatus, onOpenSignIn, onRuntimeCatalogRefreshed, + constrainToAvailableModelIds = false, fastModeActive = false, onFastModeToggle, fastModeSupported, @@ -161,13 +163,26 @@ export const ModelPicker = memo(function ModelPicker({ const modelList = useMemo(() => { if (models && models.length) return models; - const fallbackModels = mergeSelectorModels(availableModelIds, value, filter, catalogMode); + const selectedValue = (() => { + if (!constrainToAvailableModelIds) return value; + const normalizedValue = value.trim(); + if (!normalizedValue) return ""; + const available = new Set((availableModelIds ?? []).map((id) => id.trim()).filter(Boolean)); + return available.has(normalizedValue) ? normalizedValue : ""; + })(); + const fallbackModels = mergeSelectorModels( + availableModelIds, + selectedValue, + filter, + constrainToAvailableModelIds ? "available-only" : catalogMode, + ); if (catalogModels.models.length === 0) return fallbackModels; + if (constrainToAvailableModelIds) return fallbackModels; const merged = new Map(); for (const model of fallbackModels) merged.set(model.id, model); for (const model of catalogModels.models) merged.set(model.id, model); return [...merged.values()]; - }, [models, availableModelIds, value, filter, catalogMode, catalogModels.models]); + }, [models, availableModelIds, value, filter, catalogMode, catalogModels.models, constrainToAvailableModelIds]); const effectiveValue = useMemo(() => { if (value && value.length > 0) return value; @@ -186,10 +201,12 @@ export const ModelPicker = memo(function ModelPicker({ }, [value]); const availableSet = useMemo(() => { - const ids = runtimeCatalog ? catalogModels.availableModelIds : availableModelIds; + const ids = constrainToAvailableModelIds || !runtimeCatalog + ? availableModelIds + : catalogModels.availableModelIds; if (!ids) return null; return new Set(ids.map((id) => id.trim()).filter(Boolean)); - }, [availableModelIds, catalogModels.availableModelIds, runtimeCatalog]); + }, [availableModelIds, catalogModels.availableModelIds, constrainToAvailableModelIds, runtimeCatalog]); const isAvailable = useCallback( (modelId: string): boolean => { @@ -271,6 +288,7 @@ export const ModelPicker = memo(function ModelPicker({ onRequestClose={handleRequestClose} onProviderRailSelect={handleProviderRailSelect} refreshingProvider={refreshingProvider} + allowRegistryExpansion={!constrainToAvailableModelIds} {...(onOpenSignIn ? { onOpenSignIn: handleOpenSignIn } : {})} /> ) : null} diff --git a/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPickerContent.tsx b/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPickerContent.tsx index add6023d1..ec845af0a 100644 --- a/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPickerContent.tsx +++ b/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPickerContent.tsx @@ -106,6 +106,7 @@ export type ModelPickerContentProps = { onProviderRailSelect?: (family: ProviderFamily) => void; refreshingProvider?: AgentChatModelCatalogRefreshProvider | null; onOpenSignIn?: () => void; + allowRegistryExpansion?: boolean; }; export const ModelPickerContent = memo(function ModelPickerContent({ @@ -119,6 +120,7 @@ export const ModelPickerContent = memo(function ModelPickerContent({ onProviderRailSelect, refreshingProvider, onOpenSignIn, + allowRegistryExpansion = true, }: ModelPickerContentProps) { const [query, setQuery] = useState(""); const searchRef = useRef(null); @@ -163,7 +165,7 @@ export const ModelPickerContent = memo(function ModelPickerContent({ ); const expandedModels = useMemo(() => { - if (authOnly) return models; + if (authOnly || !allowRegistryExpansion) return models; const merged = new Map(); for (const m of models) merged.set(m.id, m); for (const m of MODEL_REGISTRY) { @@ -171,7 +173,7 @@ export const ModelPickerContent = memo(function ModelPickerContent({ if (!merged.has(m.id)) merged.set(m.id, m); } return [...merged.values()]; - }, [authOnly, models]); + }, [allowRegistryExpansion, authOnly, models]); const providersPresent = useMemo(() => { const set = new Set(); diff --git a/apps/desktop/src/renderer/onboarding/tours/ctoTour.ts b/apps/desktop/src/renderer/onboarding/tours/ctoTour.ts index de6e5a33f..4706be3f2 100644 --- a/apps/desktop/src/renderer/onboarding/tours/ctoTour.ts +++ b/apps/desktop/src/renderer/onboarding/tours/ctoTour.ts @@ -1,6 +1,15 @@ -import { registerTour, type Tour } from "../registry"; +import { registerTour, type StepAction, type Tour } from "../registry"; import { docs } from "../docsLinks"; +function switchCtoTab(tab: "team" | "workflows" | "settings"): StepAction[] { + return [{ + type: "ipc", + call: async () => { + window.dispatchEvent(new CustomEvent("ade:tour-cto-tab", { detail: tab })); + }, + }]; +} + export const ctoTour: Tour = { id: "cto", title: "CTO walkthrough", @@ -19,6 +28,7 @@ export const ctoTour: Tour = { body: "Look at what each AI helper is doing, change their role, or set them aside. You can also cap how much they're allowed to spend per month here — useful while you're learning what they're good for.", docUrl: docs.ctoOverview, placement: "left", + beforeEnter: () => switchCtoTab("team"), }, { target: '[data-tour="cto.linearPanel"]', @@ -26,6 +36,7 @@ export const ctoTour: Tour = { body: "Use Linear (a popular project management tool) to track work? Connect it here and the CTO will turn tickets into AI tasks automatically, then post results back to the ticket. Skip if you don't use Linear.", docUrl: docs.ctoOverview, placement: "left", + beforeEnter: () => switchCtoTab("workflows"), }, ], }; diff --git a/apps/desktop/src/renderer/onboarding/tours/firstJourneyTour.test.ts b/apps/desktop/src/renderer/onboarding/tours/firstJourneyTour.test.ts index c7f7742c9..220548c1a 100644 --- a/apps/desktop/src/renderer/onboarding/tours/firstJourneyTour.test.ts +++ b/apps/desktop/src/renderer/onboarding/tours/firstJourneyTour.test.ts @@ -1,5 +1,8 @@ -import { describe, expect, it } from "vitest"; +/* @vitest-environment jsdom */ + +import { describe, expect, it, vi } from "vitest"; import { firstJourneyTour } from "./firstJourneyTour"; +import type { StepAction, TourStep } from "../registry"; const SECTION_PREFIXES = [ "act1.laneWorkPane.", @@ -83,4 +86,44 @@ describe("firstJourneyTour tutorialSection wrapping", () => { expect(welcome!.fallbackNextLabel).toBeUndefined(); expect(welcome!.fallbackNotice).toBeUndefined(); }); + + it("keeps CTO tab switches on wrapped Team and Linear steps", async () => { + const findCtoStep = (target: string): TourStep | undefined => + firstJourneyTour.steps.find((step) => step.target === target); + const teamStep = findCtoStep('[data-tour="cto.teamPanel"]'); + const linearStep = findCtoStep('[data-tour="cto.linearPanel"]'); + + expect(teamStep?.beforeEnter, "Team step should switch to the Team tab").toBeTruthy(); + expect(linearStep?.beforeEnter, "Linear step should switch to the Workflows tab").toBeTruthy(); + + const teamActions = await teamStep!.beforeEnter!(); + const linearActions = await linearStep!.beforeEnter!(); + expect(Array.isArray(teamActions)).toBe(true); + expect(Array.isArray(linearActions)).toBe(true); + const [teamAction] = teamActions as StepAction[]; + const [linearAction] = linearActions as StepAction[]; + expect(teamAction).toMatchObject({ type: "ipc" }); + expect(linearAction).toMatchObject({ type: "ipc" }); + + const ctoTabListener = vi.fn(); + window.addEventListener("ade:tour-cto-tab", ctoTabListener); + try { + if (teamAction?.type !== "ipc" || linearAction?.type !== "ipc") { + throw new Error("CTO tab switch actions must be IPC actions."); + } + await teamAction.call(); + await linearAction.call(); + } finally { + window.removeEventListener("ade:tour-cto-tab", ctoTabListener); + } + + expect(ctoTabListener).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ detail: "team" }), + ); + expect(ctoTabListener).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ detail: "workflows" }), + ); + }); }); diff --git a/apps/desktop/src/renderer/onboarding/tours/firstJourneyTour.ts b/apps/desktop/src/renderer/onboarding/tours/firstJourneyTour.ts index 06aab33ae..5c04df96d 100644 --- a/apps/desktop/src/renderer/onboarding/tours/firstJourneyTour.ts +++ b/apps/desktop/src/renderer/onboarding/tours/firstJourneyTour.ts @@ -496,43 +496,6 @@ const act6Intro: TourStep = { docUrl: docs.welcome, }; -const act6Entries: TourStep = { - id: "act6.entries", - target: '[data-tour="history.entries"]', - title: "What just happened", - body: "The newest events sit at the top — making a lane, saving work, sharing changes. The list grows as you do things. If you haven't done it yet, it won't show up here.", - placement: "right", - requires: PROJECT_OPEN_REQUIRES, - waitForSelector: '[data-tour="history.entries"]', - docUrl: docs.welcome, -}; - -const act6Filter: TourStep = { - id: "act6.filter", - target: '[data-tour="history.filter"]', - title: "Find specific moments", - body: "When the list gets long, filter to just the big stuff — \"lane created\", \"shipped\", \"deleted\" — or just one type of event. Saves scrolling.", - placement: "bottom", - requires: PROJECT_OPEN_REQUIRES, - waitForSelector: '[data-tour="history.filter"]', - ghostCursor: { - from: '[data-tour="history.entries"]', - to: '[data-tour="history.filter"]', - }, - docUrl: docs.welcome, -}; - -const act6ColumnSettings: TourStep = { - id: "act6.columnSettings", - target: '[data-tour="history.export"]', - title: "Show what matters to you", - body: "Choose which details show up next to each event — like timestamps, who did it, or which lane it was in. Hide the noise, keep what's useful.", - placement: "bottom", - requires: PROJECT_OPEN_REQUIRES, - waitForSelector: '[data-tour="history.export"]', - docUrl: docs.welcome, -}; - // --- Act 7: PRs ------------------------------------------------------------- const act7Intro: TourStep = { id: "act7.intro", @@ -639,32 +602,6 @@ const act8Intro: TourStep = { docUrl: docs.projectHome, }; -const act8AddCommand: TourStep = { - id: "act8.addCommand", - target: '[data-tour="run.addCommand"]', - title: "Add a command", - body: "Add a dev server, test watcher, or any script. Give it a name and a command string — nothing saves during this walkthrough.", - placement: "bottom", - requires: PROJECT_OPEN_REQUIRES, - waitForSelector: '[data-tour="run.addCommand"]', - ghostCursor: { - from: '[data-tour="run.header"]', - to: '[data-tour="run.addCommand"]', - }, - docUrl: docs.projectHome, -}; - -const act8ProcessMonitor: TourStep = { - id: "act8.processMonitor", - target: '[data-tour="run.commandCards"]', - title: "Watch what's running", - body: "Each saved command card shows **status**, **lane**, **uptime**, and **when it ended** in the runtime strip at the bottom — plus force-stop while a run is active.", - placement: "top", - requires: PROJECT_OPEN_REQUIRES, - waitForSelector: '[data-tour="run.commandCards"]', - docUrl: docs.projectHome, -}; - // --- Bonus Act 9: Automations ----------------------------------------------- const act9Intro: TourStep = { id: "act9.intro", @@ -677,39 +614,6 @@ const act9Intro: TourStep = { docUrl: docs.automationsOverview, }; -const act9Triggers: TourStep = { - id: "act9.triggers", - target: '[data-tour="automations.createTrigger"]', - title: "Triggers", - body: "Pick what starts the automation — a webhook, a schedule, a git event, or a file watch. One automation can wire up several.", - placement: "right", - requires: PROJECT_OPEN_REQUIRES, - waitForSelector: '[data-tour="automations.createTrigger"]', - docUrl: docs.automationsOverview, -}; - -const act9Actions: TourStep = { - id: "act9.actions", - target: '[data-tour="automations.createTrigger"]', - title: "Actions", - body: "What happens when a trigger fires — run a command, dispatch a mission, ping a worker. Chain them in order.", - placement: "right", - requires: PROJECT_OPEN_REQUIRES, - waitForSelector: '[data-tour="automations.createTrigger"]', - docUrl: docs.automationsOverview, -}; - -const act9Guardrails: TourStep = { - id: "act9.guardrails", - target: '[data-tour="automations.createTrigger"]', - title: "Guardrails", - body: "Rate limits, concurrency caps, quiet hours. Guardrails stop an automation from going rogue — set them before you save.", - placement: "right", - requires: PROJECT_OPEN_REQUIRES, - waitForSelector: '[data-tour="automations.createTrigger"]', - docUrl: docs.automationsOverview, -}; - // --- Bonus Act 10: CTO ------------------------------------------------------ const act10Intro: TourStep = { id: "act10.intro", @@ -722,45 +626,6 @@ const act10Intro: TourStep = { docUrl: docs.ctoOverview, }; -const act10Sidebar: TourStep = { - id: "act10.sidebar", - target: '[data-tour="cto.sidebar"]', - title: "Your agents", - body: "The sidebar lists every agent the CTO manages. Identities persist — each one remembers who they are between sessions.", - placement: "right", - requires: PROJECT_OPEN_REQUIRES, - waitForSelector: '[data-tour="cto.sidebar"]', - docUrl: docs.ctoOverview, -}; - -const act10Team: TourStep = { - id: "act10.team", - target: '[data-tour="cto.teamPanel"]', - title: "Team panel", - body: "Inspect, edit, or archive agents. Budget caps and heartbeat intervals live here too — set them low while you're learning.", - placement: "left", - requires: PROJECT_OPEN_REQUIRES, - waitForSelector: '[data-tour="cto.teamPanel"]', - docUrl: docs.ctoOverview, -}; - -const act10Linear: TourStep = { - id: "act10.linear", - target: '[data-tour="cto.linearPanel"]', - title: "Linear sync", - body: "Hook the CTO up to Linear and it auto-dispatches missions from tickets, posting results back to the issue. Skip if you don't use Linear.", - placement: "left", - requires: PROJECT_OPEN_REQUIRES, - beforeEnter: async () => [{ - type: "ipc", - call: async () => { - window.dispatchEvent(new CustomEvent("ade:tour-cto-tab", { detail: "workflows" })); - }, - }], - waitForSelector: '[data-tour="cto.linearPanel"]', - docUrl: docs.ctoOverview, -}; - // --- Bonus Act 11: Settings -------------------------------------------------- const act11Intro: TourStep = { id: "act11.intro", @@ -773,39 +638,6 @@ const act11Intro: TourStep = { docUrl: docs.settingsGeneral, }; -const act11Appearance: TourStep = { - id: "act11.appearance", - target: '[data-tour="settings.appearance"]', - title: "Appearance", - body: "Theme, density, accent color. Dark mode is the default — light mode and high-contrast live right here.", - placement: "right", - requires: PROJECT_OPEN_REQUIRES, - waitForSelector: '[data-tour="settings.appearance"]', - docUrl: docs.settingsGeneral, -}; - -const act11Ai: TourStep = { - id: "act11.ai", - target: '[data-tour="settings.ai"]', - title: "AI providers", - body: "Plug in Claude, OpenAI, local models, or point at your own endpoint. Workers pick per-session; you set defaults here.", - placement: "right", - requires: PROJECT_OPEN_REQUIRES, - waitForSelector: '[data-tour="settings.ai"]', - docUrl: docs.settingsGeneral, -}; - -const act11Templates: TourStep = { - id: "act11.templates", - target: '[data-tour="settings.laneTemplates"]', - title: "Lane templates", - body: "Pre-baked lane recipes — a fixed stack of runtimes and commands that any new lane can inherit. Save a template, apply it in one click.", - placement: "right", - requires: PROJECT_OPEN_REQUIRES, - waitForSelector: '[data-tour="settings.laneTemplates"]', - docUrl: docs.settingsGeneral, -}; - // --- Act 12: Cleanup -------------------------------------------------------- const act12Nav: TourStep = { id: "act12.nav", diff --git a/apps/desktop/src/shared/chatModelSwitching.test.ts b/apps/desktop/src/shared/chatModelSwitching.test.ts index 01c43f5bf..2132d8af9 100644 --- a/apps/desktop/src/shared/chatModelSwitching.test.ts +++ b/apps/desktop/src/shared/chatModelSwitching.test.ts @@ -39,6 +39,17 @@ describe("chatModelSwitching", () => { ]); }); + it("can leave the active session model out when a caller supplies a strict allowlist", () => { + expect( + filterChatModelIdsForSession({ + availableModelIds: ["anthropic/claude-sonnet-4-6"], + activeSessionModelId: "openai/gpt-5.4", + hasConversation: true, + includeActiveSessionModel: false, + }), + ).toEqual(["anthropic/claude-sonnet-4-6"]); + }); + it("allows same-family switches after launch", () => { expect( canSwitchChatSessionModel({ diff --git a/apps/desktop/src/shared/chatModelSwitching.ts b/apps/desktop/src/shared/chatModelSwitching.ts index 7010d2a2e..e6c5ed3a9 100644 --- a/apps/desktop/src/shared/chatModelSwitching.ts +++ b/apps/desktop/src/shared/chatModelSwitching.ts @@ -4,6 +4,7 @@ type FilterChatModelIdsArgs = { availableModelIds: string[]; activeSessionModelId?: string | null; hasConversation: boolean; + includeActiveSessionModel?: boolean; policy?: ChatModelSwitchPolicy; }; @@ -22,7 +23,7 @@ export function canSwitchChatSessionModel(_args: CanSwitchChatSessionModelArgs): export function filterChatModelIdsForSession(args: FilterChatModelIdsArgs): string[] { const ids = args.availableModelIds.map((entry) => String(entry ?? "").trim()).filter(Boolean); const activeModelId = String(args.activeSessionModelId ?? "").trim(); - if (activeModelId && !ids.includes(activeModelId)) { + if (args.includeActiveSessionModel !== false && activeModelId && !ids.includes(activeModelId)) { return [activeModelId, ...ids]; } return ids; diff --git a/apps/desktop/src/shared/types/orchestrator.ts b/apps/desktop/src/shared/types/orchestrator.ts index d4974b10f..3d0e7f2f6 100644 --- a/apps/desktop/src/shared/types/orchestrator.ts +++ b/apps/desktop/src/shared/types/orchestrator.ts @@ -4,7 +4,6 @@ import type { AgentChatFileRef } from "./chat"; import type { ModelId } from "./core"; -import type { ModelConfig } from "./models"; import type { PrDepth, QueueWaitReason } from "./prs"; import type { MissionDetail, MissionStepHandoff, PhaseCard } from "./missions"; import type { diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 0f74b9133..afe33afa0 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -513,7 +513,7 @@ Most services described here live under `apps/desktop/src/main/services/ | `sync/` | `syncService.ts`, `syncHostService.ts`, `syncPeerService.ts`, `syncRemoteCommandService.ts`, `syncProtocol.ts`, `deviceRegistryService.ts`, `syncPairingStore.ts` | **Thin delegation to the runtime daemon's sync host plus a legacy in-process fallback.** The authoritative sync host now lives in `apps/ade-cli/src/services/sync/`; the desktop main-process instances default to a non-host viewer role for legacy state. The old in-process host is disabled unless `ADE_ENABLE_DESKTOP_SYNC_HOST=1` (diagnostics only). Wire formats — WebSocket envelope, remote command routing, device registry, pairing secrets — are the same across both implementations. | | `notifications/` | `apnsService.ts`, `apnsBridgeService.ts`, `notificationMapper.ts`, `notificationEventBus.ts` | APNs HTTP/2 client (ES256 JWT, key persisted via Electron `safeStorage` on the desktop or `EncryptedFileCredentialStore` under `.ade/secrets/` in the headless daemon), pure domain-event → `MappedNotification` mapping (13 categories / 4 families), event bus routing to APNs alert pushes + Live Activity update pushes + in-app WS delivery, filtered by per-device `NotificationPreferences`. `apnsBridgeService.ts` is the `notifications_apns` ADE action domain (`getStatus`, `saveConfig`, `uploadKey`, `clearKey`, `sendTestPush`) so the same Settings flow works whether the active project is local-bound or SSH-bound. | | `tests/` | `testService.ts` | Test-suite execution + run history. | -| `updates/` | `autoUpdateService.ts` | Electron auto-update wrapper around `electron-updater`. Owns the renderer-visible `AutoUpdateSnapshot` (`idle | checking | downloading | ready | installing | error`), uses `compareUpdateVersions` (SemVer-aware) to dedupe / supersede staged installers and to reconcile `pendingInstallUpdate` against the running version on next boot. `quitAndInstall()` is async: it re-runs `checkForUpdates({ allowReady: true })` to confirm the staged build is still latest, and only then flips to `installing` and calls `updater.quitAndInstall(false, true)`. | +| `updates/` | `autoUpdateService.ts` | Electron auto-update wrapper around `electron-updater`. Owns the renderer-visible `AutoUpdateSnapshot` (`idle \| checking \| downloading \| ready \| installing \| error`), uses `compareUpdateVersions` (SemVer-aware) to dedupe / supersede staged installers and to reconcile `pendingInstallUpdate` against the running version on next boot. `quitAndInstall()` is async: it re-runs `checkForUpdates({ allowReady: true })` to confirm the staged build is still latest, and only then flips to `installing` and calls `updater.quitAndInstall(false, true)`. | | `usage/` | `usageTrackingService.ts`, `budgetCapService.ts` | Token/cost accounting, budget enforcement. Local cost scans stream bounded recent Claude/Codex JSONL files instead of loading the whole history into memory. | | `perf/` | `perfLog.ts`, `perfIpc.ts`, `metricsSampler.ts`, `aggregator.ts` | Opt-in local performance harness. `ADE_PERF_RUN_ID` opens a JSONL event log, samples Electron process metrics, records IPC durations, accepts renderer perf marks/web-vitals, and aggregates each run into `summary.json`. | @@ -914,7 +914,7 @@ ADE/ └── .ade/ # Self-hosted ADE project state (ignored subset) ``` -Root `package.json` is a thin aggregator: `npm test` runs desktop + ade-cli; `npm run test:ci` runs coverage on desktop + ade-cli. +Root `package.json` is a thin aggregator: `npm test` and `npm run test:ci` run the desktop suite in CI-style shards plus the ade-cli suite. `npm run test:coverage` runs desktop coverage plus the ade-cli suite. Per-app scripts: @@ -947,7 +947,7 @@ Sharding is required because the desktop suite is large enough to be slow in a s ### 14.3 Test organization - **Tooling**: Vitest with `node` environment, `pool: "forks"`, `maxForks: 4`, 20s test/hook timeouts. -- **Config**: `apps/desktop/vitest.config.ts` (base), plus project-specific configs for `unit-main`, `unit-renderer`, `unit-shared` when needed. +- **Config**: `apps/desktop/vitest.workspace.ts` defines the `unit-main`, `unit-renderer`, and `unit-shared` projects. The pinned Vitest version does not support CLI `--project`, so `test:unit` is plain `vitest run`, `test:integration` filters `*.integration.test.*`, and `test:component` filters `src/renderer/**/*.test.*`. Root desktop sharding uses `scripts/run-desktop-test-shards.mjs`, which runs `vitest run --shard=N/8` for shards 1-8. - **Test locations**: colocated with source (`*.test.ts` / `*.test.tsx`) under `src/**`. - **Setup**: `apps/desktop/src/test/setup.ts` (browser/DOM mocks via `browserMock.ts`). - **Philosophy**: keep tests that carry real value; aggressively remove brittle UI/render tests; keep mutation + integration coverage solid. diff --git a/docs/features/ade-code/README.md b/docs/features/ade-code/README.md index e01b108b6..581714733 100644 --- a/docs/features/ade-code/README.md +++ b/docs/features/ade-code/README.md @@ -26,7 +26,8 @@ Point Cursor’s browser inspector at the served page for layout debugging. The | `apps/ade-cli/src/tuiClient/app.tsx` | Primary Ink/React surface: navigation, composer, drawers, right pane, session lifecycle, slash command dispatch. Owns the `Ctrl+Y` "copy ADE deeplink" handler which resolves the focused lane / PR row through `buildDeeplinkForRow` and copies the canonical `ade://...` URL to the system clipboard. Also backs `/skills` by listing Agent Skill roots from project, user, inherited, and bundled ADE locations, independent of the active provider. | | `apps/ade-cli/src/tuiClient/deeplinkRow.ts` | Pure helper used by the `Ctrl+Y` keybinding. Maps the focused lane or PR row (including parsing a GitHub PR URL when the right pane only carries the URL) onto a `DeeplinkTarget` and returns the built `ade://` URL. Tested in `tuiClient/__tests__/deeplinkKeybind.test.ts`. | | `apps/ade-cli/src/commands/deeplinks.ts` | `ade open`, `ade link`, and `ade linear install` subcommands. Shares the parser + builder with the desktop main process so URLs round-trip across both surfaces. See [features/deeplinks/README.md](../deeplinks/README.md). | -| `apps/ade-cli/src/tuiClient/connection.ts` | Resolves attached vs embedded mode, runs the `ade/initialize` handshake, registers the project with `projects.add`, wraps subsequent requests with `projectId`. | +| `apps/ade-cli/src/tuiClient/connection.ts` | Resolves attached vs embedded mode, runs the `ade/initialize` handshake, registers the project with `projects.add`, wraps subsequent requests with `projectId`. Computes the daemon's expected SHA-256 build hash from the resolved CLI entrypoint and compares it against the runtime's reported `runtimeInfo.buildHash` / `defaultRole` / `projectRoot`; a mismatch throws `StaleAdeSocketError`, optionally shuts the stale daemon down, and lets `spawnDaemon` start a compatible one (with `ADE_DEFAULT_ROLE=cto` in the spawned env). `initializeEmbeddedCto` injects a trusted `cto` role only when `ADE_DEFAULT_ROLE` is not already set to a valid value. | +| `apps/ade-cli/src/runtimeRoles.ts` | `ADE_RUNTIME_ROLES` (`cto`, `orchestrator`, `agent`, `external`, `evaluator`), `normalizeAdeRuntimeRole`, and `resolveAdeDefaultRole`. Shared by `cli.ts`, `adeRpcServer.ts`, `multiProjectRpcServer.ts`, and `tuiClient/connection.ts` so role parsing stays consistent across surfaces. | | `apps/ade-cli/src/tuiClient/jsonRpcClient.ts` | Socket client: connect, request/response, `chat/event` notifications. | | `apps/ade-cli/src/tuiClient/commands.ts` / `linearCommands.ts` | Slash command catalog and routing. `commands.ts` ships `/lane delete` (right-pane confirmation form that destroys the active lane), `/effort` (reasoning-effort-only picker, a narrower companion to `/model`), and provider-agnostic `/skills` for Agent Skill discovery. `linearCommands.ts` requires a sub-command — bare `/linear` returns the usage hint instead of silently picking `workflows`. | | `apps/ade-cli/src/tuiClient/rightPaneFormatters.ts` | Pure formatters for right-pane result panes (PR summary / review / checks / comments, Linear status, system details). Keeps `app.tsx` free of ad-hoc rendering helpers. | @@ -87,10 +88,37 @@ Both modes run the same handshake before the TUI mounts: clientName: "ade-code", identity: { role: "cto", callerId: "ade-code:" } } -<- { runtimeInfo: { multiProject: true, version, ... }, capabilities: { projects: true, ... } } +<- { + runtimeInfo: { + name: "ade-rpc", + version: "", + buildHash: "", + defaultRole: "cto", + projectRoot: "/path/to/project", + multiProject: true, + pid: 12345 + }, + capabilities: { + projects: true, + actions: { listChanged: false } + } + } -> ade/initialized ``` +`identity.role` remains compatibility metadata; the runtime's trusted +role comes from `ADE_DEFAULT_ROLE` and the rest of the ADE context env. +Direct headless CLI sets that env role from `--role` (defaulting to +`cto`). `ade code` injects `cto` only for an embedded runtime or a +freshly spawned daemon when no valid explicit role exists. Socket +clients then read `runtimeInfo.buildHash`, `runtimeInfo.defaultRole`, +`runtimeInfo.projectRoot`, and `runtimeInfo.pid` to detect stale local +daemons via `attachedRuntimeMismatchReason`; a mismatch raises +`StaleAdeSocketError`, optionally shuts the stale daemon down, and +falls through to `spawnDaemon`. `capabilities.actions.listChanged` is +currently `false`, so the action list is static after initialization +and there is no `ade/actions/list_changed` notification stream. + If the response advertises `runtimeInfo.multiProject === true` or `capabilities.projects === true`, `connection.ts` calls `projects.add { rootPath: }`, captures the returned `projectId`, and from then on every project-scoped request is rewritten to include `projectId`. The runtime-scoped methods (the set in `MULTI_PROJECT_RUNTIME_METHODS`: `ade/initialize`, `projects.*`, `ping`, `runtime/info`, etc.) pass through unchanged. For the embedded runtime there is no `projects.add` step — the in-process runtime is already bound to one project root. diff --git a/docs/features/agents/tool-registration.md b/docs/features/agents/tool-registration.md index c6cb8894a..554414bea 100644 --- a/docs/features/agents/tool-registration.md +++ b/docs/features/agents/tool-registration.md @@ -70,7 +70,6 @@ const rpcSocketServer = net.createServer((conn) => { const rpcHandler = createAdeRpcRequestHandler({ runtime: rpcRuntime, serverVersion: app.getVersion(), - onActionsListChanged: () => stop?.notify("ade/actions/list_changed", {}), }); const stop = startJsonRpcServer(rpcHandler, transport, { nonFatal: true }); // ... cleanup wiring @@ -87,8 +86,9 @@ Key properties: `unlink` the socket in case a prior crash left it. - **Active connection tracking.** Each connection is registered so the service can destroy it cleanly on shutdown. -- **Live action-list updates.** `onActionsListChanged` notifies clients - via `ade/actions/list_changed` when the action surface changes. +- **Static action-list capability.** The action surface is resolved during + initialization and action listing; live action-list notifications are not + advertised until there is a concrete change source to publish. ### Identity propagation @@ -120,10 +120,31 @@ Roles: - `external` -- External callers. Gets only the base action set. - `evaluator` -- Evaluation runs. -The role is locked to what the env context reports; the -`identity.role` field in the payload is honored only when the env -context agrees. This prevents a rogue client from claiming elevated -access by forging `identity.role`. +The trusted server role comes from `ADE_DEFAULT_ROLE` and the other ADE +context environment variables. The `identity.role` field in +`ade/initialize` is compatibility metadata for older clients; it does +not grant access by itself. Direct headless CLI mode sets +`ADE_DEFAULT_ROLE` from `--role`, and socket-backed launchers restart +stale daemons when the daemon's reported `runtimeInfo.defaultRole` +does not match the requested role. + +The initialize response advertises the runtime contract used by clients +to detect stale daemons: + +```json +{ + "runtimeInfo": { + "version": "0.0.0", + "buildHash": "", + "defaultRole": "cto", + "projectRoot": "/path/to/project", + "pid": 12345 + }, + "capabilities": { + "actions": { "listChanged": false } + } +} +``` ## Tool filtering @@ -192,8 +213,8 @@ For a tool call: - `LINEAR_SYNC_TOOL_SPECS` -> Linear tool implementations. 6. Result is returned as structured JSON. 7. If the tool mutates resources visible to other clients, the - server may fire `ade/resources/list_changed` or - `ade/actions/list_changed`. + server may fire `ade/resources/list_changed`. Action-list changes are + currently not advertised as live notifications. ## External CLI detection diff --git a/docs/features/chat/composer-and-ui.md b/docs/features/chat/composer-and-ui.md index b44f6ed87..e26a5a8a3 100644 --- a/docs/features/chat/composer-and-ui.md +++ b/docs/features/chat/composer-and-ui.md @@ -138,7 +138,20 @@ and a footer that contains the composer. "force", refreshProvider? })` and only triggers a runtime probe when the user actually opens the corresponding rail and the per-provider freshness TTL has lapsed (`runtimeCatalogCache.ts`: 30 min for - Cursor / Droid / OpenCode, 30 s for `lmstudio` / `ollama`). + Cursor / Droid / OpenCode, 30 s for `lmstudio` / `ollama`). When a + caller passes `availableModelIdsOverride`, `AgentChatPane` constrains + selection to exactly those ids: `filterChatModelIdsForSession({ + includeActiveSessionModel: false })` skips the usual "preserve the + active model even if it's not in the list" rule, the runtime-catalog + merge is bypassed, and `AgentChatComposer` is rendered with + `constrainModelSelection={true}` so the `ModelPicker` opens with + `constrainToAvailableModelIds`. The picker drops registry expansion + (no "Show all models" suggestion) and the picker rail and the + composer both refuse to commit a value outside the allowlist. A + matching `constrainedModelSelectionError` blocks submit, draft + auto-create, and parallel launches if the current model or any + parallel-slot model fell off the allowlist — main and slot setters + also no-op on out-of-list ids instead of silently bouncing. - **Reasoning effort.** A standalone `ReasoningEffortPicker` (extracted from the model row) is rendered next to the model trigger when the active descriptor exposes `reasoningTiers`. The picker remembers the @@ -478,8 +491,8 @@ surface's visual treatment: | Field | Effect | |---|---| -| `mode` | `standard | resolver | mission-thread | mission-feed`. | -| `profile` | `standard | persistent_identity` -- persistent identity adjusts accent color, chips, title, and some layouts. | +| `mode` | `standard \| resolver \| mission-thread \| mission-feed`. | +| `profile` | `standard \| persistent_identity` -- persistent identity adjusts accent color, chips, title, and some layouts. | | `modelSwitchPolicy` | Overrides the default switch policy for the session. | | `title`, `subtitle`, `assistantLabel`, `messagePlaceholder` | Text overrides. | | `accentColor` | Accent color used in header, chips, and active-turn indicators. | diff --git a/docs/features/cto/README.md b/docs/features/cto/README.md index e74af641f..946a3bf7e 100644 --- a/docs/features/cto/README.md +++ b/docs/features/cto/README.md @@ -98,6 +98,11 @@ Portability rule: identity YAML is git-tracked; generated CTO continuity files a | Workflows | `LinearSyncPanel` (dashboard + run detail + pipeline) | On tab activation; refresh debounced | | Settings | Identity and session logs | On tab activation | +The CTO tour switches tabs through `ade:tour-cto-tab` before the Team +and Workflows steps. The first-run journey wraps those CTO steps but +preserves their `beforeEnter` hooks, so the correct tab is active +before each wrapped anchor is awaited. + The sidebar worker tree is precomputed and memoized. The budget footer is isolated so a budget refresh does not rerender the tree. ## Wiring and IPC diff --git a/docs/features/missions/README.md b/docs/features/missions/README.md index 86b677d23..ca2c37096 100644 --- a/docs/features/missions/README.md +++ b/docs/features/missions/README.md @@ -32,7 +32,7 @@ Caveats that follow from "runtime owns missions": - `orchestrator/executionPolicy.ts` — default `MissionExecutionPolicy`, merge rules (mission > project > fallback), completion evaluation, run/step validation. - `orchestrator/adaptiveRuntime.ts` — `classifyTaskComplexity` (trivial/simple/moderate/complex), parallelism scaling, model downgrade. - `orchestrator/workerDeliveryService.ts` — message delivery pipeline between coordinator and worker chats; retry, idempotency, in-flight leases. -- `orchestrator/workerTracking.ts` — post-attempt artifact extraction and the planning-question intervention path. Only `planner_natural_question` opens a `manual_input` intervention now; the planning-question "required before exit" enforcement has been retired (see [Planning question handling](#planning-question-handling)). +- `orchestrator/workerTracking.ts` — post-attempt artifact extraction and the planning-question intervention path. `planner_natural_question` opens a `manual_input` intervention, and `planningQuestionPolicy.ts` still gates required planning clarification with `planner_required_question_missing` when configured. - `orchestrator/delegationContracts.ts` — contracts between coordinator and workers (scope, allowed tools, handoff shape). - `orchestrator/runtimeEventRouter.ts` — routes events from worker sessions and CLI output into the coordinator. - `orchestrator/metaReasoner.ts` — higher-level reasoning for coordinator choices. @@ -114,24 +114,43 @@ When a mission reaches terminal status (`completed`, `failed`, `cancelled`), `tr ### Planning question handling -A planner can pause the run by emitting an `awaiting_user_input` step with -`source === "planner_natural_question"` and a non-empty `question`. -`workerTracking.extractAndRegisterArtifacts` translates that into a single -`manual_input` intervention (`reasonCode: "planner_natural_question"`) and a -matching `pauseRun` so the coordinator stops until the user answers. There is -no longer a separate `planner_required_question_missing` reason code or a -phase-level "questions required before exit" gate — the legacy -`planningQuestionPolicy.ts` module was removed, and the worker prompt no longer -mentions an ADE-blocking-question prerequisite. If a phase wants the planner to -ask clarifying questions, that intent is conveyed by the planner itself -through the `ask_user` tool (or `awaiting_user_input` with the natural-question -source); the orchestrator never refuses to exit planning just because no -question was asked. +A planner can pause the run by emitting an `awaiting_user_input` step +with `source === "planner_natural_question"` and a non-empty +`question`. `workerTracking.extractAndRegisterArtifacts` translates +that into a single `manual_input` intervention (`reasonCode: +"planner_natural_question"`) and a matching `pauseRun` so the +coordinator stops until the user answers. + +Required planning clarification is still enforced separately by +`planningQuestionPolicy.ts`: if a planning phase exits without a +required answer, ADE records `planner_required_question_missing` and +keeps the run paused. Both `planner_natural_question` and +`planner_required_question_missing` are valid intervention reason codes. ### Mission step bidirectional sync `syncRunStepsFromMission()` pulls user-initiated mutations (cancel, skip) from the mission state back into orchestrator run state. The orchestrator picks the change up on its next tick. +`syncMissionStepsFromRun()` is the other direction. It pairs orchestrator +run steps to mission steps via `runStep.missionStepId`, falling back to +`metadata.orchestratorStepId` / `metadata.stepKey` / matching titles so +steps recorded before the explicit join column was populated still +synchronize. + +After step/phase sync, `syncMissionFromRun()` must re-read mission +detail before deriving a non-terminal mission status. Sync can add or +resolve interventions, so deriving status from a stale mission snapshot +can overwrite `intervention_required` back to `in_progress`. Only an +explicit `nextMissionStatus` bypasses that re-derive. + +`ensureTerminalFailedRunIntervention()` then guarantees a terminal +`failed` run cannot leave the mission in a clean `failed` status without +a matching `failed_step` intervention: if no open `failed_step` row +exists, it adds one (with reason code `terminal_run_failed_step`), +moves the mission to `intervention_required` even though the run itself +is terminal, and `deriveMissionStatusFromRun` checks blocking +interventions before honouring `run.status === "failed"`. + ### Cascade cleanup On terminal state, the runtime calls `cleanupTeamResources()` (best-effort). Worker sessions, temporary worktrees, and team-scoped resources are torn down so they don't leak across runs. @@ -206,7 +225,7 @@ Preflight warnings surface in the launch dialog but do not block launch unless t ## Gotchas and fragile areas - **`missionLifecycle.ts` uses the deps-injection pattern** because the extraction from `aiOrchestratorService.ts` is partial — many functions re-declare type contracts and get their implementations via the deps arg. Don't assume the file contains the full logic; follow the imports back to `aiOrchestratorService`. -- **Dual intervention creation paths** — `orchestratorService.completeAttempt()` and `runtimeEventRouter.routeEventToCoordinator()` both used to create interventions. `VAL-INTV-001` / `VAL-INTV-002` assert that they now dedupe. Any new code path that creates `failed_step` interventions must re-check existing-open-intervention. +- **Dual intervention creation paths** — `orchestratorService.completeAttempt()`, `runtimeEventRouter.routeEventToCoordinator()`, and `ensureTerminalFailedRunIntervention()` (called from `syncMissionFromRun`) all open `failed_step` interventions. `VAL-INTV-001` / `VAL-INTV-002` assert that they dedupe. Any new code path that creates `failed_step` interventions must re-check existing-open-intervention. - **Budget pause consistency** — token budget in `completeAttempt` and hard cap in `spawn_worker` must both flow through `pauseMissionWithIntervention`. See `VAL-BUDGET-001`. - **`tickRun` must skip budget-paused runs** — `VAL-BUDGET-002`. Any refactor that replaces the skip check must preserve the invariant. - **`finalizationPolicyKind` is always `"result_lane"`** for newly created missions. Don't re-introduce PR-strategy UI without coordinating with the closeout contract. diff --git a/docs/features/missions/orchestration.md b/docs/features/missions/orchestration.md index 5604071d1..e17be6e93 100644 --- a/docs/features/missions/orchestration.md +++ b/docs/features/missions/orchestration.md @@ -25,7 +25,7 @@ All in `apps/desktop/src/main/services/orchestrator/`. Files in this directory a - `metaReasoner.ts` — higher-level reasoning helpers for coordinator decisions. - `metricsAndUsage.ts` — token and cost accounting; `estimateTokenCost`. - `recoveryService.ts` — tracked session state, recovery iteration policy (`DEFAULT_RECOVERY_LOOP_POLICY`). -- `workerTracking.ts` — worker session tracking, per-attempt artifact extraction (`extractAndRegisterArtifacts`), planning-phase plan-artifact persistence gate, `planner_plan_missing` intervention auto-resolution on successful re-planning, and the `planner_natural_question` `manual_input` intervention path (with matching `pauseRun`) when the planner emits an `awaiting_user_input` step. The legacy `planner_required_question_missing` reason code and its companion `planningQuestionPolicy.ts` module were removed; phases no longer enforce a "required question before exit" gate. +- `workerTracking.ts` — worker session tracking, per-attempt artifact extraction (`extractAndRegisterArtifacts`), planning-phase plan-artifact persistence gate, `planner_plan_missing` intervention auto-resolution on successful re-planning, and the `planner_natural_question` `manual_input` intervention path (with matching `pauseRun`) when the planner emits an `awaiting_user_input` step. `planningQuestionPolicy.ts` still enforces required planning clarification and can record `planner_required_question_missing`. - `stepPolicyResolver.ts` — `ResolvedOrchestratorRuntimeConfig`, step-level policy merging, autopilot config, file-claim scope (`doFileClaimsOverlap`, `doesFileClaimMatchPath`), repo-relative path normalization. - `baseOrchestratorAdapter.ts` — `buildFullPrompt` (the worker prompt builder), shell escaping, inline decoding. Worker runtime is now `tracked_session | in_process` only; the legacy `managed_chat` branch was retired with the worker-prompt simplification. - `providerOrchestratorAdapter.ts` — provider-specific launchers for Claude CLI, Codex CLI, and managed OpenCode-backed execution. @@ -178,6 +178,26 @@ could clear the intervention when the plan was never actually written, so the persistence check is load-bearing — do not relax it to just checking `report_result.plan`. +After step/phase sync, `syncMissionFromRun()` re-reads mission detail +before deriving any non-terminal mission status. Sync can add or +resolve interventions, so deriving status from the stale pre-sync +mission snapshot can overwrite `intervention_required` back to +`in_progress`. Only an explicit `nextMissionStatus` skips that +re-derive. + +`ensureTerminalFailedRunIntervention()` runs right after +`syncMissionStepsFromRun()` for terminal-`failed` runs. When no open +`failed_step` row exists it adds one (reason +`terminal_run_failed_step`) and flips the mission to +`intervention_required` even though the run is terminal. The change in +`deriveMissionStatusFromRun()` reflects the same priority: blocking +interventions are checked **before** `run.status === "failed"`, so a +terminal run with an open intervention surfaces as +`intervention_required` rather than `failed`. The `else if +(nextMissionStatus === "failed")` branch in `syncMissionFromRun()` +re-derives once more against the latest mission snapshot before +committing the transition, so newly-opened interventions still win. + ## Runtime event routing `runtimeEventRouter.routeEventToCoordinator()` classifies an incoming event (worker output, CLI signal, test result, gate report) and decides whether to: diff --git a/docs/features/onboarding-and-settings/README.md b/docs/features/onboarding-and-settings/README.md index 2865ec2d3..6b65b573b 100644 --- a/docs/features/onboarding-and-settings/README.md +++ b/docs/features/onboarding-and-settings/README.md @@ -143,7 +143,10 @@ Renderer — onboarding: `waitForSelector` from `target`, and — for any step that has a `requires` gate without its own `fallbackAfterMs` — injects a default 30 s `Skip` fallback so the tutorial can never get - permanently stuck waiting on state that doesn't appear. The acts + permanently stuck waiting on state that doesn't appear. Wrapped + steps keep their original hooks; for example `ctoTour` still + dispatches `ade:tour-cto-tab` before the Team and Workflows steps + after `firstJourneyTour` wraps them. The acts themselves are intentionally streamlined: act 1 only borrows the base-branch / status-chip / lane-work-pane bits (since the user has just created a lane interactively); acts 2 + 3 inline ctx-aware @@ -151,8 +154,9 @@ Renderer — onboarding: the per-act "tab handoff" reminder steps were collapsed into the single act 12 finale. - `apps/desktop/src/renderer/components/cto/...` — CTO first-run is a - separate lightweight wizard covering identity, project context, and - optional Linear (see `apps/desktop/src/renderer/components/cto/`). + separate lightweight wizard covering minimal identity/personality + setup only. Model selection, Linear, and worker hiring are deferred + to Settings or the relevant CTO tabs. Renderer — settings: @@ -302,12 +306,10 @@ the General settings tab via `AdeCliSection`: compares the runtime's `buildHash` against the desktop's expected value). A dev build that reports the placeholder version `0.0.0` is accepted when its build hash matches the bundled CLI. Mismatches - are surfaced as a `LocalRuntimeCompatibilityError` and the **existing - daemon is left running** so the user's active work is preserved — - the previous behaviour of force-shutting the daemon and reconnecting - has been retired because it killed live PTYs / chats during a stale - reattach window. The desktop reports the incompatibility back to the - user instead. + are surfaced as a `LocalRuntimeCompatibilityError`; the pool + terminates the stale runtime process when the handshake reported a + pid, unlinks the stale socket, and then lets the normal spawn path + start a compatible daemon. 2. Register the runtime as a per-user login service so it survives reboots. `installServiceBestEffort()` runs `ade serve --install-service` once per session; the implementation lives in diff --git a/docs/features/remote-runtime/internal-architecture.md b/docs/features/remote-runtime/internal-architecture.md index b93a502f1..3601f9c00 100644 --- a/docs/features/remote-runtime/internal-architecture.md +++ b/docs/features/remote-runtime/internal-architecture.md @@ -128,7 +128,7 @@ The renderer's command palette needs to know which action domains a target suppo `LocalRuntimeConnectionPool` handles the desktop side of the local runtime binding: - `connect()` first tries an existing `~/.ade/sock/ade.sock`. If that fails, it spawns `ade serve --socket ` detached (using the bundled CLI from `process.resourcesPath/ade-cli/cli.cjs` or the dev path), waits for the socket, and reconnects. -- `initialize` is called immediately after connect; if `runtimeInfo.version` does not match the desktop app version, the pool shuts the connection down and lets the next call respawn the daemon at the right version. +- `initialize` is called immediately after connect. The pool compares `runtimeInfo.version` and `runtimeInfo.buildHash` with the expected desktop runtime; a mismatch closes that client and lets the next attach/spawn path choose the right daemon. - `installServiceBestEffort()` runs `ade serve --install-service` once per session to register the per-user login service; the result feeds `LocalRuntimeStatus.serviceInstall`. - `getStatus()` periodically refreshes `serviceHealth` (`unsupported | not_installed | installed | running | error | unknown`) by calling `getRuntimeServiceStatus()` from the service manager. - The pool exposes typed entry points for action calls (`callActionForRoot`), sync calls (`callSyncForRoot`), event polling (`streamEventsForRoot`), and event subscription (`subscribeEventsForRoot`). All of them register the project with `projects.add` once and then carry `projectId` on every project-scoped request. diff --git a/goal.md b/goal.md index 583da7aa3..64a0e0748 100644 --- a/goal.md +++ b/goal.md @@ -1,804 +1,436 @@ -# Goal: ADE TUI — Universal Click + Multi-Chat Middle Pane +# Goal: ADE Huge Cleanup Pass 2 - Find What The Last Pass Missed -You are implementing two interlocking TUI features for the ADE CLI. Read this whole brief before touching code. Worktree is `/Users/admin/Projects/ADE/.ade/worktrees/deeplinks-d52aa89e/`. Do not switch lanes. +You are the next agent working in: ---- - -## Why we're doing this - -The ADE TUI today gives you one chat at a time in the middle pane. Power users running agents across multiple lanes constantly want to watch two, three, or six chats stream in parallel — currently they tile multiple TUI instances side by side, which is wasteful and forces them to manage N copies of the same drawer + right pane. - -The other long-standing irritation: mouse support is partial. The drawer, chat text selection, and a handful of right-pane targets accept clicks; most things (model picker, approval prompts, slash/mention palettes, lane-details actions, lane-delete form, prompt-history nav) don't. Add multi-chat without expanding click coverage and the new feature is unusable for mouse-driven users. - -Ship them together: -1. **Universal click** — every keyboard handler gets a matching mouse hit-test, with hover-highlight so users can see what's clickable before committing. -2. **Multi-chat middle pane** — middle splits into 1–6 chat tiles in fixed grids; chats can be from any lane; focus drives prompt routing and right-pane context. +`/Users/admin/Projects/ADE/.ade/worktrees/huge-cleanup-8469674a` ---- +This lane already contains a large cleanup/audit pass. Your job is **not** to admire that pass and rubber-stamp it. Your job is to assume there are still mistakes hiding in the diff, audit it again from first principles, and make the code safer, clearer, and cheaper to change while preserving ADE's behavior and UX. -## TL;DR feature spec - -**Universal click** -- Add hit-testing for every existing keyboard action across drawer, right pane, palettes, prompt area, and overlays. -- Build a `HitTestRegistry` so components register their bounds + handlers declaratively; future components opt in for free. -- Enable terminal mouse mode 1003 (any-event) so we receive `move` events and can highlight whatever's under the cursor. - -**Multi-chat middle pane** -- Middle pane state: `multiView = { tiles: Array<{ sessionId, laneId }>, focusedIndex: number } | null`. -- Up to 6 tiles. Hardcoded layouts: 1=full, 2=2 cols, 3=3 cols, 4=2×2, 5=2-top/3-bot, 6=3×2. -- Chats can come from any lane (cross-lane mixing). State is global to the TUI and **ephemeral** — never persisted across restart. -- Add flow: shortcut while chat pane focused → enter "add-mode" (sidebar focused, non-sidebar regions dimmed, banner overlay) → arrow-nav across all lanes' chats → `Enter` adds, `Esc` cancels. -- Remove flow: shortcut or click `×` in tile header. Dropping below 2 tiles exits multi-view entirely. -- No duplicate chats — adding an already-open chat refocuses the existing tile. -- Focused tile is the source of truth: bottom prompt routes to it; right pane / status / sidebar lane highlight follow it. -- Concurrent streaming: every open tile streams events live whether focused or not. - -**Extras (locked picks)** -- `×` affordance in each tile header for mouse-driven removal. -- Status-bar grid mini-map (e.g. `▣▢ / ▢▢`). -- Per-tile prompt history recall (up/down only cycles the focused tile's prompts). -- Drag a sidebar chat onto the middle pane to add (bypasses add-mode). +Read this whole file before touching code. --- -## Current architecture you must understand first - -The TUI is **Ink v5.2.1** (React for terminal). Entry point: `apps/ade-cli/src/tuiClient/cli.tsx`. Top-level component: `AdeCodeApp` in `apps/ade-cli/src/tuiClient/app.tsx`. That single file is ~8000 lines and owns the entire app's state, focus, mouse parsing, and layout. The rest of `tuiClient/` is split into focused components and helpers. - -### Layout (read `app.tsx` lines ~7626–7800) +## Mission -Root is ``: +Run a second end-to-end cleanup pass over the entire worktree, focused on: -``` -┌────────────────────────────────────────────────────────┐ -│
fixed 1-2 │ -├────────────────────────────────────────────────────────┤ -│ {goal-banner conditional} 0 or 1 │ -├──────────┬──────────────────────────────┬──────────────┤ -│ Drawer │ (middle: ) │ RightPane │ -│ 32 cols │ flexGrow:1 │ 30–42 cols │ -│ (left) │ │ │ -│ │ │ │ -├──────────┴──────────────────────────────┴──────────────┤ -│ prompt input │ -├────────────────────────────────────────────────────────┤ -│ fixed 1-2 │ -└────────────────────────────────────────────────────────┘ -``` +- finding bugs or regressions introduced by the current diff, +- finding missed dead code, stale docs, stale tests, and stale assumptions, +- simplifying risky or needlessly complex code without changing behavior, +- improving tests where the previous pass made behavior more important but did not pin it tightly enough, +- verifying that every changed interface is synced across main process, preload/shared types, renderer, ADE CLI, docs, and tests, +- keeping ADE fast and smooth in practice, not just "more performant" on paper. -Constants in `app.tsx` ~1508–1511: `DRAWER_PANE_WIDTH = 32`, right pane is computed `30–42`, middle gets the remainder (`min 24`). +This pass should feel like a strict source-command audit plus a code quality review. Do not stop after one green test. Re-inventory the diff, re-read suspicious files, use parallel agents for distinct slices, fix what you find, then validate again. -The chat row budget (~line 2514): -```ts -const chatRowBudget = Math.max(4, rows - 8 - (promptRows.length - 1) - statusRows - goalBannerRows); -``` -You'll need to subdivide this budget across grid rows when multi-view is active. +--- -### Focus / pane state (read `app.tsx` ~1733–1776) +## Project rules you must preserve -```ts -type PaneFocus = "chat" | "drawer" | "details"; -const [activePane, setActivePane] = useState("chat"); -const activePaneRef = useRef("chat"); -``` +Read `AGENTS.md` first. The key constraints: -The single `useInput` handler (~line 3400+) branches on `activePane` to dispatch keystrokes. **You will extend `PaneFocus` with `"addMode"`** and add `multiView.focusedIndex` for tile focus (the chat pane itself owns the sub-focus). +- ADE desktop lives in `apps/desktop` and uses Electron, React, and TypeScript. +- ADE CLI lives in `apps/ade-cli`. +- Node.js 22.x is required. +- There are no npm workspaces; each app has its own `node_modules` and lockfile. +- Keep IPC contracts, preload types, shared types, and renderer usage in sync. +- For ADE CLI changes, verify both headless mode and the desktop socket-backed ADE RPC path when relevant. +- Use "lane" for worktrees/branches and "mission" for orchestrated multi-step work. +- Do not reframe ADE as a generic docs site or template app. +- Do not store secrets in plaintext project files. +- Preserve user-facing smoothness. Avoid "performance improvements" that delay visible data, create races, remove useful optimistic state, or make UI feel worse. -### Mouse (read `app.tsx` `parseTerminalMouseInput` + hit-test helpers ~1557–1620) +Respect the existing worktree. Do not revert changes you did not make unless explicitly instructed. -The custom parser handles SGR (`\x1b[<…M/m`), X10, and RXVT escape sequences. It's enabled at startup by writing mode-enable sequences to stdout. Today's modes used: -- `\x1b[?1000h` — basic click tracking -- `\x1b[?1002h` — drag tracking -- `\x1b[?1006h` — SGR extended (for x/y > 223) +--- -**You will add `\x1b[?1003h`** (any-event tracking, i.e. mouse-move without buttons) and disable it cleanly on exit. Reference: . +## Current state of this lane -The current hit-test pattern is a family of per-pane helpers with hardcoded Y offsets, e.g.: +The previous agent already made a large diff and ran a full validation loop. Treat that as useful context, not as proof. -```ts -// app.tsx ~1557 -export function laneDetailsActionIndexForMouseLine(y, actionCount) { - if (y == null || actionCount <= 0) return null; - const firstActionLine = 18; - const index = y - firstActionLine; - return index >= 0 && index < actionCount ? index : null; -} -``` +At the end of the previous pass, the worktree had roughly 76 changed tracked files and 3 untracked files: -This pattern repeats in `formFieldIndexForMouseLine`, `setupPaneRowIndexForMouseLine`, `subagentIndexForPaneLine`. **You will replace these with a unified registry (see Feature 1 below).** Don't delete the old helpers in one go — migrate component by component; once a component is on the registry, delete its old helper. - -### Chat model (read `apps/desktop/src/shared/types/chat.ts` ~725–773) - -A chat is an `AgentChatSession`: -```ts -type AgentChatSession = { - id: string; // session ID (unique per chat) - laneId: string; // lane it belongs to - provider: "claude" | "codex" | "cursor" | "droid" | "opencode"; - model: string; - status: "active" | "idle" | "ended"; - sessionProfile?: …; - permissionMode?: …; - interactionMode?: …; - // … -}; -``` +- `apps/ade-cli/src/runtimeRoles.ts` +- `apps/desktop/src/renderer/components/settings/AiFeaturesSection.test.tsx` +- `scripts/run-desktop-test-shards.mjs` -### Single-source streaming today (read `app.tsx` ~580, ~1190) - -```ts -const [streaming, setStreaming] = useState(false); // GLOBAL flag — must die -… -connection.onChatEvent((envelope) => { - if (envelope.sessionId !== activeSessionIdRef.current) { - refreshState({ hydrateHistory: false }); // silently drop the event UI-side - return; - } - if (event.type === "status" && event.turnStatus === "started") setStreaming(true); - … -}); -``` +Those untracked files are intentional and required. The tracked diff imports or references them. If you prepare a commit or PR, include them. -This is the single biggest blocker for multi-chat. Two changes: -1. `streaming: boolean` → `streamingBySessionId: Record` (or a `Map`). -2. The early-return filter widens from `=== activeSessionIdRef.current` to `openSessionIds.has(envelope.sessionId)`, where `openSessionIds` is `multiView ? new Set(tiles.map(t=>t.sessionId)) : new Set([activeSessionIdRef.current])`. +Major areas already touched: -### ChatView (read `apps/ade-cli/src/tuiClient/components/ChatView.tsx`) +- ADE CLI runtime role normalization and stale daemon detection. +- Runtime `buildHash`, `defaultRole`, `projectRoot`, and `pid` reporting. +- Dev launcher freshness checks in `scripts/dev-*.mjs` and `scripts/tui-web.mjs`. +- Root and desktop test scripts, including `scripts/run-desktop-test-shards.mjs`. +- Constrained model selection in `AgentChatPane`, `AgentChatComposer`, and shared `ModelPicker`. +- Settings AI feature setup routing and tests. +- CTO/first-journey onboarding tour tab switching and tests. +- Mission status sync after mission-step sync, with a regression test for failed-step interventions. +- App control screenshot timer cleanup. +- macOS VM test cleanup. +- A broad unused import/dead-code cleanup across desktop main, renderer, and shared types. +- Docs across architecture, ADE code, agents/tool registration, chat composer, CTO, missions, onboarding/settings, and remote runtime. -```ts -function ChatView({ - events, activeSession, streaming, interrupted, - expandedLineIds, maxRows, scrollOffsetRows, selection, - width, laneName, projectName, provider, notices, … -}) -``` +Validation already run once on the final tree: -No local state for messages — fully driven from parent. **You will extend the props with `focused?: boolean` (for tile-level focus rendering) and `onRemove?: () => void` (for the `×` affordance).** Internal logic computes `RenderedChatRow` from aggregated blocks (`aggregateChatBlocks()` in `aggregate.ts`). +- `npm test` from repo root: all 8 desktop shards passed, then ADE CLI tests passed. +- `npm --prefix apps/desktop run typecheck` +- `npm --prefix apps/ade-cli run typecheck` +- `npm --prefix apps/desktop run lint` passed with 0 errors and existing warnings. +- `npm --prefix apps/desktop run build` +- `node scripts/validate-docs.mjs` +- `git diff --check` +- `node --check` on touched dev/test-shard scripts. -### Submit / prompt routing (read `app.tsx` `submitPrompt` ~4050) +You still need to audit. Green tests are not a substitute for thought. -```ts -async function submitPrompt(value: string) { - // ...validate, parse slash commands, extract attachments - const sessionId = activeSessionIdRef.current; // <-- this line is the only routing - await sendChatMessage(conn, sessionId, text, attachments); - setStreaming(true); - await refreshState(); -} -``` - -In multi-view, replace the `sessionId` line with: -```ts -const sessionId = multiView - ? multiView.tiles[multiView.focusedIndex].sessionId - : activeSessionIdRef.current; -``` - -### Persistence (read `apps/ade-cli/src/tuiClient/state.ts`) - -`~/.ade/ade-code-state.json` stores `lastChatByLane` and `lastChatByProjectLane` (per-lane last-active session). Debounced 500ms saves via `saveAdeCodeProjectState()`. +--- -**Do not** add `multiView` here. Multi-view is in-memory only. +## Required first steps + +1. Read `AGENTS.md`. +2. Run: + - `git status --short` + - `git diff --stat` + - `git diff --name-status` + - `git diff --check` +3. Read the current diff in slices. Do not only look at file names. +4. Use parallel agents for independent audit slices. Suggested slices: + - ADE CLI/runtime daemon/dev scripts. + - Desktop renderer/model picker/settings/onboarding. + - Desktop main services/orchestrator/mission/app-control/macOS VM/local runtime. + - Docs/package/test infrastructure. +5. Keep working locally while agents run. Do not wait idly unless you are blocked. +6. If a subagent finds a real issue, patch it and rerun relevant validation. --- -## Feature 1: Universal click — detailed design +## Areas that deserve extra suspicion -### Step 1: Hit-test registry +### 1. Runtime role normalization and daemon freshness -Create `apps/ade-cli/src/tuiClient/hitTestRegistry.ts`: +Files to audit: -```ts -export type HitRect = { x: number; y: number; w: number; h: number }; +- `apps/ade-cli/src/runtimeRoles.ts` +- `apps/ade-cli/src/cli.ts` +- `apps/ade-cli/src/adeRpcServer.ts` +- `apps/ade-cli/src/multiProjectRpcServer.ts` +- `apps/ade-cli/src/tuiClient/connection.ts` +- `apps/ade-cli/src/tuiClient/__tests__/connection.test.ts` +- `apps/ade-cli/src/stdioRpcDaemon.test.ts` +- `scripts/dev-shared.mjs` +- `scripts/dev-desktop.mjs` +- `scripts/dev-code.mjs` +- `scripts/dev-runtime.mjs` +- `scripts/dev-all.mjs` +- `scripts/tui-web.mjs` -export type HitTarget = { - id: string; // stable id for the click target - rect: HitRect; // absolute terminal coordinates (1-based) - onClick?: (ev: MouseEvent) => void; - onHover?: (hovered: boolean) => void; - zIndex?: number; // higher wins on overlap (default 0) -}; +Questions to answer: -export interface HitTestRegistry { - register(target: HitTarget): void; - unregister(id: string): void; - hitTest(x: number, y: number): HitTarget | null; // for clicks - hoverTest(x: number, y: number): HitTarget | null; // same lookup, used for hover state - clear(): void; -} +- Is `ADE_DEFAULT_ROLE` normalized consistently everywhere? +- Are invalid roles handled as missing where that is intended? +- Does embedded TUI force `cto` only when it should, and restore env afterward? +- Do `serve`, headless CLI, machine daemon, multi-project daemon, and desktop socket-backed RPC agree on trusted role behavior? +- Can an older daemon with the same version but stale code survive because `buildHash` is missing or computed against the wrong file? +- Is the placeholder version `0.0.0` bypass intentional and still safe? +- Does `dev:desktop --auto` now behave consistently with `dev-code`/`tui-web` auto mode? +- Does `dev:desktop --auto` starting a runtime before Electron change the expected developer experience in a bad way? +- Do attach modes fail clearly when the runtime is stale? +- Do TCP sockets fail safely when auto-start is impossible? +- Are docs accurate about build hash, default role, project root, and daemon freshness? -// Backed by a flat array; linear scan is plenty fast (<500 targets, called per mouse event ≤ 60Hz). -export function createHitTestRegistry(): HitTestRegistry { … } -``` +Targeted validation ideas: -Wire one registry instance into `app.tsx` via a React context (`HitTestProvider`). Components grab the registry through a `useHitTest()` hook and call `register` in a `useEffect` (cleanup unregisters). Use `useLayoutEffect` if you find render-order races between mouse event and registration. - -Inside `app.tsx`'s `parseTerminalMouseInput` consumer: -```ts -case "click": { - const target = registry.hitTest(mouse.x, mouse.y); - if (target?.onClick) target.onClick(mouse); - break; -} -case "move": { - const target = registry.hoverTest(mouse.x, mouse.y); - if (target?.id !== currentHoveredId) { - currentHovered?.onHover?.(false); - target?.onHover?.(true); - currentHoveredId = target?.id ?? null; - setHoveredId(currentHoveredId); // triggers re-render so highlight updates - } - break; -} -``` +- `npm --prefix apps/ade-cli run typecheck` +- `npx vitest run src/stdioRpcDaemon.test.ts src/tuiClient/__tests__/connection.test.ts` in `apps/ade-cli` +- `npx vitest run src/adeRpcServer.test.ts src/multiProjectRpcServer.test.ts` in `apps/ade-cli` +- `node --check scripts/dev-shared.mjs scripts/dev-desktop.mjs scripts/dev-code.mjs scripts/tui-web.mjs scripts/run-desktop-test-shards.mjs` -### Step 2: Enable mode 1003 (mouse move) +### 2. Constrained model selection -In the mouse-enable block in `app.tsx`: -```ts -process.stdout.write("\x1b[?1003h"); // any-event tracking (includes mouse-move) -``` -And on shutdown: -```ts -process.stdout.write("\x1b[?1003l"); -``` +Files to audit: -Note: mode 1003 generates events for *every* cursor movement, which can be heavy. Throttle the hover-test path with `requestAnimationFrame`-equivalent (e.g. setImmediate-based coalescing) if profiling shows it dominating render cost. +- `apps/desktop/src/renderer/components/chat/AgentChatPane.tsx` +- `apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx` +- `apps/desktop/src/renderer/components/chat/AgentChatPane.submit.test.tsx` +- `apps/desktop/src/renderer/components/shared/ModelPicker/ModelPicker.tsx` +- `apps/desktop/src/renderer/components/shared/ModelPicker/ModelPickerContent.tsx` -### Step 3: Hover styling +Questions to answer: -Components decide their own hover render. Standard pattern: -```tsx -const [hovered, setHovered] = useState(false); -useHitTest({ id, rect, onClick, onHover: setHovered }); -return ; -``` +- Does constrained mode truly avoid merging runtime catalog models? +- Does it block submit, draft creation, and parallel launch when the selected model is stale or unavailable? +- Is `runtimeCatalogVersion` intentionally in the memo dependencies even if lint calls it unnecessary? If yes, do not "fix" it into a stale UI bug. +- Are existing unconstrained chat surfaces still able to pick configured/runtime models as before? +- Are active-session models preserved where allowed by policy? +- Are empty constrained lists handled with clear UI and no accidental launch? +- Are tests strong enough to catch stale localStorage model config issues? -Use Ink's `Box backgroundColor` or `Text inverse` for the highlight — pick whichever reads better with the existing palette. Keep it subtle; the hover is meant to teach, not strobe. +Targeted validation ideas: -### Step 4: Migrate components (full parity list) +- `npx vitest run src/renderer/components/chat/AgentChatPane.submit.test.tsx src/renderer/components/shared/ModelPicker/ModelPicker.test.tsx` in `apps/desktop` +- `npm --prefix apps/desktop run typecheck` -Convert each of these to register hit-test rects and call their existing keyboard action on click. Source columns reference where the keyboard handler lives today. +### 3. Settings AI provider routing -| Pane | Component / handler | File:line (approx) | -|-------------------|------------------------------------------------------|-------------------------------| -| Right pane | Model picker rows + tab rail + favorite toggle (`f`) | `app.tsx` 7878–7937 | -| Right pane | Model picker search (`/`) | `app.tsx` 7940–7970 | -| Right pane | Lane-details actions (`Return`, t-toggle file tree) | `app.tsx` 7979–8044 | -| Right pane | List pane rows + scroll | `app.tsx` 8048–8056 | -| Right pane | Lane-delete form radio (`1/2/3`, space/`f`) | `app.tsx` 7750–7765 | -| Overlay | Approval prompt accept/decline (`a`/`d`) | `app.tsx` 7732–7735 | -| Overlay | Mention palette nav + insert (up/down/Tab/Enter) | `app.tsx` 8118–8142 | -| Overlay | Slash palette nav + insert | `app.tsx` 8118–8142 | -| Bottom prompt | Prompt-history recall (k/j or up/down in vim mode) | `app.tsx` 7597–7603 | -| Bottom prompt | Prompt submit (vim normal mode) | `app.tsx` 7605–7608 | -| Footer | Status-line clickable model swap, etc. | `ModelStatus`, `FooterControls` | -| Header | Project / lane / chat title clickable to open palette| `Header` component | +Files to audit: -For each: the keyboard handler stays; clicks dispatch the same intent. After migration, delete the old `*IndexForMouseLine` helpers (~`app.tsx` 1557–1620). +- `apps/desktop/src/renderer/components/settings/AiFeaturesSection.tsx` +- `apps/desktop/src/renderer/components/settings/AiFeaturesSection.test.tsx` +- `apps/desktop/src/renderer/components/settings/ProvidersSection.tsx` +- `apps/desktop/src/renderer/components/settings/ProxyAndPreviewSection.tsx` +- related docs in `docs/features/onboarding-and-settings/README.md` -### Step 5: Don't break what works +Questions to answer: -Things already mouse-wired (don't touch their behavior, just port them to the registry on the way through): +- Do all model picker surfaces that need provider setup route to `/settings?tab=ai#ai-providers`? +- Did the cleanup remove imports or handlers that had side effects? +- Are the tests too coupled to implementation details, or are they pinning useful routing behavior? -| Target | File:line | -|-----------------------------------------------|-------------------------| -| Drawer lanes/chats select | `app.tsx` 7096–7131 | -| Chat transcript text select (click/drag/release) | `app.tsx` 7181–7225 | -| Chat scroll wheel | `app.tsx` 7239–7245 | -| Prompt focus click | `app.tsx` 7083–7094 | -| Lane detail action click (existing partial) | `app.tsx` 7141–7144 | -| Form field click | `app.tsx` 7156–7170 | -| Subagent transcript list click | `app.tsx` 7175–7176 | +### 4. Onboarding and CTO tour behavior ---- +Files to audit: -## Feature 2: Multi-chat middle pane — detailed design +- `apps/desktop/src/renderer/onboarding/tours/ctoTour.ts` +- `apps/desktop/src/renderer/onboarding/tours/firstJourneyTour.ts` +- `apps/desktop/src/renderer/onboarding/tours/firstJourneyTour.test.ts` +- `apps/desktop/src/main/services/onboarding/onboardingService.ts` +- `apps/desktop/src/renderer/components/cto/CtoPage.tsx` +- `apps/desktop/src/renderer/components/cto/LinearSyncPanel.tsx` +- `apps/desktop/src/renderer/components/cto/TeamPanel.tsx` -### Step 1: State +Questions to answer: -In `app.tsx`: -```ts -type MultiViewTile = { sessionId: string; laneId: string }; -type MultiViewState = { tiles: MultiViewTile[]; focusedIndex: number }; +- Are CTO tab switch step actions preserved when steps are wrapped into the first journey tour? +- Do step targets still match actual renderer tab keys? +- Did cleanup remove any event bridging that tours still depend on? +- Are docs and terminology still current? -const [multiView, setMultiView] = useState(null); -const multiViewRef = useRef(null); -useEffect(() => { multiViewRef.current = multiView; }, [multiView]); +Targeted validation: -// Per-tile transient state -const [streamingBySessionId, setStreamingBySessionId] = useState>({}); -const [scrollBySessionId, setScrollBySessionId] = useState>({}); -const [selectionBySessionId, setSelectionBySessionId] = useState>({}); -const [promptHistoryBySessionId, setPromptHistoryBySessionId] = useState>({}); +- `npx vitest run src/renderer/onboarding/tours/firstJourneyTour.test.ts` in `apps/desktop` -// Add-mode -const [addMode, setAddMode] = useState<{ cursorLaneId: string; cursorChatId: string | null } | null>(null); -``` +### 5. Mission/orchestrator status sync -### Step 2: Streaming refactor +Files to audit: -Replace every reference to global `streaming` with `streamingBySessionId[sessionId]`. The places that set `setStreaming(true/false)` (in `submitPrompt`, `onChatEvent`) update the record instead: -```ts -setStreamingBySessionId(prev => ({ ...prev, [sessionId]: true })); -``` +- `apps/desktop/src/main/services/orchestrator/aiOrchestratorService.ts` +- `apps/desktop/src/main/services/orchestrator/aiOrchestratorService.test.ts` +- `apps/desktop/src/main/services/orchestrator/orchestratorContext.ts` +- `apps/desktop/src/main/services/orchestrator/coordinatorTools.ts` +- `apps/desktop/src/main/services/orchestrator/coordinatorAgent.ts` +- `apps/desktop/src/main/services/missions/missionService.ts` +- `docs/features/missions/README.md` +- `docs/features/missions/orchestration.md` -Widen the event subscription filter: -```ts -const openSessionIds = multiView - ? new Set(multiView.tiles.map(t => t.sessionId)) - : new Set([activeSessionIdRef.current]); +Questions to answer: -if (!openSessionIds.has(envelope.sessionId)) { - refreshState({ hydrateHistory: false }); - return; -} -``` +- Is `nextMissionStatus` re-derived after `syncMissionStepsFromRun` and `syncMissionPhaseFromRun` in every path where the mission may have changed? +- Is the new failed-step intervention regression enough, or are there other sync-created intervention paths that need coverage? +- Does the re-derive logic respect explicit `options.nextMissionStatus`? +- Does finalization still use a fresh enough mission detail for outcome summaries and errors? +- Did removed imports/functions have any side effects? +- Did cleanup of coordinator/orchestrator types accidentally weaken tool contracts? -### Step 3: Layout math - -Create `apps/ade-cli/src/tuiClient/multiChatLayout.ts`: - -```ts -export type TileRect = { x: number; y: number; w: number; h: number }; - -const PATTERNS: Record> = { - 1: [{ row: 0, col: 0, rowSpan: 1, colSpan: 1, rows: 1, cols: 1 }], - 2: [ - { row: 0, col: 0, rowSpan: 1, colSpan: 1, rows: 1, cols: 2 }, - { row: 0, col: 1, rowSpan: 1, colSpan: 1, rows: 1, cols: 2 }, - ], - 3: [ - { row: 0, col: 0, rowSpan: 1, colSpan: 1, rows: 1, cols: 3 }, - { row: 0, col: 1, rowSpan: 1, colSpan: 1, rows: 1, cols: 3 }, - { row: 0, col: 2, rowSpan: 1, colSpan: 1, rows: 1, cols: 3 }, - ], - 4: [ /* 2x2 */ - { row: 0, col: 0, rowSpan: 1, colSpan: 1, rows: 2, cols: 2 }, - { row: 0, col: 1, rowSpan: 1, colSpan: 1, rows: 2, cols: 2 }, - { row: 1, col: 0, rowSpan: 1, colSpan: 1, rows: 2, cols: 2 }, - { row: 1, col: 1, rowSpan: 1, colSpan: 1, rows: 2, cols: 2 }, - ], - 5: [ /* 2 top, 3 bottom — row 0 has 2 cells over a 6-col virtual grid (each spans 3); row 1 has 3 cells (each spans 2) */ - { row: 0, col: 0, rowSpan: 1, colSpan: 3, rows: 2, cols: 6 }, - { row: 0, col: 3, rowSpan: 1, colSpan: 3, rows: 2, cols: 6 }, - { row: 1, col: 0, rowSpan: 1, colSpan: 2, rows: 2, cols: 6 }, - { row: 1, col: 2, rowSpan: 1, colSpan: 2, rows: 2, cols: 6 }, - { row: 1, col: 4, rowSpan: 1, colSpan: 2, rows: 2, cols: 6 }, - ], - 6: [ /* 3x2 */ - { row: 0, col: 0, rowSpan: 1, colSpan: 1, rows: 2, cols: 3 }, - { row: 0, col: 1, rowSpan: 1, colSpan: 1, rows: 2, cols: 3 }, - { row: 0, col: 2, rowSpan: 1, colSpan: 1, rows: 2, cols: 3 }, - { row: 1, col: 0, rowSpan: 1, colSpan: 1, rows: 2, cols: 3 }, - { row: 1, col: 1, rowSpan: 1, colSpan: 1, rows: 2, cols: 3 }, - { row: 1, col: 2, rowSpan: 1, colSpan: 1, rows: 2, cols: 3 }, - ], -}; - -export function computeTileRects(n: 1|2|3|4|5|6, width: number, height: number): TileRect[] { - const pat = PATTERNS[n]; - const colW = Math.floor(width / pat[0].cols); - const rowH = Math.floor(height / pat[0].rows); - return pat.map(p => ({ - x: p.col * colW, - y: p.row * rowH, - w: p.colSpan * colW, - h: p.rowSpan * rowH, - })); -} -``` +Targeted validation: -Edge cases: -- If `width < 2 * MIN_TILE_W` for an n>=2 layout, refuse to render the grid and surface a notice in the status line ("terminal too narrow for multi-view"). Suggest `MIN_TILE_W = 30`, `MIN_TILE_H = 8` — tune by feel. -- Round-down division leaves a few unused cells at the right/bottom edge. That's fine; the parent `` clips. - -### Step 4: `MultiChatGrid` component - -Create `apps/ade-cli/src/tuiClient/components/MultiChatGrid.tsx`: - -```tsx -type Props = { - tiles: MultiViewTile[]; - focusedIndex: number; - width: number; - height: number; - eventsBySessionId: Record; - sessionBySessionId: Record; - streamingBySessionId: Record; - scrollBySessionId: Record; - selectionBySessionId: Record; - onFocusTile: (index: number) => void; - onRemoveTile: (index: number) => void; -}; - -export function MultiChatGrid(props: Props) { - const rects = useMemo(() => - computeTileRects(props.tiles.length as 1|2|3|4|5|6, props.width, props.height), - [props.tiles.length, props.width, props.height]); - - return ( - - {props.tiles.map((t, i) => { - const rect = rects[i]; - const isFocused = i === props.focusedIndex; - return ( - - props.onRemoveTile(i)} - // … other passthrough props - /> - - ); - })} - - ); -} -``` +- `npx vitest run src/main/services/orchestrator/aiOrchestratorService.test.ts -t "intervention_required"` in `apps/desktop` +- `npx vitest run src/main/services/orchestrator/orchestratorPlanning.test.ts src/main/services/orchestrator/coordinatorTools.test.ts` in `apps/desktop` -Note: Ink supports `position="absolute"` via Yoga's positioning model; verify with a quick smoke test before relying on it. If it doesn't, render tiles in normal flow with computed line padding (the chat-row-budget pattern already does this). +### 6. App control, computer use, and artifact ownership -### Step 5: Per-tile prompt history +Files to audit: -```ts -// when submitting -setPromptHistoryBySessionId(prev => ({ - ...prev, - [sessionId]: [...(prev[sessionId] ?? []).slice(-99), text], // cap at 100 -})); +- `apps/desktop/src/main/services/appControl/appControlService.ts` +- `apps/desktop/src/main/services/appControl/appControlService.test.ts` +- `apps/desktop/src/main/services/ai/tools/universalTools.ts` +- `apps/desktop/src/main/services/computerUse/*` -// when up-arrow recalls -const history = promptHistoryBySessionId[focusedSessionId] ?? []; -``` +Questions to answer: -### Step 6: Add-mode +- Is screenshot timeout cleanup robust on success, failure, and timeout? +- Are timers `unref()`ed only where safe? +- Are policy enforcement and artifact ownership still implemented in code, not just prompts? +- Did cleanup remove capability checks or ownership metadata? -State machine: -``` -normal ──(Ctrl+A, only when activePane === "chat")──> addMode -addMode ──(Esc)──> normal (no changes) -addMode ──(Enter on highlighted chat)──> normal + multiView updated -``` +Targeted validation: -Render: when `addMode` is set, the layout wraps every non-drawer region in a `` that applies dim styling (Ink's `` on all descendants, or a custom `` wrapper). Insert a top banner row: -``` - Pick a chat to add · ↵ Add · Esc Cancel -``` - -The drawer renders normally but with a separate cursor state (`addMode.cursorLaneId / cursorChatId`) so navigation in add-mode does NOT touch `activeLaneId` / `activeSessionId`. Reuse the existing drawer rendering function — just thread the alternate cursor in. - -Add behavior: -```ts -function addTileToGrid(sessionId: string, laneId: string) { - setMultiView(prev => { - // If chat is already in grid, refocus its tile, no-op the add. - if (prev) { - const existingIdx = prev.tiles.findIndex(t => t.sessionId === sessionId); - if (existingIdx >= 0) return { ...prev, focusedIndex: existingIdx }; - if (prev.tiles.length >= 6) return prev; // cap - return { - tiles: [...prev.tiles, { sessionId, laneId }], - focusedIndex: prev.tiles.length, // focus the newly added tile - }; - } - // Bootstrapping into multi-view: include the currently-active chat as tile 0 - return { - tiles: [ - { sessionId: activeSessionIdRef.current, laneId: activeLaneIdRef.current }, - { sessionId, laneId }, - ], - focusedIndex: 1, - }; - }); -} -``` - -### Step 7: Remove - -```ts -function removeTile(index: number) { - setMultiView(prev => { - if (!prev) return prev; - const tiles = prev.tiles.filter((_, i) => i !== index); - if (tiles.length < 2) { - // Exit multi-view; surviving tile (if any) becomes the active chat in single mode - if (tiles[0]) { - selectActiveLaneId(tiles[0].laneId); - selectActiveSessionId(tiles[0].sessionId); - } - return null; - } - const focusedIndex = Math.min(prev.focusedIndex, tiles.length - 1); - return { tiles, focusedIndex }; - }); -} -``` - -### Step 8: Focus follows tile - -When `multiView` is set and `multiView.focusedIndex` changes, sync the rest of the TUI: -```ts -useEffect(() => { - if (!multiView) return; - const tile = multiView.tiles[multiView.focusedIndex]; - if (!tile) return; - if (tile.laneId !== activeLaneIdRef.current) selectActiveLaneId(tile.laneId); - if (tile.sessionId !== activeSessionIdRef.current) selectActiveSessionId(tile.sessionId); -}, [multiView]); -``` +- `npx vitest run src/main/services/appControl/appControlService.test.ts` in `apps/desktop` -This makes the right pane, status bar, and sidebar lane highlight all reflect the focused tile's lane automatically — they already read from `activeLaneId` / `activeSessionId`. +### 7. Local runtime connection pool test cleanup -### Step 9: Drag-to-add +Files to audit: -In the drawer chat row component, register a `onDragStart` via the registry (extend `HitTarget` with optional `onDragStart`/`onDrop`). When mouse-drag begins on a chat row and ends inside the middle pane bounds, invoke `addTileToGrid(sessionId, laneId)`. Mouse mode 1002 (drag) is already enabled — coordinates flow through the parser as `drag` events. +- `apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.test.ts` -### Step 10: Keybindings +Previous audit noted duplicated temp-dir cleanup. It was not refactored because it was low risk and the suite was passing. -Pick free chords. Suggested: -- `Ctrl+G` — toggle add-mode (only fires when `activePane === "chat"`) -- `Ctrl+W` — remove focused tile -- `Tab` — cycle focused tile within multi-view (existing `Tab` cycles panes; bind it to tile-cycle when in chat pane *and* multi-view is active; otherwise current behavior) -- Mouse: click anywhere inside a tile = focus that tile; click on the `×` = remove +Your task: -Document these in the footer and in `FooterControls` rendering. +- Decide if this duplication is worth simplifying now. +- If you refactor, keep it tiny and rerun the file. +- Do not churn the test if it makes failure cleanup less obvious. ---- +Targeted validation: -## UI specification (depth) +- `npx vitest run src/main/services/localRuntime/localRuntimeConnectionPool.test.ts` in `apps/desktop` -### Tile chrome +### 8. Sharded tests and package scripts -Single-line header at the top of each tile: +Files to audit: -``` -┌ lane-slug / chat-title ●─────────────── ×┐ -``` +- `package.json` +- `apps/desktop/package.json` +- `scripts/run-desktop-test-shards.mjs` +- `apps/desktop/vitest.workspace.ts` +- `docs/ARCHITECTURE.md` +- `AGENTS.md` -- Leading `┌` corner from Ink's border. -- `lane-slug` truncated with ellipsis if needed to keep `chat-title` visible. -- ` / ` separator. -- `chat-title` (the session's display name) truncated as needed. -- Trailing space + `●` when `streaming === true`, otherwise space. -- Right-aligned `×` (1 cell) when `onRemove` is provided. Register this single cell as a separate hit target so click works precisely. +Questions to answer: -### Focused tile vs unfocused +- Does root `npm test` match CI-style desktop sharding and then ADE CLI? +- Does `test:unit` avoid unsupported Vitest `--project`? +- Does the sharding script propagate failures, preserve exit codes, and avoid hiding output? +- Should the sharding script be tested directly, or is command validation enough? +- Are docs clear that `vitest.workspace.ts` is active but CLI `--project` is unsupported for the pinned Vitest version? -- **Unfocused tile**: `borderStyle="round"` (Ink built-in), header text default color. -- **Focused tile**: `borderStyle="double"`, header text in cyan (``). -- Content (message body) is **not dimmed** on unfocused tiles — keep them fully readable so background streaming is visible at a glance. +Validation: -### Six grid layouts (wireframes) +- `node --check scripts/run-desktop-test-shards.mjs` +- `npm test` from repo root before final -Imagine the middle pane is ~80 cols wide × ~24 rows tall. Borders are illustrative; real rendering uses Ink's box borders. +### 9. Docs and stale text -**N=1 — full:** -``` -┌─ lane-a / chat-1 ●────────────────────────────────────────────────────────┐ -│ │ -│ (full chat body) │ -│ │ -└────────────────────────────────────────────────────────────────────────── ×┘ -``` +Files to audit: -**N=2 — 2 cols:** -``` -┌─ lane-a/chat-1 ●─────────────┐ ╔ lane-b/chat-2 ●═════════════════╗ -│ │ ║ ║ -│ left chat │ ║ right chat (focused) ║ -│ │ ║ ║ -└──────────────────────────── ×┘ ╚══════════════════════════════ ×╝ -``` +- `AGENTS.md` +- `README.md` +- `docs/ARCHITECTURE.md` +- `docs/features/ade-code/README.md` +- `docs/features/agents/tool-registration.md` +- `docs/features/chat/composer-and-ui.md` +- `docs/features/cto/README.md` +- `docs/features/missions/README.md` +- `docs/features/missions/orchestration.md` +- `docs/features/onboarding-and-settings/README.md` +- `docs/features/remote-runtime/internal-architecture.md` -**N=3 — 3 cols:** -``` -┌ lane-a/c1 ●────┐ ┌ lane-b/c2 ●────┐ ╔ lane-c/c3 ●════════╗ -│ │ │ │ ║ ║ -│ │ │ │ ║ focused ║ -└────────────── ×┘ └────────────── ×┘ ╚════════════════ ×╝ -``` +Questions to answer: -**N=4 — 2×2:** -``` -┌ lane-a/c1 ●────────────────┐ ╔ lane-b/c2 ●═══════════════╗ -│ │ ║ (focused) ║ -└────────────────────────── ×┘ ╚═════════════════════════ ×╝ -┌ lane-c/c3 ●────────────────┐ ┌ lane-a/c4 ●────────────────┐ -│ │ │ │ -└────────────────────────── ×┘ └────────────────────────── ×┘ -``` +- Are docs consistent with current code, not just with the previous agent's mental model? +- Are there stale references to removed action-list changed events, old test commands, old runtime role trust model, or old local-runtime behavior? +- Are tables still valid Markdown? +- Did docs validation cover all edited docs? -**N=5 — 2 top + 3 bottom:** -``` -┌ lane-a/c1 ●────────────────────────┐ ┌ lane-b/c2 ●───────────────────────┐ -│ │ │ │ -│ │ │ │ -└────────────────────────────────── ×┘ └─────────────────────────────────×┘ -┌ lane-c/c3 ●──────┐ ╔ lane-d/c4 ●═════╗ ┌ lane-e/c5 ●──────┐ -│ │ ║ (focused) ║ │ │ -└──────────────── ×┘ ╚═══════════════ ×╝ └──────────────── ×┘ -``` +Commands: -**N=6 — 3×2:** -``` -┌ lane-a/c1 ●─────┐ ┌ lane-b/c2 ●─────┐ ╔ lane-c/c3 ●═══════╗ -│ │ │ │ ║ focused ║ -└─────────────── ×┘ └─────────────── ×┘ ╚═══════════════ ×╝ -┌ lane-d/c4 ●─────┐ ┌ lane-e/c5 ●─────┐ ┌ lane-f/c6 ●───────┐ -│ │ │ │ │ │ -└─────────────── ×┘ └─────────────── ×┘ └───────────────── ×┘ -``` +- `node scripts/validate-docs.mjs` +- `rg -n "onActionsListChanged|actions/list_changed|--project unit|vitest.config.ts|265 test files|listChanged: true|runtimeInfo.defaultRole|runtimeInfo.buildHash|ADE_DEFAULT_ROLE" AGENTS.md README.md docs apps scripts package.json` -### Add-mode +Note: Some words like "planner" are real product concepts. Do not delete real docs just because a broad search finds the term. -Full app view with dim overlay everywhere except the drawer: +### 10. Removed imports and "cleanup-only" changes -``` - Pick a chat to add · ↵ Add · Esc Cancel -┌──────────────┐ ┌── (dim middle) ─────────────┐ ┌(dim right)┐ -│ lane-foo │ │ existing tile contents │ │ │ -│ chat-1 ▸ │ │ (still visible, just dim) │ │ │ -│ chat-2 │ │ │ │ │ -│ lane-bar │ │ │ │ │ -│ chat-3 │ │ │ │ │ -│ chat-4 │ └─────────────────────────────┘ └───────────┘ -│ lane-baz │ ┌── (dim prompt) ──────────────────────────┐ -│ chat-5 │ │ │ -└──────────────┘ └──────────────────────────────────────────┘ -``` +Many files were touched only to remove imports, helper functions, or stale code. These are easy places to accidentally remove a side-effect import or a piece of behavior that looked unused. -- Banner uses default colors (not dimmed) — it's the only bright thing besides the sidebar so the eye lands on it. -- The `▸` marker indicates the add-mode cursor (separate from the underlying active-chat highlight). -- Pressing arrow keys moves `▸` across lanes and chats freely. `Enter` adds. `Esc` exits. +Audit at least these: -### Status-bar grid mini-map +- `apps/desktop/src/main/main.ts` +- `apps/desktop/src/main/services/ipc/registerIpc.ts` +- `apps/desktop/src/main/services/state/kvDb.ts` +- `apps/desktop/src/renderer/components/app/AppShell.tsx` +- `apps/desktop/src/renderer/components/lanes/LaneGitActionsPane.tsx` +- `apps/desktop/src/renderer/components/lanes/LanesPage.tsx` +- `apps/desktop/src/renderer/components/missions/missionThreadEventAdapter.ts` +- `apps/desktop/src/renderer/components/history/*` +- `apps/desktop/src/renderer/components/settings/*` -Append to the footer status row, after model name: +Questions: -- N=1: `▣` -- N=2: `▣▢` or `▢▣` depending on focus -- N=3: `▣▢▢` etc. -- N=4: `▣▢ / ▢▢` (rows separated by ` / `) -- N=5: `▣▢ / ▢▢▢` -- N=6: `▣▢▢ / ▢▢▢` +- Was any import removed that registered a side effect? +- Was any callback removed that still appears in JSX, IPC contracts, preload types, or tests? +- Was any type removed from shared types while serialized data still uses it? +- Do typecheck and full tests exercise enough of the affected code path? -Use `▣` for focused tile, `▢` for unfocused. Render in plain Unicode; no color needed. +--- -### Hover affordance (universal click) +## Search checklist -When `hoveredId` matches a registered target, the target renders with a subtle highlight. Pick **one** of these and apply consistently: +Run targeted searches and actually inspect results: -- Option A: `` wrapping the target. -- Option B: `` on the target's text. +```sh +rg -n "TODO|FIXME|HACK|XXX|dead code|unused|stale|deprecated|temporary|compat|fallback" apps docs scripts +rg -n "onActionsListChanged|actions/list_changed|listChanged: true" apps docs scripts +rg -n "ADE_DEFAULT_ROLE|defaultRole|runtimeInfo|buildHash|projectRoot" apps/ade-cli/src scripts docs apps/desktop/src +rg -n "availableModelIdsOverride|constrainModelSelection|constrainToAvailableModelIds|runtimeCatalogVersion" apps/desktop/src/renderer/components +rg -n "syncMissionFromRun|deriveMissionStatusFromRun|intervention_required|failed_step" apps/desktop/src/main/services +rg -n "--project|test:unit|test:desktop:sharded|run-desktop-test-shards" package.json apps/desktop/package.json docs scripts +``` -Recommendation: Option A for multi-cell targets (rows, buttons, tabs), Option B for inline single-line affordances (the `×`, palette items). Keep the effect very subtle — this is a confirmation, not a beacon. +Do not treat grep output as truth. Follow each suspicious result to code. --- -## Edge cases to handle - -- **Terminal too narrow** for the chosen grid (e.g. N=6 but `width < 90`). Refuse to render the grid; show a notice in the status line ("Multi-view: terminal too narrow, displaying focused tile only") and render only the focused tile full-width until the terminal grows. -- **Tile session ends** (chat marked `status: "ended"`). Keep the tile visible (with greyed header), don't auto-remove. Let the user decide via `×`. -- **Lane deleted** while one of its sessions is in the grid. Treat similarly to ended — keep the tile, badge it with `(lane removed)` in the header. -- **Drag-to-add when grid is already at 6**: ignore the drop, flash a 1s status-line notice ("Multi-view full (max 6)"). -- **Active session changes** outside multi-view (e.g. user clicks a sidebar chat in single-chat mode). Single-chat behavior preserved exactly. -- **Add-mode while terminal resizes**: cancel add-mode, return to normal layout, do not lose existing multi-view tiles. -- **Mouse mode 1003 not supported** by the user's terminal (rare but possible — older `tmux` versions, some SSH client wrappers). Detection is hard; if hover events never arrive, the feature degrades gracefully — clicks still work, just no hover. Acceptable. -- **High event volume** with 6 concurrent streams: confirm event aggregation in `aggregateChatBlocks` doesn't lock the render thread. Add a small throttle if needed (coalesce per-session re-renders to ~30 Hz). +## Validation bar before you finish ---- +Run smallest relevant tests while iterating. Before final, run the broad checks. -## Files to create / modify +Minimum final validation: -### Create - -- `apps/ade-cli/src/tuiClient/hitTestRegistry.ts` — registry implementation + React context + `useHitTest` hook. -- `apps/ade-cli/src/tuiClient/multiChatLayout.ts` — `computeTileRects` + PATTERNS table. -- `apps/ade-cli/src/tuiClient/components/MultiChatGrid.tsx` — grid wrapper rendering N `` instances. -- `apps/ade-cli/src/tuiClient/components/AddChatMode.tsx` — banner + dim wrapper + alt-cursor drawer rendering. -- `apps/ade-cli/src/tuiClient/components/GridMiniMap.tsx` — small footer component. -- Test files alongside each. +```sh +git diff --check +node scripts/validate-docs.mjs +npm --prefix apps/desktop run typecheck +npm --prefix apps/ade-cli run typecheck +npm --prefix apps/desktop run lint +npm --prefix apps/ade-cli run build +npm --prefix apps/desktop run build +npm test +pgrep -fl "run-desktop-test-shards|vitest run --shard|npm test|tsc -p tsconfig|eslint.js|vite build|tsup" || true +``` -### Modify +Important: -- `apps/ade-cli/src/tuiClient/app.tsx` — the bulk of the work: - - Add new state (multiView, addMode, streamingBySessionId, scrollBySessionId, selectionBySessionId, promptHistoryBySessionId). - - Replace `streaming` references throughout. - - Widen `onChatEvent` filter to `openSessionIds`. - - Branch middle-pane render: `multiView ? : `. - - Extend `useInput` to handle add-mode keys (Esc, Enter, arrows) and multi-view keys (Ctrl+G, Ctrl+W, Tab for tile cycle). - - Update `submitPrompt` to route by focused tile. - - Enable/disable mouse mode 1003. - - Migrate each existing `*IndexForMouseLine` helper to component-level registry registration. - - Add `useEffect` syncing focused-tile → active lane/session. -- `apps/ade-cli/src/tuiClient/components/ChatView.tsx` — add `focused?: boolean` + `onRemove?: () => void` props; render double-border + cyan header + clickable `×` accordingly. -- `apps/ade-cli/src/tuiClient/state.ts` — **no changes** (multiView intentionally ephemeral). +- The full desktop suite must be sharded. Root `npm test` should do this. +- Do not run desktop and ADE CLI builds in parallel; they can collide through shared build artifacts. +- Desktop lint may report existing warnings. Treat new errors as failures. If you introduce new warnings in touched files, consider fixing them if low risk. +- Expected full-test noise includes React unknown-prop warnings from mocked split panes, mocked PR check failures, preload fallback errors, terminal fit rejection logs, SQLite experimental warnings, and mocked git stderr in lane tests. These are not failures if the tests pass. +- If you touch frontend behavior that needs visual confidence, launch the local ADE desktop dev app and inspect the actual Electron surface. Follow `AGENTS.md` for macOS Computer Use instructions. Do not use Safari as the desktop parity reference. --- -## Verification plan +## Parallel agent prompts you can reuse -End-to-end (run the TUI in this worktree): +Use subagents because this is too broad for one mental thread. -1. **Universal click smoke test** - - Open model picker → click rows, tabs, favorite star → confirm parity with keyboard. - - Trigger approval prompt → click accept/decline. - - Open slash and mention palettes → click items. - - Open lane-delete form → click radio options and force toggle. - - Move mouse around → hover-highlight follows pointer. +### Agent A: CLI/runtime/dev scripts -2. **Multi-chat lifecycle** - - From single chat, press `Ctrl+G` → add-mode banner appears, non-sidebar regions dim. - - Arrow into another lane → confirm underlying active lane stays put. - - Press Enter on a chat → grid switches to 2-col with new tile focused, right pane updates to new tile's lane. - - Add 3rd, 4th, 5th, 6th → confirm layouts match wireframes. - - Press `Ctrl+G` while 6 tiles open and try to add → confirm cap (no add, notice). - - Click `×` on a tile → confirm removal + grid reshapes. - - Remove down to 2 tiles → confirm remaining tile becomes single-chat mode (multiView cleared). +Audit only, or patch only with permission. Worktree is `/Users/admin/Projects/ADE/.ade/worktrees/huge-cleanup-8469674a`. Review the current diff for ADE CLI runtime roles, daemon freshness, `buildHash`, `defaultRole`, `projectRoot`, env restoration, `serve`, headless CLI, socket-backed RPC, and dev launcher scripts. Find behavior regressions or missing tests. Return severity, file/line references, and validation run. -3. **Cross-lane focus sync** - - 2 tiles from 2 lanes → click between them → confirm right pane, status bar, sidebar lane highlight all follow focused tile. - - Switch lanes via sidebar → confirm multi-view persists. +### Agent B: Renderer model/settings/onboarding -4. **Concurrent streaming** - - Open 3 tiles → send a long prompt in each (focus, submit, focus next, submit, focus next, submit). - - Confirm all three stream simultaneously with ● indicators; non-focused tiles continue rendering events. +Audit constrained model selection, provider setup routing, shared `ModelPicker`, chat submit/parallel launch guards, `AiFeaturesSection`, CTO tour wrapping, and first-journey tour tests. Verify stale model and empty allowed-list behavior. Return severity, file/line references, and validation run. -5. **Per-tile history** - - Send 3 prompts to tile A, 2 prompts to tile B. - - Focus A → up-arrow cycles only A's. Focus B → up-arrow cycles only B's. +### Agent C: Main services/orchestrator/runtime cleanup -6. **Drag-to-add** - - Click-drag a sidebar chat row into the middle → confirm it adds without add-mode. +Audit app control screenshot timers, mission/orchestrator status sync, local runtime connection pool tests, macOS VM cleanup, removed imports in main services, and state/IPC cleanup. Look for behavior hidden behind "dead code" removals. Return severity, file/line references, and validation run. -7. **Persistence (negative)** - - Open 4 tiles → kill TUI → relaunch → confirm app starts in single-chat mode. +### Agent D: Docs/package/test infrastructure -Unit tests: - -- `hitTestRegistry.test.ts` — register/unregister, overlapping rects (higher `zIndex` wins), out-of-bounds returns null. -- `multiChatLayout.test.ts` — for each n ∈ [1,6], rects tile the area without overlap, respect minimums, total area ≤ input area. -- `streamingBySessionId.test.ts` — events for non-focused sessions still update the per-session record. -- `addMode.test.ts` — keyboard navigation does not mutate `activeSessionId` / `activeLaneId`; Enter calls `addTileToGrid` with the cursor target. - -Manual perf: with 6 tiles streaming, keystroke latency in the bottom prompt stays under ~16ms. +Audit docs and package scripts for stale commands, wrong Vitest config references, bad Markdown tables, missing mention of runtime freshness, and sharding script robustness. Return severity, file/line references, and validation run. --- -## Docs / references to read before starting +## Completion criteria -- **Ink (terminal React)**: — especially the section on `Box`, `Text`, `useInput`, `useStdout`, and the `position`/`marginLeft`/`marginTop` props (Yoga layout). -- **XTerm mouse tracking modes**: — for mode 1003 (any-event tracking), SGR encoding, and how to disable cleanly. -- **Yoga layout reference**: — Ink's underlying layout engine; relevant if you opt for `position="absolute"` in `MultiChatGrid`. -- **Existing ADE patterns to study before coding**: - - `apps/ade-cli/src/tuiClient/app.tsx` — read `parseTerminalMouseInput`, `submitPrompt`, `onChatEvent`, the `useInput` block, and the layout `` tree (~7626–7800). - - `apps/ade-cli/src/tuiClient/components/ChatView.tsx` — full component, especially the event-aggregation and row-rendering paths. - - `apps/ade-cli/src/tuiClient/state.ts` — to know what NOT to touch for persistence. - - `apps/desktop/src/shared/types/chat.ts` lines 725–773 — the `AgentChatSession` type and friends. -- **Existing hit-test helpers** (to be deleted post-migration): `laneDetailsActionIndexForMouseLine`, `formFieldIndexForMouseLine`, `setupPaneRowIndexForMouseLine`, `subagentIndexForPaneLine` in `app.tsx` ~1557–1620. - ---- +You are done only when: -## Out of scope (do not do) +- Every current diff file has been considered, not merely listed. +- All subagent findings are either fixed or explicitly rejected with a reason. +- Any new behavior you rely on has focused tests. +- The broad validation bar passes on the final tree. +- `git status --short` is understood, including untracked required files. +- You have not left long-running test/build/dev processes behind. +- Your final summary names what changed, what was validated, and any honest residual risk. -- **Persistence of multi-view across restart** — explicitly ephemeral. -- **Broadcasting one prompt to multiple tiles** — single-tile routing only. -- **Per-tile prompt input** — keep the one bottom prompt; routing changes are enough. -- **Tile rearranging / drag-to-swap** — tiles render in insertion order; not negotiable for v1. -- **Number-key tile focus, middle-click remove, hover gutter arrow, streaming flash on non-focused tile** — not selected from the extras menu. -- **Touching the desktop or web apps** — TUI-only change. +This is a second cleanup pass. Be skeptical, be practical, and preserve the feel of the product. diff --git a/package.json b/package.json index 924f0a6ac..dd74307a0 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,9 @@ "runtime:build": "npm --prefix apps/ade-cli run build", "setup": "npm run install:apps", "stop": "node scripts/dev-runtime-stop.mjs", - "test": "npm run test --prefix apps/desktop && npm run test --prefix apps/ade-cli", - "test:ci": "npm run test:coverage --prefix apps/desktop && npm run test --prefix apps/ade-cli" + "test": "npm run test:desktop:sharded && npm --prefix apps/ade-cli run test", + "test:ci": "npm run test:desktop:sharded && npm --prefix apps/ade-cli run test", + "test:coverage": "npm --prefix apps/desktop run test:coverage && npm --prefix apps/ade-cli run test", + "test:desktop:sharded": "node scripts/run-desktop-test-shards.mjs" } } diff --git a/scripts/dev-all.mjs b/scripts/dev-all.mjs index d059cec34..6559bdc02 100644 --- a/scripts/dev-all.mjs +++ b/scripts/dev-all.mjs @@ -15,7 +15,7 @@ function usage() { "Then run npm run dev:desktop:attach and npm run dev:code:attach in separate terminals.", "", "Options:", - " --project-root Project root. Defaults to this checkout.", + " --project-root Project root. Defaults to the primary checkout for ADE worktrees.", " --socket Dev runtime socket. Defaults to /tmp/ade-runtime-dev.sock.", " --skip-runtime-build Launch without rebuilding apps/ade-cli.", " -h, --help Show this help.", @@ -70,7 +70,7 @@ async function main() { process.stdout.write(`[ade] project root: ${options.projectRoot}\n`); process.stdout.write(`[ade] runtime socket: ${options.socketPath}\n`); await buildRuntimeCliForDevClient(options.skipRuntimeBuild, options.socketPath); - await ensureRuntime(options.socketPath); + await ensureRuntime(options.socketPath, options.projectRoot); process.stdout.write("[ade] dev runtime is ready.\n"); process.stdout.write("[ade] terminal 1: npm run dev:desktop:attach\n"); process.stdout.write("[ade] terminal 2: npm run dev:code:attach\n"); diff --git a/scripts/dev-code.mjs b/scripts/dev-code.mjs index 5177c2331..e00a5e426 100644 --- a/scripts/dev-code.mjs +++ b/scripts/dev-code.mjs @@ -2,6 +2,7 @@ import { buildRuntimeCliForDevClient, + assertRuntimeFresh, canConnectToSocket, cliPath, devRuntimeEnv, @@ -113,9 +114,10 @@ async function main() { throw new Error(`No dev runtime is listening at ${options.socketPath}. Start it with npm run dev:runtime.`); } await buildRuntimeCliForDevClient(options.skipRuntimeBuild, options.socketPath); + await assertRuntimeFresh(options.socketPath, options.projectRoot); } else { await buildRuntimeCliForDevClient(options.skipRuntimeBuild, options.socketPath); - await ensureRuntime(options.socketPath); + await ensureRuntime(options.socketPath, options.projectRoot); } await run( process.execPath, diff --git a/scripts/dev-desktop.mjs b/scripts/dev-desktop.mjs index 204e8bc0c..6c8b6f994 100644 --- a/scripts/dev-desktop.mjs +++ b/scripts/dev-desktop.mjs @@ -3,8 +3,10 @@ import fs from "node:fs"; import { buildRuntimeCliForDevClient, + assertRuntimeFresh, canConnectToSocket, devRuntimeEnv, + ensureRuntime, npmCommand, resolveDevSocketPath, resolveProjectRoot, @@ -98,6 +100,11 @@ async function main() { throw new Error(`No dev runtime is listening at ${options.socketPath}. Start it with npm run dev:runtime.`); } await buildRuntimeCliForDevClient(options.skipRuntimeBuild, options.socketPath); + if (options.mode === "attach") { + await assertRuntimeFresh(options.socketPath, options.projectRoot); + } else { + await ensureRuntime(options.socketPath, options.projectRoot); + } const desktopScript = options.clean ? "dev:clean" : "dev"; await run( npmCommand, diff --git a/scripts/dev-runtime.mjs b/scripts/dev-runtime.mjs index 4c33e75ef..fa0fc6c26 100644 --- a/scripts/dev-runtime.mjs +++ b/scripts/dev-runtime.mjs @@ -16,7 +16,7 @@ function usage() { "Builds the ADE CLI/runtime, then runs only the dev runtime in the foreground.", "", "Options:", - " --project-root Project root exported to the runtime. Defaults to this checkout.", + " --project-root Project root exported to the runtime. Defaults to the primary checkout for ADE worktrees.", " --socket Dev runtime socket. Defaults to /tmp/ade-runtime-dev.sock.", " --skip-runtime-build Launch without rebuilding apps/ade-cli.", " --no-sync Disable runtime sync discovery for this run.", diff --git a/scripts/dev-shared.mjs b/scripts/dev-shared.mjs index 6475f645e..d56baf2c1 100644 --- a/scripts/dev-shared.mjs +++ b/scripts/dev-shared.mjs @@ -13,6 +13,12 @@ export const npmCommand = process.platform === "win32" ? "npm.cmd" : "npm"; export const defaultDevSocketPath = process.platform === "win32" ? path.join(os.tmpdir(), "ade-runtime-dev.sock") : "/tmp/ade-runtime-dev.sock"; +const validDefaultRoles = new Set(["cto", "orchestrator", "agent", "external", "evaluator"]); + +function normalizeDefaultRole(value, fallback = null) { + const candidate = typeof value === "string" ? value.trim() : ""; + return validDefaultRoles.has(candidate) ? candidate : fallback; +} export function resolveDevSocketPath(rawSocketPath = null) { const candidate = rawSocketPath?.trim() @@ -181,6 +187,15 @@ export async function buildRuntimeCliForDevClient(skipRuntimeBuild, socketPath) await buildRuntimeCli(false); } +export async function assertRuntimeFresh(socketPath, projectRoot = null) { + const info = await getRuntimeInfo(socketPath); + const mismatch = runtimeMismatchReason(info, { projectRoot }); + if (!mismatch) return; + throw new Error( + `The dev runtime at ${socketPath} is stale (${mismatch}). Restart it with npm run dev:runtime or use auto mode so ADE can restart it.`, + ); +} + function createSocket(socketPath) { if (socketPath.startsWith("tcp://")) { const parsed = new URL(socketPath); @@ -203,7 +218,13 @@ function readRuntimeInfo(value) { const buildHash = typeof runtimeInfo.buildHash === "string" && runtimeInfo.buildHash.trim() ? runtimeInfo.buildHash.trim() : null; - return { version, buildHash }; + const defaultRole = typeof runtimeInfo.defaultRole === "string" && runtimeInfo.defaultRole.trim() + ? runtimeInfo.defaultRole.trim() + : null; + const projectRoot = typeof runtimeInfo.projectRoot === "string" && runtimeInfo.projectRoot.trim() + ? path.resolve(runtimeInfo.projectRoot.trim()) + : null; + return { version, buildHash, defaultRole, projectRoot }; } function jsonRpcRequestSequence(socketPath, requests, options = {}) { @@ -324,9 +345,11 @@ async function getRuntimeInfo(socketPath) { return readRuntimeInfo(result); } -function runtimeMismatchReason(info) { +function runtimeMismatchReason(info, expected = {}) { const expectedVersion = resolveDevAppVersion(); const expectedBuildHash = computeRuntimeBuildHash(); + const expectedDefaultRole = normalizeDefaultRole(process.env.ADE_DEFAULT_ROLE, "cto"); + const expectedProjectRoot = expected.projectRoot ? path.resolve(expected.projectRoot) : null; if (info.version && info.version !== expectedVersion) { return `version ${info.version} != ${expectedVersion}`; } @@ -335,6 +358,12 @@ function runtimeMismatchReason(info) { ? "build hash changed" : "build hash missing"; } + if (info.defaultRole !== expectedDefaultRole) { + return `default role ${info.defaultRole ?? "missing"} != ${expectedDefaultRole}`; + } + if (expectedProjectRoot && info.projectRoot !== expectedProjectRoot) { + return `project root ${info.projectRoot ?? "missing"} != ${expectedProjectRoot}`; + } return null; } @@ -395,11 +424,14 @@ async function shutdownRuntime(socketPath) { await waitForSocketToClose(socketPath); } -export async function ensureRuntime(socketPath) { +export async function ensureRuntime(socketPath, projectRoot = null) { try { const info = await getRuntimeInfo(socketPath); - const mismatch = runtimeMismatchReason(info); + const mismatch = runtimeMismatchReason(info, { projectRoot }); if (!mismatch) return false; + if (socketPath.startsWith("tcp://")) { + throw new Error(`ADE dev runtime at ${socketPath} is stale (${mismatch}), and TCP runtimes cannot be auto-started.`); + } process.stdout.write(`[ade] restarting stale dev runtime at ${socketPath} (${mismatch})\n`); await shutdownRuntime(socketPath); } catch (error) { @@ -415,11 +447,7 @@ export async function ensureRuntime(socketPath) { cwd: repoRoot, env: { ...process.env, - ADE_CLI_VERSION: resolveDevAppVersion(), - ADE_DEV_RUNTIME_SOCKET_PATH: socketPath, - ADE_RUNTIME_SOCKET_PATH: socketPath, - ADE_RPC_SOCKET_PATH: socketPath, - ...runtimeBuildEnv(), + ...devRuntimeEnv(socketPath, projectRoot), }, detached: true, stdio: "ignore", @@ -433,6 +461,7 @@ export async function ensureRuntime(socketPath) { export function devRuntimeEnv(socketPath, projectRoot) { return { ADE_CLI_VERSION: resolveDevAppVersion(), + ADE_DEFAULT_ROLE: normalizeDefaultRole(process.env.ADE_DEFAULT_ROLE, "cto"), ADE_DEV_RUNTIME_SOCKET_PATH: socketPath, ADE_RUNTIME_SOCKET_PATH: socketPath, ADE_RPC_SOCKET_PATH: socketPath, diff --git a/scripts/run-desktop-test-shards.mjs b/scripts/run-desktop-test-shards.mjs new file mode 100644 index 000000000..b889d47e3 --- /dev/null +++ b/scripts/run-desktop-test-shards.mjs @@ -0,0 +1,27 @@ +#!/usr/bin/env node + +import { spawnSync } from "node:child_process"; + +const npmCommand = process.platform === "win32" ? "npm.cmd" : "npm"; +const shardCount = Number.parseInt(process.env.ADE_DESKTOP_TEST_SHARDS ?? "8", 10); +const totalShards = Number.isFinite(shardCount) && shardCount > 0 ? shardCount : 8; + +for (let shard = 1; shard <= totalShards; shard += 1) { + process.stdout.write(`[ade] desktop test shard ${shard}/${totalShards}\n`); + const result = spawnSync( + npmCommand, + ["--prefix", "apps/desktop", "run", "test:unit", "--", `--shard=${shard}/${totalShards}`], + { stdio: "inherit" }, + ); + if (result.error) { + process.stderr.write(`[ade] desktop test shard ${shard}/${totalShards} failed to start: ${result.error.message}\n`); + process.exit(1); + } + if (result.signal) { + process.stderr.write(`[ade] desktop test shard ${shard}/${totalShards} exited with signal ${result.signal}\n`); + process.exit(1); + } + if (result.status !== 0) { + process.exit(result.status ?? 1); + } +} diff --git a/scripts/tui-web.mjs b/scripts/tui-web.mjs index 352f731e1..2fec56c9b 100644 --- a/scripts/tui-web.mjs +++ b/scripts/tui-web.mjs @@ -13,6 +13,7 @@ import net from "node:net"; import path from "node:path"; import { buildRuntimeCliForDevClient, + assertRuntimeFresh, canConnectToSocket, cliPath, devRuntimeEnv, @@ -438,9 +439,10 @@ async function main() { throw new Error(`No dev runtime is listening at ${options.socketPath}. Start it with npm run dev:runtime.`); } await buildRuntimeCliForDevClient(options.skipRuntimeBuild, options.socketPath); + await assertRuntimeFresh(options.socketPath, options.projectRoot); } else { await buildRuntimeCliForDevClient(options.skipRuntimeBuild, options.socketPath); - await ensureRuntime(options.socketPath); + await ensureRuntime(options.socketPath, options.projectRoot); } let pty; diff --git a/scripts/validate-docs.mjs b/scripts/validate-docs.mjs index b79ec6c21..d9a2fda15 100644 --- a/scripts/validate-docs.mjs +++ b/scripts/validate-docs.mjs @@ -7,7 +7,6 @@ const ignoredTopLevel = new Set([ ".github", ".ade", "apps", - "docs", "node_modules", "plans", "dist", @@ -31,7 +30,12 @@ async function walkDocs(dir) { continue; } - if (relPath === "README.md" || relPath.endsWith(".mdx")) { + if ( + relPath === "README.md" || + relPath === "AGENTS.md" || + relPath.endsWith(".mdx") || + (relPath.startsWith("docs/") && relPath.endsWith(".md")) + ) { docFiles.push(relPath); } } @@ -53,6 +57,7 @@ const errors = []; function normalizeTarget(rawTarget, fromFile) { const stripped = rawTarget.split("#")[0]?.split("?")[0] ?? ""; + if (stripped === "…" || stripped === "...") return null; if (!stripped || stripped.startsWith("http://") || stripped.startsWith("https://") || stripped.startsWith("mailto:") || stripped.startsWith("tel:")) { return null; } @@ -150,4 +155,3 @@ if (errors.length > 0) { } console.log(`Documentation validation passed for ${docFiles.length} files.`); - From 2dabe69cb815ac578771881c2d675ee870dae7df Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Sun, 24 May 2026 18:09:12 -0400 Subject: [PATCH 2/9] Address launch cleanup review feedback --- apps/ade-cli/src/adeRpcServer.test.ts | 10 ++- apps/ade-cli/src/adeRpcServer.ts | 14 +++- apps/ade-cli/src/cli.test.ts | 4 +- apps/ade-cli/src/cli.ts | 4 +- .../tuiClient/__tests__/connection.test.ts | 3 + apps/ade-cli/src/tuiClient/connection.ts | 13 ++-- .../main/services/ai/tools/workflowTools.ts | 1 + .../computerUseArtifactBrokerService.test.ts | 32 +++++++++ .../computerUseArtifactBrokerService.ts | 22 ++++++- .../aiOrchestratorService.test.ts | 65 +++++++++++++++++++ .../orchestrator/aiOrchestratorService.ts | 10 ++- .../chat/AgentChatComposer.test.tsx | 43 +++++++----- .../components/chat/AgentChatComposer.tsx | 16 +++-- .../src/renderer/lib/computerUse.test.ts | 1 + .../src/shared/types/computerUseArtifacts.ts | 7 ++ scripts/dev-shared.mjs | 27 ++++++-- 16 files changed, 227 insertions(+), 45 deletions(-) diff --git a/apps/ade-cli/src/adeRpcServer.test.ts b/apps/ade-cli/src/adeRpcServer.test.ts index dab2712e4..1318d9c8e 100644 --- a/apps/ade-cli/src/adeRpcServer.test.ts +++ b/apps/ade-cli/src/adeRpcServer.test.ts @@ -1608,7 +1608,7 @@ describe("adeRpcServer", () => { }); expect(fixture.runtime.computerUseArtifactBrokerService.ingest).toHaveBeenCalledWith( expect.objectContaining({ - backend: { name: "macos-vm", toolName: "macos_vm_screenshot" }, + backend: { name: "macos-vm", style: "local_fallback", toolName: "macos_vm_screenshot" }, owners: expect.arrayContaining([ expect.objectContaining({ kind: "lane", id: "lane-1" }), expect.objectContaining({ kind: "chat_session", id: "chat-session-1" }), @@ -1631,7 +1631,7 @@ describe("adeRpcServer", () => { }); expect(fixture.runtime.computerUseArtifactBrokerService.ingest).toHaveBeenCalledWith( expect.objectContaining({ - backend: { name: "macos-vm", toolName: "macos_vm_select" }, + backend: { name: "macos-vm", style: "local_fallback", toolName: "macos_vm_select" }, }), ); @@ -1671,7 +1671,7 @@ describe("adeRpcServer", () => { }); expect(fixture.runtime.computerUseArtifactBrokerService.ingest).toHaveBeenCalledWith( expect.objectContaining({ - backend: { name: "macos-vm", toolName: "screenshot_environment" }, + backend: { name: "macos-vm", style: "local_fallback", toolName: "screenshot_environment" }, }), ); @@ -1967,6 +1967,10 @@ describe("adeRpcServer", () => { expect(runtime.computerUseArtifactBrokerService.ingest).toHaveBeenCalledWith( expect.objectContaining({ + backend: expect.objectContaining({ + name: "agent-browser", + style: "external_cli", + }), owners: expect.arrayContaining([ expect.objectContaining({ kind: "chat_session", diff --git a/apps/ade-cli/src/adeRpcServer.ts b/apps/ade-cli/src/adeRpcServer.ts index 47d871440..778c7b206 100644 --- a/apps/ade-cli/src/adeRpcServer.ts +++ b/apps/ade-cli/src/adeRpcServer.ts @@ -42,6 +42,7 @@ import { } from "../../desktop/src/shared/prIssueResolution"; import { type LinearWorkflowConfig, + type ComputerUseBackendStyle, type ComputerUseArtifactOwner, type LaneLinearIssue, type MergeMethod, @@ -2440,6 +2441,14 @@ function assertNonEmptyString(value: unknown, field: string): string { return text; } +function assertComputerUseBackendStyle(value: unknown, field: string): ComputerUseBackendStyle { + const style = assertNonEmptyString(value, field); + if (style === "external_cli" || style === "manual" || style === "local_fallback") { + return style; + } + throw new JsonRpcError(JsonRpcErrorCode.invalidParams, `${field} must be one of: external_cli, manual, local_fallback`); +} + function parseCliSessionProvider(value: unknown): LaunchProfile { const provider = asTrimmedString(value).toLowerCase(); if (!isLaunchProfile(provider)) { @@ -4666,6 +4675,7 @@ async function runTool(args: { const result = runtime.computerUseArtifactBrokerService.ingest({ backend: { name: "screencapture", + style: "local_fallback", toolName: args.toolName, }, inputs: [ @@ -4735,6 +4745,7 @@ async function runTool(args: { const result = runtime.computerUseArtifactBrokerService.ingest({ backend: { name: "macos-vm", + style: "local_fallback", toolName: args.toolName, }, inputs: [ @@ -5894,7 +5905,7 @@ async function runTool(args: { } if (name === "ingest_computer_use_artifacts") { - assertNonEmptyString(toolArgs.backendStyle, "backendStyle"); + const backendStyle = assertComputerUseBackendStyle(toolArgs.backendStyle, "backendStyle"); const backendName = assertNonEmptyString(toolArgs.backendName, "backendName"); const manifestPath = asOptionalTrimmedString(toolArgs.manifestPath); let inputs = Array.isArray(toolArgs.inputs) ? toolArgs.inputs.map((entry) => safeObject(entry)) : []; @@ -5933,6 +5944,7 @@ async function runTool(args: { const result = runtime.computerUseArtifactBrokerService.ingest({ backend: { name: backendName, + style: backendStyle, toolName: asOptionalTrimmedString(toolArgs.toolName), command: asOptionalTrimmedString(toolArgs.command), }, diff --git a/apps/ade-cli/src/cli.test.ts b/apps/ade-cli/src/cli.test.ts index fcc9fef6b..f11a8d5ac 100644 --- a/apps/ade-cli/src/cli.test.ts +++ b/apps/ade-cli/src/cli.test.ts @@ -59,12 +59,12 @@ describe("ADE CLI", () => { ]); }); - it("defaults ordinary CLI calls to the CTO runtime role", () => { + it("defaults ordinary CLI calls to the agent runtime role", () => { const previousRole = process.env.ADE_DEFAULT_ROLE; delete process.env.ADE_DEFAULT_ROLE; try { const parsed = parseCliArgs(["lanes", "list"]); - expect(parsed.options.role).toBe("cto"); + expect(parsed.options.role).toBe("agent"); } finally { if (previousRole === undefined) delete process.env.ADE_DEFAULT_ROLE; else process.env.ADE_DEFAULT_ROLE = previousRole; diff --git a/apps/ade-cli/src/cli.ts b/apps/ade-cli/src/cli.ts index 8a69dc22c..fc2af7ab4 100644 --- a/apps/ade-cli/src/cli.ts +++ b/apps/ade-cli/src/cli.ts @@ -2058,7 +2058,7 @@ function parseCliArgs(argv: string[]): ParsedCli { const options: GlobalOptions = { projectRoot: null, workspaceRoot: null, - role: resolveAdeDefaultRole(process.env.ADE_DEFAULT_ROLE, "cto"), + role: resolveAdeDefaultRole(process.env.ADE_DEFAULT_ROLE, "agent"), headless: parseBooleanEnv(process.env.ADE_CLI_HEADLESS), requireSocket: false, pretty: true, @@ -13443,7 +13443,7 @@ function summarizeExecution(args: { connection.mode === "desktop-socket" ? "local-desktop-socket" : "local-headless-project", - role: resolveAdeDefaultRole(process.env.ADE_DEFAULT_ROLE, "cto"), + role: resolveAdeDefaultRole(process.env.ADE_DEFAULT_ROLE, "agent"), projectRoot: connection.projectRoot, workspaceRoot: connection.workspaceRoot, socketPath: connection.socketPath, diff --git a/apps/ade-cli/src/tuiClient/__tests__/connection.test.ts b/apps/ade-cli/src/tuiClient/__tests__/connection.test.ts index ae65de0ed..904770f0e 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/connection.test.ts +++ b/apps/ade-cli/src/tuiClient/__tests__/connection.test.ts @@ -428,6 +428,7 @@ describe("connectToAde embedded mode", () => { ADE_RPC_SOCKET_PATH: socketPath, }), }); + expect((spawnCall?.[2] as { env?: Record } | undefined)?.env?.ADE_RUNTIME_BUILD_HASH).toBeUndefined(); expect(childProcess.child.unref).toHaveBeenCalledTimes(1); expect(client.close).toHaveBeenCalledTimes(1); }); @@ -439,6 +440,7 @@ describe("connectToAde embedded mode", () => { ); const entrypoint = path.join(entrypointDir, "cli.cjs"); fs.writeFileSync(entrypoint, "#!/usr/bin/env node\n"); + const expectedBuildHash = createHash("sha256").update(fs.readFileSync(entrypoint)).digest("hex"); process.argv[1] = entrypoint; mockAttachedClient(); @@ -458,6 +460,7 @@ describe("connectToAde embedded mode", () => { env: expect.objectContaining({ ADE_DEFAULT_ROLE: "cto", ADE_RPC_SOCKET_PATH: socketPath, + ADE_RUNTIME_BUILD_HASH: expectedBuildHash, }), }); }); diff --git a/apps/ade-cli/src/tuiClient/connection.ts b/apps/ade-cli/src/tuiClient/connection.ts index eec998538..8ef1285d7 100644 --- a/apps/ade-cli/src/tuiClient/connection.ts +++ b/apps/ade-cli/src/tuiClient/connection.ts @@ -452,20 +452,23 @@ function attachedRuntimeMismatchReason( function spawnDaemon(socketPath: string): boolean { const cliEntrypoint = resolveCliEntrypoint(); + const buildHash = computeCliEntrypointBuildHash(); const daemonArgs = cliEntrypoint ? [cliEntrypoint, "serve", "--socket", socketPath] : ["serve", "--socket", socketPath]; + const env = { + ...process.env, + ADE_DEFAULT_ROLE: "cto", + ADE_RPC_SOCKET_PATH: socketPath, + ...(buildHash ? { ADE_RUNTIME_BUILD_HASH: buildHash } : {}), + }; const child = spawn( process.execPath, daemonArgs, { detached: true, stdio: "ignore", - env: { - ...process.env, - ADE_DEFAULT_ROLE: "cto", - ADE_RPC_SOCKET_PATH: socketPath, - }, + env, }, ); child.unref(); diff --git a/apps/desktop/src/main/services/ai/tools/workflowTools.ts b/apps/desktop/src/main/services/ai/tools/workflowTools.ts index 83bba488a..af2a2ed9b 100644 --- a/apps/desktop/src/main/services/ai/tools/workflowTools.ts +++ b/apps/desktop/src/main/services/ai/tools/workflowTools.ts @@ -185,6 +185,7 @@ export function createWorkflowTools( const result = computerUseArtifactBrokerService.ingest({ backend: { name: "screencapture", + style: "local_fallback", toolName: "captureScreenshot", }, inputs: [ diff --git a/apps/desktop/src/main/services/computerUse/computerUseArtifactBrokerService.test.ts b/apps/desktop/src/main/services/computerUse/computerUseArtifactBrokerService.test.ts index 9e70c10ad..84a27bf99 100644 --- a/apps/desktop/src/main/services/computerUse/computerUseArtifactBrokerService.test.ts +++ b/apps/desktop/src/main/services/computerUse/computerUseArtifactBrokerService.test.ts @@ -177,6 +177,38 @@ describe("computerUseArtifactBrokerService", () => { } }); + it("persists the declared backend style for ingested artifacts", () => { + const broker = createComputerUseArtifactBrokerService({ + db, + projectId: "project-1", + projectRoot, + missionService: { addArtifact: vi.fn() } as any, + orchestratorService: { registerArtifact: vi.fn() } as any, + logger: createLogger(), + }); + + const ingested = broker.ingest({ + backend: { + name: "ade-cli", + style: "manual", + }, + inputs: [ + { + kind: "console_logs", + title: "Manual note", + text: "Looks good.", + }, + ], + }); + + expect(ingested.artifacts[0]?.backendStyle).toBe("manual"); + const row = db.get<{ backend_style: string }>( + `select backend_style from computer_use_artifacts where id = ?`, + [ingested.artifacts[0]!.id], + ); + expect(row?.backend_style).toBe("manual"); + }); + it("rejects symlinked artifact paths that escape the project artifact directory", () => { const missionService = { addArtifact: vi.fn() } as any; const orchestratorService = { registerArtifact: vi.fn() } as any; diff --git a/apps/desktop/src/main/services/computerUse/computerUseArtifactBrokerService.ts b/apps/desktop/src/main/services/computerUse/computerUseArtifactBrokerService.ts index dfd123f84..4354a7e1a 100644 --- a/apps/desktop/src/main/services/computerUse/computerUseArtifactBrokerService.ts +++ b/apps/desktop/src/main/services/computerUse/computerUseArtifactBrokerService.ts @@ -16,6 +16,7 @@ import type { ComputerUseArtifactReviewState, ComputerUseArtifactRouteArgs, ComputerUseArtifactView, + ComputerUseBackendStyle, ComputerUseBackendStatus, ComputerUseExternalBackendStatus, ComputerUseArtifactWorkflowState, @@ -72,6 +73,10 @@ const ARTIFACT_PREVIEW_MIME_BY_EXTENSION: Record = { webp: "image/webp", }; +type ComputerUseArtifactRecordInsert = Omit & { + backendStyle?: ComputerUseBackendStyle | null; +}; + type StoredLinkRow = { id: string; artifact_id: string; @@ -221,6 +226,10 @@ function defaultTitleForKind(kind: ComputerUseArtifactKind): string { return kind.replace(/_/g, " "); } +function normalizeBackendStyle(style: unknown): ComputerUseBackendStyle { + return style === "manual" || style === "local_fallback" ? style : "external_cli"; +} + function normalizeInputKind(input: ComputerUseArtifactInput): ComputerUseArtifactKind { const normalized = normalizeComputerUseArtifactKind(input.kind ?? input.rawType ?? input.title ?? null); if (normalized) return normalized; @@ -313,10 +322,13 @@ export function createComputerUseArtifactBrokerService(args: { }; }; - const insertArtifactRecord = (record: Omit): ComputerUseArtifactRecord => { + const insertArtifactRecord = (record: ComputerUseArtifactRecordInsert): ComputerUseArtifactRecord => { + const { backendStyle: rawBackendStyle, ...artifactRecord } = record; + const backendStyle = normalizeBackendStyle(rawBackendStyle); const next: ComputerUseArtifactRecord = { id: randomUUID(), - ...record, + backendStyle, + ...artifactRecord, createdAt: nowIso(), }; db.run( @@ -330,7 +342,7 @@ export function createComputerUseArtifactBrokerService(args: { next.id, projectId, next.kind, - "external_cli", + backendStyle, next.backendName, next.sourceToolName, next.originalType, @@ -462,6 +474,7 @@ export function createComputerUseArtifactBrokerService(args: { laneId, metadata: { brokerArtifactId: record.id, + backendStyle: record.backendStyle, backendName: record.backendName, sourceToolName: record.sourceToolName, originalType: record.originalType, @@ -489,6 +502,7 @@ export function createComputerUseArtifactBrokerService(args: { brokerArtifactId: record.id, title: record.title, description: record.description, + backendStyle: record.backendStyle, backendName: record.backendName, sourceToolName: record.sourceToolName, uri: record.uri, @@ -503,6 +517,7 @@ export function createComputerUseArtifactBrokerService(args: { db.all(query, params).map((row) => ({ id: row.id, kind: row.artifact_kind as ComputerUseArtifactKind, + backendStyle: normalizeBackendStyle(row.backend_style), backendName: row.backend_name, sourceToolName: row.source_tool_name, originalType: row.original_type, @@ -606,6 +621,7 @@ export function createComputerUseArtifactBrokerService(args: { }; const record = insertArtifactRecord({ kind, + backendStyle: normalizeBackendStyle(request.backend.style), backendName: request.backend.name, sourceToolName: toOptionalString(request.backend.toolName) ?? toOptionalString(request.backend.command), originalType: toOptionalString(input.rawType) ?? toOptionalString(input.kind), diff --git a/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.test.ts b/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.test.ts index 40b229e87..c0bd56452 100644 --- a/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.test.ts +++ b/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.test.ts @@ -7007,6 +7007,71 @@ describe("aiOrchestratorService", () => { } }); + it("does not sync run steps to duplicate mission titles without a stable link", async () => { + const fixture = await createFixture(); + try { + const mission = fixture.missionService.create({ + prompt: "Avoid title-only mission step sync.", + laneId: fixture.laneId, + plannedSteps: [ + { + index: 0, + title: "Implement API", + detail: "First duplicate title", + kind: "implementation", + metadata: { stepType: "implementation" } + }, + { + index: 1, + title: "Implement API", + detail: "Second duplicate title", + kind: "implementation", + metadata: { stepType: "implementation" } + } + ] + }); + const missionSteps = fixture.missionService.get(mission.id)?.steps ?? []; + expect(missionSteps.map((step) => step.title)).toEqual(["Implement API", "Implement API"]); + + const started = fixture.orchestratorService.startRun({ + missionId: mission.id, + steps: [ + { + stepKey: "unlinked-implement-api", + title: "Implement API", + stepIndex: 0, + dependencyStepKeys: [], + executorKind: "manual", + metadata: { stepType: "implementation" } + } + ] + }); + const runId = started.run.id; + fixture.missionService.update({ + missionId: mission.id, + status: "in_progress", + }); + const failedAt = new Date().toISOString(); + fixture.db.run( + `update orchestrator_runs set status = 'active', updated_at = ? where id = ?`, + [failedAt, runId], + ); + fixture.db.run( + `update orchestrator_steps set status = 'failed', completed_at = ?, updated_at = ? where run_id = ?`, + [failedAt, failedAt, runId], + ); + + await fixture.aiOrchestratorService.syncMissionFromRun(runId, "step_failed"); + + const refreshed = fixture.missionService.get(mission.id); + expect(refreshed?.steps.map((step) => step.status)).toEqual(["pending", "pending"]); + expect(refreshed?.status).toBe("in_progress"); + expect(refreshed?.interventions).toHaveLength(0); + } finally { + fixture.dispose(); + } + }); + it("applies steering directives onto active run steps for worker prompt guidance", async () => { const fixture = await createFixture(); try { diff --git a/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.ts b/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.ts index 678fed3be..abda203ab 100644 --- a/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.ts +++ b/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.ts @@ -6982,7 +6982,7 @@ Check all worker statuses and continue managing the mission from here. Read work return mission.steps.find((step) => { const metadata = isRecord(step.metadata) ? step.metadata : null; return metadata?.orchestratorStepId === runStep.id || metadata?.stepKey === runStep.stepKey; - }) ?? mission.steps.find((step) => step.title === runStep.title) ?? null; + }) ?? null; }; for (const runStep of graph.steps) { @@ -7063,7 +7063,9 @@ Check all worker statuses and continue managing the mission from here. Read work reasonCode: "terminal_run_failed_step", } }); - transitionMissionStatus(mission.id, "intervention_required", { allowTerminalRestart: true }); + if (mission.status !== "intervention_required") { + transitionMissionStatus(mission.id, "intervention_required", { allowTerminalRestart: true }); + } }; // TERMINAL_PHASE_STEP_STATUSES — imported from missionLifecycle @@ -7341,7 +7343,9 @@ Check all worker statuses and continue managing the mission from here. Read work : deriveMissionStatusFromRun(graph, latestMission); if (latestStatus === "intervention_required") { nextMissionStatus = latestStatus; - transitionMissionStatus(mission.id, latestStatus, { allowTerminalRestart: true }); + if (latestMission.status !== "intervention_required") { + transitionMissionStatus(mission.id, latestStatus, { allowTerminalRestart: true }); + } } else { transitionMissionStatus(mission.id, "failed", { lastError: extractRunFailureMessage(graph) diff --git a/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx index 330da2798..9fb6f588f 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx @@ -155,23 +155,6 @@ function makeLinearIssue(overrides: Partial = {}): Normal }; } -const executionModeOptions = [ - { - value: "focused", - label: "Focused", - summary: "Single stream", - helper: "Keep work in one stream.", - accent: "#38bdf8", - }, - { - value: "parallel", - label: "Parallel", - summary: "Split work", - helper: "Use parallel branches for independent tasks.", - accent: "#c084fc", - }, -] as NonNullable["executionModeOptions"]>; - describe("AgentChatComposer", () => { it("clear draft only triggers the draft-clear action during an active turn", () => { const props = renderComposer(); @@ -638,6 +621,32 @@ describe("AgentChatComposer", () => { expect(props.onSubmit).not.toHaveBeenCalled(); }); + it("blocks send when the selected model is unavailable on a constrained surface", () => { + const onSubmit = vi.fn(); + const onSubmitBlocked = vi.fn(); + const onSubmitInBackground = vi.fn(); + renderComposer({ + turnActive: false, + draft: "This should not send with a stale model.", + modelId: "openai/retired-model", + availableModelIds: ["openai/gpt-5.4"], + constrainModelSelection: true, + modelUnavailableMessage: "This model is not available in this context.", + onSubmit, + onSubmitBlocked, + onSubmitInBackground, + }); + + expect((screen.getByRole("button", { name: "Send" }) as HTMLButtonElement).disabled).toBe(true); + expect((screen.getByRole("button", { name: "Launch in background" }) as HTMLButtonElement).disabled).toBe(true); + + fireEvent.keyDown(screen.getByRole("textbox"), { key: "Enter" }); + + expect(onSubmit).not.toHaveBeenCalled(); + expect(onSubmitInBackground).not.toHaveBeenCalled(); + expect(onSubmitBlocked).toHaveBeenCalledWith("This model is not available in this context."); + }); + it("keeps the option hint when a pending question includes selectable options", () => { renderComposer({ pendingInput: { diff --git a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx index 8c2eaddc3..5bb56c022 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx @@ -2608,6 +2608,11 @@ export function AgentChatComposer({ captureRichSelection(); }, [captureRichSelection, getRichCursorTextOffset, onDraftChange, serializeRichEditor]); + const singleModelBlockedMessage = (modelUnavailableMessage?.trim() ?? "").length > 0 + ? modelUnavailableMessage + : null; + const singleModelReady = Boolean(modelId) && !singleModelBlockedMessage; + const submitComposerDraft = useCallback(() => { if (pendingInput?.blocking) { return; @@ -2650,12 +2655,12 @@ export function AgentChatComposer({ }); return; } - if (busy || !modelId || (!draft.trim().length && !hasContextSelection && contextAttachmentCount === 0)) { - if (!busy && !modelId) onSubmitBlocked?.(modelUnavailableMessage ?? "Select a model first"); + if (busy || !singleModelReady || (!draft.trim().length && !hasContextSelection && contextAttachmentCount === 0)) { + if (!busy && !singleModelReady) onSubmitBlocked?.(singleModelBlockedMessage ?? "Select a model first"); return; } onSubmit(); - }, [appControlContextItems.length, attachments, builtInBrowserContextItems.length, busy, contextAttachmentCount, contextAttachments, cursorCloudAvailable, cursorCloudCanLaunch, cursorCloudLaunchModeOpen, draft, iosElementContextItems.length, macosVmContextItems.length, modelId, modelUnavailableMessage, onDraftChange, onSubmit, onSubmitBlocked, onSubmitToCloud, pendingImageAttachments.length, pendingInput, parallelChatMode, parallelLaunchBusy, parallelModelSlots.length]); + }, [appControlContextItems.length, attachments, builtInBrowserContextItems.length, busy, contextAttachmentCount, contextAttachments, cursorCloudAvailable, cursorCloudCanLaunch, cursorCloudLaunchModeOpen, draft, iosElementContextItems.length, macosVmContextItems.length, onDraftChange, onSubmit, onSubmitBlocked, onSubmitToCloud, pendingImageAttachments.length, pendingInput, parallelChatMode, parallelLaunchBusy, parallelModelSlots.length, singleModelBlockedMessage, singleModelReady]); const pendingQuestionCount = getPendingInputQuestionCount(pendingInput); const showPendingInputOptionsHint = hasPendingInputOptions(pendingInput); @@ -2713,7 +2718,7 @@ export function AgentChatComposer({ const hasAppControlContext = appControlContextItems.length > 0; const hasBuiltInBrowserContext = builtInBrowserContextItems.length > 0; const hasMacosVmContext = macosVmContextItems.length > 0; - const singleReady = !parallelChatMode && Boolean(modelId) && ( + const singleReady = !parallelChatMode && singleModelReady && ( draft.trim().length > 0 || (allowAttachmentOnlySubmit && attachments.length > 0) || hasIosElementContext @@ -2740,7 +2745,8 @@ export function AgentChatComposer({ if (draft.trim().length === 0 && attachments.length === 0 && contextAttachmentCount === 0) return "Add a message or at least one attachment"; return "Send to all lanes"; } - if (!modelId) return modelUnavailableMessage ?? "Select a model first"; + if (!modelId) return singleModelBlockedMessage ?? "Select a model first"; + if (singleModelBlockedMessage) return singleModelBlockedMessage; if (!draft.trim().length && allowAttachmentOnlySubmit && attachments.length > 0) return "Send attached files"; if (!draft.trim().length && contextAttachmentCount > 0) return "Send attached issue context"; if (!draft.trim().length && hasAppControlContext) return "Send selected App Control context"; diff --git a/apps/desktop/src/renderer/lib/computerUse.test.ts b/apps/desktop/src/renderer/lib/computerUse.test.ts index 3ac703044..a94bc613f 100644 --- a/apps/desktop/src/renderer/lib/computerUse.test.ts +++ b/apps/desktop/src/renderer/lib/computerUse.test.ts @@ -66,6 +66,7 @@ describe("computerUse renderer helpers", () => { artifacts: [{ id: "artifact-1", kind: "screenshot", + backendStyle: "external_cli", backendName: "agent-browser", sourceToolName: null, originalType: null, diff --git a/apps/desktop/src/shared/types/computerUseArtifacts.ts b/apps/desktop/src/shared/types/computerUseArtifacts.ts index 5d0edd6ba..6caec9314 100644 --- a/apps/desktop/src/shared/types/computerUseArtifacts.ts +++ b/apps/desktop/src/shared/types/computerUseArtifacts.ts @@ -21,8 +21,14 @@ export type ComputerUseArtifactLinkRelation = | "produced_by" | "published_to"; +export type ComputerUseBackendStyle = + | "external_cli" + | "manual" + | "local_fallback"; + export type ComputerUseBackendDescriptor = { name: string; + style?: ComputerUseBackendStyle | null; toolName?: string | null; command?: string | null; metadata?: Record | null; @@ -51,6 +57,7 @@ export type ComputerUseArtifactInput = { export type ComputerUseArtifactRecord = { id: string; kind: ComputerUseArtifactKind; + backendStyle: ComputerUseBackendStyle; backendName: string; sourceToolName: string | null; originalType: string | null; diff --git a/scripts/dev-shared.mjs b/scripts/dev-shared.mjs index d56baf2c1..c2c749d90 100644 --- a/scripts/dev-shared.mjs +++ b/scripts/dev-shared.mjs @@ -204,6 +204,25 @@ function createSocket(socketPath) { return net.createConnection(socketPath); } +function isTcpSocketPath(socketPath) { + return socketPath.startsWith("tcp://"); +} + +function canAutoStartRuntime(socketPath) { + if (!isTcpSocketPath(socketPath)) return true; + try { + const parsed = new URL(socketPath); + const host = parsed.hostname.toLowerCase(); + return host === "localhost" + || host === "127.0.0.1" + || host === "::1" + || host === "[::1]" + || host === "0.0.0.0"; + } catch { + return false; + } +} + function readRuntimeInfo(value) { const runtimeInfo = value && typeof value === "object" && !Array.isArray(value) @@ -429,8 +448,8 @@ export async function ensureRuntime(socketPath, projectRoot = null) { const info = await getRuntimeInfo(socketPath); const mismatch = runtimeMismatchReason(info, { projectRoot }); if (!mismatch) return false; - if (socketPath.startsWith("tcp://")) { - throw new Error(`ADE dev runtime at ${socketPath} is stale (${mismatch}), and TCP runtimes cannot be auto-started.`); + if (!canAutoStartRuntime(socketPath)) { + throw new Error(`ADE dev runtime at ${socketPath} is stale (${mismatch}), and only local TCP or Unix-socket runtimes can be auto-started.`); } process.stdout.write(`[ade] restarting stale dev runtime at ${socketPath} (${mismatch})\n`); await shutdownRuntime(socketPath); @@ -439,8 +458,8 @@ export async function ensureRuntime(socketPath, projectRoot = null) { throw error; } } - if (socketPath.startsWith("tcp://")) { - throw new Error(`Cannot auto-start ADE dev runtime on TCP socket ${socketPath}.`); + if (!canAutoStartRuntime(socketPath)) { + throw new Error(`Cannot auto-start ADE dev runtime on remote TCP socket ${socketPath}. Start it with npm run dev:runtime, or use a local TCP/Unix socket for auto mode.`); } process.stdout.write(`[ade] starting dev runtime at ${socketPath}\n`); const child = spawn(process.execPath, [cliPath(), "serve", "--socket", socketPath], { From fb44562dafe1c6dab0c3331c728e03c908437299 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Sun, 24 May 2026 18:15:36 -0400 Subject: [PATCH 3/9] Align constrained model submit tests --- .../components/chat/AgentChatPane.submit.test.tsx | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.submit.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.submit.test.tsx index 443229d15..79886583d 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.submit.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.submit.test.tsx @@ -29,7 +29,7 @@ import { } from "./AgentChatPane"; vi.mock("../terminals/TerminalView", () => { - const ReactMod = require("react") as typeof import("react"); + const ReactMod = require("react") as typeof React; return { TerminalView: (props: { sessionId: string; ptyId: string }) => ReactMod.createElement("div", { "data-testid": "terminal-view" }, `${props.sessionId}:${props.ptyId}`), @@ -37,14 +37,14 @@ vi.mock("../terminals/TerminalView", () => { }); vi.mock("./ChatIosSimulatorPanel", () => { - const ReactMod = require("react") as typeof import("react"); + const ReactMod = require("react") as typeof React; return { ChatIosSimulatorPanel: () => ReactMod.createElement("div", { "data-testid": "ios-panel" }, "iOS panel mounted"), }; }); vi.mock("./ChatAppControlPanel", () => { - const ReactMod = require("react") as typeof import("react"); + const ReactMod = require("react") as typeof React; return { ChatAppControlPanel: () => ReactMod.createElement("div", { "data-testid": "app-control-panel" }, "App Control panel mounted"), }; @@ -883,7 +883,8 @@ describe("AgentChatPane submit recovery", () => { expect(await screen.findByRole("button", { name: new RegExp(`current: ${escapeRegExp(modelLabel)}`, "i") })).toBeTruthy(); const textbox = await screen.findByRole("textbox"); fireEvent.change(textbox, { target: { value: "This must not launch with a stale model." } }); - fireEvent.click(await screen.findByRole("button", { name: "Send" })); + expect((await screen.findByRole("button", { name: "Send" }) as HTMLButtonElement).disabled).toBe(true); + fireEvent.keyDown(textbox, { key: "Enter" }); expect(await screen.findByText("No models are available for this chat surface.")).toBeTruthy(); expect(create).not.toHaveBeenCalled(); @@ -912,7 +913,8 @@ describe("AgentChatPane submit recovery", () => { const textbox = await screen.findByRole("textbox"); fireEvent.change(textbox, { target: { value: "This should not send with a stale model." } }); - fireEvent.click(await screen.findByRole("button", { name: "Send" })); + expect((await screen.findByRole("button", { name: "Send" }) as HTMLButtonElement).disabled).toBe(true); + fireEvent.keyDown(textbox, { key: "Enter" }); expect(await screen.findByText("Select an available model for this chat surface before sending.")).toBeTruthy(); expect(send).not.toHaveBeenCalled(); From 30f99ab735358f44e05cd26b3396b3bbab7ea375 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Sun, 24 May 2026 18:41:14 -0400 Subject: [PATCH 4/9] Guard mission sync step-key matching --- .../aiOrchestratorService.test.ts | 56 +++++++++++++++++++ .../orchestrator/aiOrchestratorService.ts | 13 ++++- 2 files changed, 68 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.test.ts b/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.test.ts index c0bd56452..8d082108d 100644 --- a/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.test.ts +++ b/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.test.ts @@ -7072,6 +7072,62 @@ describe("aiOrchestratorService", () => { } }); + it("does not sync run steps through blank step keys", async () => { + const fixture = await createFixture(); + try { + const mission = fixture.missionService.create({ + prompt: "Avoid blank step key mission step sync.", + laneId: fixture.laneId, + plannedSteps: [ + { + index: 0, + title: "Implement API", + detail: "Blank metadata key from legacy state", + kind: "implementation", + metadata: { stepType: "implementation", stepKey: "" } + } + ] + }); + + const started = fixture.orchestratorService.startRun({ + missionId: mission.id, + steps: [ + { + stepKey: "runtime-step", + title: "Different runtime step", + stepIndex: 0, + dependencyStepKeys: [], + executorKind: "manual", + metadata: { stepType: "implementation" } + } + ] + }); + const runId = started.run.id; + fixture.missionService.update({ + missionId: mission.id, + status: "in_progress", + }); + const failedAt = new Date().toISOString(); + fixture.db.run( + `update orchestrator_runs set status = 'active', updated_at = ? where id = ?`, + [failedAt, runId], + ); + fixture.db.run( + `update orchestrator_steps set step_key = '', status = 'failed', completed_at = ?, updated_at = ? where run_id = ?`, + [failedAt, failedAt, runId], + ); + + await fixture.aiOrchestratorService.syncMissionFromRun(runId, "step_failed"); + + const refreshed = fixture.missionService.get(mission.id); + expect(refreshed?.steps.map((step) => step.status)).toEqual(["pending"]); + expect(refreshed?.status).toBe("in_progress"); + expect(refreshed?.interventions).toHaveLength(0); + } finally { + fixture.dispose(); + } + }); + it("applies steering directives onto active run steps for worker prompt guidance", async () => { const fixture = await createFixture(); try { diff --git a/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.ts b/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.ts index abda203ab..72504a5f4 100644 --- a/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.ts +++ b/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.ts @@ -6974,14 +6974,25 @@ Check all worker statuses and continue managing the mission from here. Read work const mission = missionService.get(graph.run.missionId); if (!mission) return; const missionStepById = new Map(mission.steps.map((step) => [step.id, step])); + const stableString = (value: unknown): string | null => { + if (typeof value !== "string") return null; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; + }; const resolveMissionStepForRunStep = (runStep: OrchestratorRunGraph["steps"][number]) => { if (runStep.missionStepId) { const byId = missionStepById.get(runStep.missionStepId); if (byId) return byId; } + const runStepId = stableString(runStep.id); + const runStepKey = stableString(runStep.stepKey); return mission.steps.find((step) => { const metadata = isRecord(step.metadata) ? step.metadata : null; - return metadata?.orchestratorStepId === runStep.id || metadata?.stepKey === runStep.stepKey; + if (!metadata) return false; + const metadataOrchestratorStepId = stableString(metadata.orchestratorStepId); + if (runStepId && metadataOrchestratorStepId === runStepId) return true; + const metadataStepKey = stableString(metadata.stepKey); + return Boolean(runStepKey && metadataStepKey === runStepKey); }) ?? null; }; From d380ab632b65adfe972abe099a70255181499b5b Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Sun, 24 May 2026 19:15:07 -0400 Subject: [PATCH 5/9] Address runtime review follow-ups --- apps/ade-cli/src/adeRpcServer.test.ts | 51 +++++++++ apps/ade-cli/src/adeRpcServer.ts | 49 ++++++-- apps/ade-cli/src/cli.ts | 17 ++- apps/ade-cli/src/stdioRpcDaemon.test.ts | 7 +- .../config/projectConfigService.test.ts | 108 +++++++++--------- .../main/services/missions/missionService.ts | 10 +- .../aiOrchestratorService.test.ts | 104 ++++++++++++++++- .../orchestrator/aiOrchestratorService.ts | 67 ++++++----- .../services/state/kvDb.migrations.test.ts | 18 ++- .../components/chat/AgentChatPane.tsx | 6 +- .../shared/ModelPicker/ModelPicker.test.tsx | 17 +++ .../shared/ModelPicker/ModelPicker.tsx | 7 +- apps/desktop/vitest.workspace.ts | 5 + 13 files changed, 357 insertions(+), 109 deletions(-) diff --git a/apps/ade-cli/src/adeRpcServer.test.ts b/apps/ade-cli/src/adeRpcServer.test.ts index 1318d9c8e..d8859490d 100644 --- a/apps/ade-cli/src/adeRpcServer.test.ts +++ b/apps/ade-cli/src/adeRpcServer.test.ts @@ -1441,6 +1441,31 @@ describe("adeRpcServer", () => { } }); + it("allows trusted runtimes to serve lower-privilege requested roles", async () => { + await withEnv({ ADE_DEFAULT_ROLE: "cto" }, async () => { + const { runtime } = createRuntime(); + const handler = createAdeRpcRequestHandler({ runtime, serverVersion: "test" }); + + await handler({ + jsonrpc: "2.0", + id: 1, + method: "ade/initialize", + params: { + identity: { + callerId: "agent-client", + role: "agent", + }, + }, + }); + const result = (await handler({ jsonrpc: "2.0", id: 3, method: "ade/actions/list" })) as any; + + const names = (result.actions ?? []).map((tool: any) => tool.name); + expect(names).toContain("delegate_to_subagent"); + expect(names).not.toContain("get_cto_state"); + expect(names).not.toContain("getLinearSyncDashboard"); + }); + }); + it("lists the full tool surface including coordinator orchestration tools for orchestrator callers", async () => { const { runtime } = createRuntime(); const handler = createAdeRpcRequestHandler({ runtime, serverVersion: "test" }); @@ -1592,6 +1617,32 @@ describe("adeRpcServer", () => { ); }); + it("keeps the action list static for the initialized session", async () => { + const fixture = createRuntime(); + const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); + + await initialize(handler, { + callerId: "worker-1", + role: "agent", + missionId: "mission-1", + runId: "run-1", + stepId: "step-1", + attemptId: "attempt-1", + }); + const before = (await handler({ jsonrpc: "2.0", id: 3, method: "ade/actions/list" })) as any; + const beforeNames = (before.actions ?? []).map((tool: any) => tool.name); + expect(beforeNames).toContain("screenshot_environment"); + + fixture.runtime.computerUseArtifactBrokerService.getBackendStatus.mockReturnValue({ + backends: [{ id: "external-proof", available: true }], + }); + + const after = (await handler({ jsonrpc: "2.0", id: 4, method: "ade/actions/list" })) as any; + const afterNames = (after.actions ?? []).map((tool: any) => tool.name); + expect(afterNames).toEqual(beforeNames); + expect(fixture.runtime.computerUseArtifactBrokerService.getBackendStatus).toHaveBeenCalledTimes(1); + }); + it("routes macOS VM computer-use tools and ingests screenshots as proof artifacts", async () => { const fixture = createRuntime(); const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); diff --git a/apps/ade-cli/src/adeRpcServer.ts b/apps/ade-cli/src/adeRpcServer.ts index 778c7b206..7477c7377 100644 --- a/apps/ade-cli/src/adeRpcServer.ts +++ b/apps/ade-cli/src/adeRpcServer.ts @@ -3404,6 +3404,30 @@ function isLocalComputerUseAllowed(callerCtx: CallerContext): boolean { || callerCtx.role === "agent"; } +function canDefaultRoleServeRequestedRole( + defaultRole: SessionIdentity["role"] | null, + requestedRole: SessionIdentity["role"], +): boolean { + if (requestedRole === "external") return true; + if (!defaultRole) return false; + if (defaultRole === "cto") return true; + if (defaultRole === "orchestrator") return requestedRole !== "cto"; + if (defaultRole === "agent") return requestedRole === "agent"; + if (defaultRole === "evaluator") return requestedRole === "evaluator"; + return false; +} + +function resolveSessionRole( + defaultRole: SessionIdentity["role"] | null, + requestedRole: SessionIdentity["role"] | null, +): SessionIdentity["role"] { + if (!defaultRole) return "external"; + if (!requestedRole) return defaultRole; + return canDefaultRoleServeRequestedRole(defaultRole, requestedRole) + ? requestedRole + : defaultRole; +} + async function listToolSpecsForSession(runtime: AdeRuntime, session: SessionState): Promise { const callerCtx = await resolveEffectiveCallerContext(runtime, session); const externalComputerUseAvailable = runtime.computerUseArtifactBrokerService @@ -3438,7 +3462,10 @@ function parseInitializeIdentity(runtime: AdeRuntime, params: unknown): SessionI const data = safeObject(params); const identity = safeObject(data.identity); const envContext = resolveEnvCallerContext(); - const validRole: SessionIdentity["role"] = envContext.role ?? "external"; + const validRole = resolveSessionRole( + envContext.role, + normalizeAdeRuntimeRole(identity.role), + ); const requestedChatSessionId = asOptionalTrimmedString(identity.chatSessionId); const resolvedChatSessionId = envContext.chatSessionId ?? requestedChatSessionId; const resolvedRunId = envContext.runId ?? asOptionalTrimmedString(identity.runId); @@ -7414,13 +7441,18 @@ export function createAdeRpcRequestHandler(args: { } }; - const listActions = async (): Promise> => ({ - actions: (await listToolSpecsForSession(runtime, session)).map((tool) => ({ - name: tool.name, - description: tool.description, - inputSchema: sanitizeToolSchema(tool.inputSchema), - })), - }); + let sessionActionSpecs: ToolSpec[] | null = null; + + const listActions = async (): Promise> => { + const actionSpecs = sessionActionSpecs ?? await listToolSpecsForSession(runtime, session); + return { + actions: actionSpecs.map((tool) => ({ + name: tool.name, + description: tool.description, + inputSchema: sanitizeToolSchema(tool.inputSchema), + })), + }; + }; const callAction = async (actionName: string, actionArgs: Record): Promise => { return await auditActionCall(actionName, actionArgs, async () => { @@ -7450,6 +7482,7 @@ export function createAdeRpcRequestHandler(args: { session.initialized = true; session.protocolVersion = asOptionalTrimmedString(params.protocolVersion) ?? DEFAULT_PROTOCOL_VERSION; session.identity = parseInitializeIdentity(runtime, params); + sessionActionSpecs = await listToolSpecsForSession(runtime, session); const resourcesEnabled = session.identity.role !== "orchestrator"; return { protocolVersion: session.protocolVersion, diff --git a/apps/ade-cli/src/cli.ts b/apps/ade-cli/src/cli.ts index fc2af7ab4..492df6757 100644 --- a/apps/ade-cli/src/cli.ts +++ b/apps/ade-cli/src/cli.ts @@ -10784,6 +10784,19 @@ function readMachineRuntimeInfo(value: unknown): MachineRuntimeInfo { }; } +function canRuntimeDefaultRoleServe( + defaultRole: MachineRuntimeInfo["defaultRole"], + requestedRole: GlobalOptions["role"], +): boolean { + if (requestedRole === "external") return true; + if (!defaultRole) return false; + if (defaultRole === "cto") return true; + if (defaultRole === "orchestrator") return requestedRole !== "cto"; + if (defaultRole === "agent") return requestedRole === "agent"; + if (defaultRole === "evaluator") return requestedRole === "evaluator"; + return false; +} + function machineRuntimeMismatchReason( runtimeInfo: MachineRuntimeInfo, expectedBuildHash: string | null, @@ -10806,8 +10819,8 @@ function machineRuntimeMismatchReason( if (expectedBuildHash && runtimeInfo.buildHash !== expectedBuildHash) { return runtimeInfo.buildHash ? "build hash changed" : "build hash missing"; } - if (runtimeInfo.defaultRole !== expectedDefaultRole) { - return `default role ${runtimeInfo.defaultRole ?? "missing"} does not match CLI role ${expectedDefaultRole}`; + if (!canRuntimeDefaultRoleServe(runtimeInfo.defaultRole, expectedDefaultRole)) { + return `default role ${runtimeInfo.defaultRole ?? "missing"} cannot serve CLI role ${expectedDefaultRole}`; } return null; } diff --git a/apps/ade-cli/src/stdioRpcDaemon.test.ts b/apps/ade-cli/src/stdioRpcDaemon.test.ts index ef5810746..e850dadae 100644 --- a/apps/ade-cli/src/stdioRpcDaemon.test.ts +++ b/apps/ade-cli/src/stdioRpcDaemon.test.ts @@ -362,7 +362,7 @@ describe("ade rpc --stdio daemon bridge", () => { } }, 45_000); - itUnix("restarts a same-version daemon when its default role is stale", async () => { + itUnix("keeps a compatible cto daemon when the proxy requests an agent role", async () => { const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const cliPath = path.join(packageRoot, "src", "cli.ts"); const adeHome = fs.mkdtempSync(path.join(os.tmpdir(), "ade-stdio-rpc-role-")); @@ -379,7 +379,7 @@ describe("ade rpc --stdio daemon bridge", () => { cwd: packageRoot, env: { ...baseEnv, - ADE_DEFAULT_ROLE: "external", + ADE_DEFAULT_ROLE: "cto", ADE_RUNTIME_BUILD_HASH: fileSha256(cliPath), }, socketPath, @@ -406,10 +406,11 @@ describe("ade rpc --stdio daemon bridge", () => { expect(initialize).toMatchObject({ runtimeInfo: { version: "2.0.0", - defaultRole: "agent", + defaultRole: "cto", multiProject: true, }, }); + expect((initialize as { runtimeInfo?: { pid?: number | null } }).runtimeInfo?.pid).toBe(oldDaemon.pid); await expect(proxy.request("shutdown")).resolves.toEqual({}); proxy.closeInput(); diff --git a/apps/desktop/src/main/services/config/projectConfigService.test.ts b/apps/desktop/src/main/services/config/projectConfigService.test.ts index e1bfead31..e0f3133e0 100644 --- a/apps/desktop/src/main/services/config/projectConfigService.test.ts +++ b/apps/desktop/src/main/services/config/projectConfigService.test.ts @@ -596,68 +596,70 @@ describe("projectConfigService - linear sync", () => { it("merges shared/local linear sync config with local precedence", async () => { const fixture = await createLinearFixture(); - - fixture.service.save({ - shared: { - linearSync: { - enabled: true, - pollingIntervalSec: 300, - projects: [{ slug: "acme-platform" }], - routing: { byLabel: { bug: "backend-dev" } }, - autoDispatch: { - default: "escalate", - rules: [{ id: "rule-shared", action: "auto", match: { labels: ["bug"] } }], + try { + fixture.service.save({ + shared: { + linearSync: { + enabled: true, + pollingIntervalSec: 300, + projects: [{ slug: "acme-platform" }], + routing: { byLabel: { bug: "backend-dev" } }, + autoDispatch: { + default: "escalate", + rules: [{ id: "rule-shared", action: "auto", match: { labels: ["bug"] } }], + }, }, }, - }, - local: { - linearSync: { - pollingIntervalSec: 120, - routing: { byLabel: { feature: "frontend-dev" } }, - autoDispatch: { - rules: [{ id: "rule-local", action: "escalate", match: { labels: ["night"] } }], + local: { + linearSync: { + pollingIntervalSec: 120, + routing: { byLabel: { feature: "frontend-dev" } }, + autoDispatch: { + rules: [{ id: "rule-local", action: "escalate", match: { labels: ["night"] } }], + }, }, }, - }, - }); - - const effective = fixture.service.getEffective(); - expect(effective.linearSync?.enabled).toBe(true); - expect(effective.linearSync?.pollingIntervalSec).toBe(120); - expect(effective.linearSync?.routing?.byLabel).toEqual({ - bug: "backend-dev", - feature: "frontend-dev", - }); - expect(effective.linearSync?.autoDispatch?.default).toBe("escalate"); - expect(effective.linearSync?.autoDispatch?.rules).toEqual([ - { - id: "rule-local", - action: "escalate", - match: { labels: ["night"] }, - }, - ]); - - fixture.db.close(); + }); + + const effective = fixture.service.getEffective(); + expect(effective.linearSync?.enabled).toBe(true); + expect(effective.linearSync?.pollingIntervalSec).toBe(120); + expect(effective.linearSync?.routing?.byLabel).toEqual({ + bug: "backend-dev", + feature: "frontend-dev", + }); + expect(effective.linearSync?.autoDispatch?.default).toBe("escalate"); + expect(effective.linearSync?.autoDispatch?.rules).toEqual([ + { + id: "rule-local", + action: "escalate", + match: { labels: ["night"] }, + }, + ]); + } finally { + fixture.db.close(); + } }); it("clamps linear sync confidence threshold to valid range", async () => { const fixture = await createLinearFixture(); - - fixture.service.save({ - shared: { - linearSync: { - enabled: true, - projects: [{ slug: "acme-platform" }], - classification: { mode: "hybrid", confidenceThreshold: 1.4 }, + try { + fixture.service.save({ + shared: { + linearSync: { + enabled: true, + projects: [{ slug: "acme-platform" }], + classification: { mode: "hybrid", confidenceThreshold: 1.4 }, + }, }, - }, - local: {}, - }); - - const effective = fixture.service.getEffective(); - expect(effective.linearSync?.classification?.confidenceThreshold).toBe(1); - - fixture.db.close(); + local: {}, + }); + + const effective = fixture.service.getEffective(); + expect(effective.linearSync?.classification?.confidenceThreshold).toBe(1); + } finally { + fixture.db.close(); + } }); }); diff --git a/apps/desktop/src/main/services/missions/missionService.ts b/apps/desktop/src/main/services/missions/missionService.ts index a39adc188..9033ede99 100644 --- a/apps/desktop/src/main/services/missions/missionService.ts +++ b/apps/desktop/src/main/services/missions/missionService.ts @@ -4030,12 +4030,20 @@ export function createMissionService({ }); if (next === "failed") { + const stepMetadata = safeParseRecord(step.metadata_json); + const stepKey = typeof stepMetadata?.stepKey === "string" && stepMetadata.stepKey.trim() + ? stepMetadata.stepKey.trim() + : null; const intervention = insertIntervention({ missionId, interventionType: "failed_step", title: `Step failed: ${step.title}`, body: note ?? "A mission step was marked as failed and needs attention.", - requestedAction: "Review the failure and decide whether to continue, retry, or cancel." + requestedAction: "Review the failure and decide whether to continue, retry, or cancel.", + metadata: { + stepId, + ...(stepKey ? { stepKey } : {}), + }, }); db.run( diff --git a/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.test.ts b/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.test.ts index 8d082108d..b01c9b4ff 100644 --- a/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.test.ts +++ b/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.test.ts @@ -6978,13 +6978,25 @@ describe("aiOrchestratorService", () => { const missionStep = fixture.missionService.get(mission.id)?.steps[0]; expect(missionStep?.id).toBeTruthy(); - const launch = await fixture.aiOrchestratorService.startMissionRun({ + const started = fixture.orchestratorService.startRun({ missionId: mission.id, - runMode: "manual", - defaultExecutorKind: "manual" + steps: [ + { + stepKey: "implement-risky-change", + title: missionStep!.title, + stepIndex: 0, + dependencyStepKeys: [], + executorKind: "manual", + missionStepId: missionStep!.id, + metadata: { stepType: "implementation" } + } + ] + }); + const runId = started.run.id; + fixture.missionService.update({ + missionId: mission.id, + status: "in_progress", }); - if (!launch.started) throw new Error("Expected mission run to start"); - const runId = launch.started.run.id; const failedAt = new Date().toISOString(); fixture.db.run( `update orchestrator_runs set status = 'failed', updated_at = ? where id = ?`, @@ -7007,6 +7019,88 @@ describe("aiOrchestratorService", () => { } }); + it("opens terminal failed-step interventions for each matched failed mission step", async () => { + const fixture = await createFixture(); + try { + const mission = fixture.missionService.create({ + prompt: "Keep failed-step interventions targeted.", + laneId: fixture.laneId, + plannedSteps: [ + { + index: 0, + title: "Implement API", + detail: "First failure", + kind: "implementation", + metadata: { stepType: "implementation", stepKey: "first-step" } + }, + { + index: 1, + title: "Validate API", + detail: "Second failure", + kind: "test", + metadata: { stepType: "test", stepKey: "second-step" } + } + ] + }); + const missionSteps = fixture.missionService.get(mission.id)?.steps ?? []; + const firstStep = missionSteps[0]; + const secondStep = missionSteps[1]; + if (!firstStep || !secondStep) throw new Error("Expected mission steps"); + fixture.missionService.update({ + missionId: mission.id, + status: "in_progress", + }); + fixture.missionService.addIntervention({ + missionId: mission.id, + interventionType: "failed_step", + title: "Step failed: Implement API", + body: "Existing first-step intervention.", + requestedAction: "Review the failure.", + metadata: { + stepId: firstStep.id, + stepKey: "first-step", + orchestratorStepId: "old-run-step", + reasonCode: "terminal_run_failed_step", + }, + }); + const started = fixture.orchestratorService.startRun({ + missionId: mission.id, + steps: [ + { + stepKey: "second-step", + title: secondStep.title, + stepIndex: 0, + dependencyStepKeys: [], + executorKind: "manual", + missionStepId: secondStep.id, + metadata: { stepType: "test", stepKey: "second-step" } + } + ] + }); + const runId = started.run.id; + const failedAt = new Date().toISOString(); + fixture.db.run( + `update orchestrator_runs set status = 'failed', updated_at = ? where id = ?`, + [failedAt, runId], + ); + fixture.db.run( + `update orchestrator_steps set status = 'failed', completed_at = ?, updated_at = ? where run_id = ?`, + [failedAt, failedAt, runId], + ); + + await fixture.aiOrchestratorService.syncMissionFromRun(runId, "run_failed"); + + const refreshed = fixture.missionService.get(mission.id); + const failedStepInterventions = refreshed?.interventions.filter((entry) => + entry.status === "open" && entry.interventionType === "failed_step" + ) ?? []; + expect(failedStepInterventions).toHaveLength(2); + expect(failedStepInterventions.some((entry) => entry.metadata?.stepId === secondStep.id)).toBe(true); + } finally { + fixture.dispose(); + } + }); + it("does not sync run steps to duplicate mission titles without a stable link", async () => { const fixture = await createFixture(); try { diff --git a/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.ts b/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.ts index 72504a5f4..1df8cb6c3 100644 --- a/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.ts +++ b/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.ts @@ -6970,34 +6970,40 @@ Check all worker statuses and continue managing the mission from here. Read work return interventionId; }; + const stableString = (value: unknown): string | null => { + if (typeof value !== "string") return null; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; + }; + + const resolveMissionStepForRunStep = ( + mission: MissionDetail, + runStep: OrchestratorRunGraph["steps"][number], + missionStepById: Map = new Map(mission.steps.map((step) => [step.id, step])), + ): MissionDetail["steps"][number] | null => { + if (runStep.missionStepId) { + const byId = missionStepById.get(runStep.missionStepId); + if (byId) return byId; + } + const runStepId = stableString(runStep.id); + const runStepKey = stableString(runStep.stepKey); + return mission.steps.find((step) => { + const metadata = isRecord(step.metadata) ? step.metadata : null; + if (!metadata) return false; + const metadataOrchestratorStepId = stableString(metadata.orchestratorStepId); + if (runStepId && metadataOrchestratorStepId === runStepId) return true; + const metadataStepKey = stableString(metadata.stepKey); + return Boolean(runStepKey && metadataStepKey === runStepKey); + }) ?? null; + }; + const syncMissionStepsFromRun = (graph: OrchestratorRunGraph) => { const mission = missionService.get(graph.run.missionId); if (!mission) return; const missionStepById = new Map(mission.steps.map((step) => [step.id, step])); - const stableString = (value: unknown): string | null => { - if (typeof value !== "string") return null; - const trimmed = value.trim(); - return trimmed.length > 0 ? trimmed : null; - }; - const resolveMissionStepForRunStep = (runStep: OrchestratorRunGraph["steps"][number]) => { - if (runStep.missionStepId) { - const byId = missionStepById.get(runStep.missionStepId); - if (byId) return byId; - } - const runStepId = stableString(runStep.id); - const runStepKey = stableString(runStep.stepKey); - return mission.steps.find((step) => { - const metadata = isRecord(step.metadata) ? step.metadata : null; - if (!metadata) return false; - const metadataOrchestratorStepId = stableString(metadata.orchestratorStepId); - if (runStepId && metadataOrchestratorStepId === runStepId) return true; - const metadataStepKey = stableString(metadata.stepKey); - return Boolean(runStepKey && metadataStepKey === runStepKey); - }) ?? null; - }; for (const runStep of graph.steps) { - const missionStep = resolveMissionStepForRunStep(runStep); + const missionStep = resolveMissionStepForRunStep(mission, runStep, missionStepById); if (!missionStep) continue; const nextStatus = mapOrchestratorStepStatus(runStep.status); if (missionStep.status === nextStatus) continue; @@ -7048,16 +7054,21 @@ Check all worker statuses and continue managing the mission from here. Read work if (graph.run.status !== "failed") return; const mission = missionService.get(graph.run.missionId); if (!mission) return; - if (mission.interventions.some((entry) => entry.status === "open" && entry.interventionType === "failed_step")) { - return; - } - const missionStepById = new Map(mission.steps.map((step) => [step.id, step])); const failedRunStep = graph.steps.find((step) => step.status === "failed"); - const failedMissionStep = failedRunStep?.missionStepId - ? missionStepById.get(failedRunStep.missionStepId) ?? null + const missionStepById = new Map(mission.steps.map((step) => [step.id, step])); + const failedMissionStep = failedRunStep + ? resolveMissionStepForRunStep(mission, failedRunStep, missionStepById) : mission.steps.find((step) => step.status === "failed") ?? null; if (!failedRunStep && !failedMissionStep) return; + const hasMatchingOpenIntervention = mission.interventions.some((entry) => { + if (entry.status !== "open" || entry.interventionType !== "failed_step") return false; + const metadata = isRecord(entry.metadata) ? entry.metadata : null; + if (!metadata) return false; + if (failedMissionStep?.id && metadata.stepId === failedMissionStep.id) return true; + return Boolean(!failedMissionStep && failedRunStep?.id && metadata.orchestratorStepId === failedRunStep.id); + }); + if (hasMatchingOpenIntervention) return; const failedStepTitle = failedMissionStep?.title ?? failedRunStep?.title ?? "Run step"; missionService.addIntervention({ diff --git a/apps/desktop/src/main/services/state/kvDb.migrations.test.ts b/apps/desktop/src/main/services/state/kvDb.migrations.test.ts index 2d3171723..8d20ba800 100644 --- a/apps/desktop/src/main/services/state/kvDb.migrations.test.ts +++ b/apps/desktop/src/main/services/state/kvDb.migrations.test.ts @@ -58,6 +58,7 @@ describe("kvDb migrations - orchestrator schema bootstrap", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-kvdb-orchestrator-")); const dbPath = path.join(root, "ade.db"); const db = await openKvDb(dbPath, createLogger()); + try { const expectedTables = [ "orchestrator_runs", @@ -404,8 +405,9 @@ describe("kvDb migrations - orchestrator schema bootstrap", () => { "select sql from sqlite_master where type = 'index' and name = 'idx_orchestrator_claims_active_scope' limit 1", ); expect((activeScopeSql?.sql ?? "").toLowerCase()).toContain("where state = 'active'"); - - db.close(); + } finally { + db.close(); + } }); }); @@ -606,6 +608,7 @@ describe("kvDb migrations - mission schema", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-kvdb-missions-")); const dbPath = path.join(root, "ade.db"); const db = await openKvDb(dbPath, createLogger()); + try { const expectedTables = [ "missions", @@ -673,8 +676,9 @@ describe("kvDb migrations - mission schema", () => { ]; expectIndexes(db, expectedIndexes); - - db.close(); + } finally { + db.close(); + } }); }); @@ -683,6 +687,7 @@ describe("kvDb migrations - worker agent schema", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-kvdb-workers-")); const dbPath = path.join(root, "ade.db"); const db = await openKvDb(dbPath, createLogger()); + try { const expectedTables = [ "worker_agents", @@ -796,7 +801,8 @@ describe("kvDb migrations - worker agent schema", () => { ]; expectIndexes(db, expectedIndexes); - - db.close(); + } finally { + db.close(); + } }); }); diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx index 1b79f2c62..c089fd3d3 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx @@ -4837,7 +4837,7 @@ export function AgentChatPane({ if (!laneId) return null; if (constrainedModelSelectionError) { setError(constrainedModelSelectionError); - return null; + throw new Error(constrainedModelSelectionError); } const createPromise = createSessionForLane(laneId, { select: true, notify: true }) .then((created) => created.id); @@ -5256,7 +5256,9 @@ export function AgentChatPane({ if (selectedSessionId || lockSessionId || initialSessionId) return; if (forceDraft) return; eagerCreateFiredRef.current = true; - void createSession(); + void createSession().catch(() => { + eagerCreateFiredRef.current = false; + }); }, [preferencesReady, laneId, modelId, selectedSessionId, lockSessionId, initialSessionId, forceDraft, createSession]); const submit = useCallback(async () => { diff --git a/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPicker.test.tsx b/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPicker.test.tsx index 54ed7c4f8..e01e4e236 100644 --- a/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPicker.test.tsx +++ b/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPicker.test.tsx @@ -301,6 +301,23 @@ describe("ModelPicker", () => { expect(visibleIds).not.toContain(SONNET.id); }); + it("filters provided models when constrained to available ids", async () => { + const user = userEvent.setup(); + renderPicker({ + constrainToAvailableModelIds: true, + availableModelIds: [SONNET.id], + }); + + await user.click(screen.getByRole("button", { name: /Select model/i })); + + const visibleIds = screen + .getAllByRole("option") + .map((el) => el.getAttribute("data-model-id")); + expect(visibleIds).toContain(SONNET.id); + expect(visibleIds).not.toContain(OPUS.id); + expect(visibleIds).not.toContain(GPT.id); + }); + it("toggles favorites when the star button is clicked", async () => { const user = userEvent.setup(); renderPicker(); diff --git a/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPicker.tsx b/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPicker.tsx index e1b86a19a..78141ed44 100644 --- a/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPicker.tsx +++ b/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPicker.tsx @@ -162,7 +162,12 @@ export const ModelPicker = memo(function ModelPicker({ ); const modelList = useMemo(() => { - if (models && models.length) return models; + if (models && models.length) { + if (!constrainToAvailableModelIds) return models; + const available = new Set((availableModelIds ?? []).map((id) => id.trim()).filter(Boolean)); + const constrainedModels = models.filter((model) => available.has(model.id.trim())); + if (constrainedModels.length > 0) return constrainedModels; + } const selectedValue = (() => { if (!constrainToAvailableModelIds) return value; const normalizedValue = value.trim(); diff --git a/apps/desktop/vitest.workspace.ts b/apps/desktop/vitest.workspace.ts index 9e57b92b8..d50e55204 100644 --- a/apps/desktop/vitest.workspace.ts +++ b/apps/desktop/vitest.workspace.ts @@ -33,6 +33,8 @@ const sharedResolveAlias = [ { find: "lottie-react", replacement: lottieReactStub }, ]; +const unitExcludes = ["**/*.integration.test.{ts,tsx}"]; + export default defineWorkspace([ { resolve: { alias: sharedResolveAlias }, @@ -40,6 +42,7 @@ export default defineWorkspace([ ...shared, name: "unit-main", include: ["src/main/**/*.test.{ts,tsx}"], + exclude: unitExcludes, }, }, { @@ -48,6 +51,7 @@ export default defineWorkspace([ ...shared, name: "unit-renderer", include: ["src/renderer/**/*.test.{ts,tsx}"], + exclude: unitExcludes, }, }, { @@ -59,6 +63,7 @@ export default defineWorkspace([ "src/shared/**/*.test.{ts,tsx}", "src/preload/**/*.test.{ts,tsx}", ], + exclude: unitExcludes, }, }, ]); From 7a92f249d3a4648be88001679a6e0f53badd574e Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Sun, 24 May 2026 19:26:38 -0400 Subject: [PATCH 6/9] Restore dynamic RPC action lists --- apps/ade-cli/src/adeRpcServer.test.ts | 8 ++++---- apps/ade-cli/src/adeRpcServer.ts | 7 ++----- apps/ade-cli/src/multiProjectRpcServer.test.ts | 5 ++++- apps/ade-cli/src/multiProjectRpcServer.ts | 2 +- 4 files changed, 11 insertions(+), 11 deletions(-) diff --git a/apps/ade-cli/src/adeRpcServer.test.ts b/apps/ade-cli/src/adeRpcServer.test.ts index d8859490d..2972c9e87 100644 --- a/apps/ade-cli/src/adeRpcServer.test.ts +++ b/apps/ade-cli/src/adeRpcServer.test.ts @@ -1617,7 +1617,7 @@ describe("adeRpcServer", () => { ); }); - it("keeps the action list static for the initialized session", async () => { + it("reflects backend availability changes in the action list", async () => { const fixture = createRuntime(); const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); @@ -1639,8 +1639,8 @@ describe("adeRpcServer", () => { const after = (await handler({ jsonrpc: "2.0", id: 4, method: "ade/actions/list" })) as any; const afterNames = (after.actions ?? []).map((tool: any) => tool.name); - expect(afterNames).toEqual(beforeNames); - expect(fixture.runtime.computerUseArtifactBrokerService.getBackendStatus).toHaveBeenCalledTimes(1); + expect(afterNames).not.toContain("screenshot_environment"); + expect(fixture.runtime.computerUseArtifactBrokerService.getBackendStatus).toHaveBeenCalledTimes(2); }); it("routes macOS VM computer-use tools and ingests screenshots as proof artifacts", async () => { @@ -2415,7 +2415,7 @@ describe("adeRpcServer", () => { params: { identity: { callerId: "coord-1", role: "orchestrator" } } }) as any; - expect(response.capabilities?.actions).toBeTruthy(); + expect(response.capabilities?.actions).toEqual({ listChanged: true }); expect(response.capabilities?.resources).toBeUndefined(); } finally { if (previousRole == null) delete process.env.ADE_DEFAULT_ROLE; diff --git a/apps/ade-cli/src/adeRpcServer.ts b/apps/ade-cli/src/adeRpcServer.ts index 7477c7377..653365b96 100644 --- a/apps/ade-cli/src/adeRpcServer.ts +++ b/apps/ade-cli/src/adeRpcServer.ts @@ -7441,10 +7441,8 @@ export function createAdeRpcRequestHandler(args: { } }; - let sessionActionSpecs: ToolSpec[] | null = null; - const listActions = async (): Promise> => { - const actionSpecs = sessionActionSpecs ?? await listToolSpecsForSession(runtime, session); + const actionSpecs = await listToolSpecsForSession(runtime, session); return { actions: actionSpecs.map((tool) => ({ name: tool.name, @@ -7482,7 +7480,6 @@ export function createAdeRpcRequestHandler(args: { session.initialized = true; session.protocolVersion = asOptionalTrimmedString(params.protocolVersion) ?? DEFAULT_PROTOCOL_VERSION; session.identity = parseInitializeIdentity(runtime, params); - sessionActionSpecs = await listToolSpecsForSession(runtime, session); const resourcesEnabled = session.identity.role !== "orchestrator"; return { protocolVersion: session.protocolVersion, @@ -7502,7 +7499,7 @@ export function createAdeRpcRequestHandler(args: { }, capabilities: { actions: { - listChanged: false + listChanged: true }, ...(resourcesEnabled ? { diff --git a/apps/ade-cli/src/multiProjectRpcServer.test.ts b/apps/ade-cli/src/multiProjectRpcServer.test.ts index f5f68cf0a..3f43417d1 100644 --- a/apps/ade-cli/src/multiProjectRpcServer.test.ts +++ b/apps/ade-cli/src/multiProjectRpcServer.test.ts @@ -153,7 +153,10 @@ describe("multi-project RPC server", () => { }); expect(init).toMatchObject({ runtimeInfo: { multiProject: true }, - capabilities: { projects: true }, + capabilities: { + actions: { listChanged: true }, + projects: true, + }, }); const actions = await handler({ diff --git a/apps/ade-cli/src/multiProjectRpcServer.ts b/apps/ade-cli/src/multiProjectRpcServer.ts index 795a90d21..723814baf 100644 --- a/apps/ade-cli/src/multiProjectRpcServer.ts +++ b/apps/ade-cli/src/multiProjectRpcServer.ts @@ -413,7 +413,7 @@ export function createMultiProjectRpcRequestHandler( }, capabilities: { actions: { - listChanged: false, + listChanged: true, }, projects: true, machineProjects: { From 493ba96dd6c23f4efb134ae69b1dfdb4ed806c56 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Sun, 24 May 2026 19:38:47 -0400 Subject: [PATCH 7/9] Allow TCP runtime daemons without build hashes --- apps/ade-cli/src/cli.ts | 7 +- apps/ade-cli/src/stdioRpcDaemon.test.ts | 117 ++++++++++++++++++++++-- 2 files changed, 113 insertions(+), 11 deletions(-) diff --git a/apps/ade-cli/src/cli.ts b/apps/ade-cli/src/cli.ts index 492df6757..3f0e26fcf 100644 --- a/apps/ade-cli/src/cli.ts +++ b/apps/ade-cli/src/cli.ts @@ -10927,7 +10927,10 @@ async function connectMachineRuntimeDaemon( const socketPath = await resolveMachineRuntimeSocketPath(socketPathOverride); const label = "ADE runtime daemon socket"; const allowSpawn = connectOptions.allowSpawn ?? !options.requireSocket; - const expectedBuildHash = await resolveExpectedMachineRuntimeBuildHash(); + const isTcpSocket = socketPath.startsWith("tcp://"); + const expectedBuildHash = isTcpSocket + ? null + : await resolveExpectedMachineRuntimeBuildHash(); try { const client = await SocketJsonRpcClient.connect( socketPath, @@ -10944,7 +10947,7 @@ async function connectMachineRuntimeDaemon( options.role, ); if (mismatch) { - if (!allowSpawn || socketPath.startsWith("tcp://")) { + if (!allowSpawn || isTcpSocket) { client.close(); throw new Error( `ADE runtime daemon ${mismatch}.`, diff --git a/apps/ade-cli/src/stdioRpcDaemon.test.ts b/apps/ade-cli/src/stdioRpcDaemon.test.ts index e850dadae..da9f419ec 100644 --- a/apps/ade-cli/src/stdioRpcDaemon.test.ts +++ b/apps/ade-cli/src/stdioRpcDaemon.test.ts @@ -30,16 +30,42 @@ function fileSha256(filePath: string): string { return createHash("sha256").update(fs.readFileSync(filePath)).digest("hex"); } -async function waitForSocket(socketPath: string, timeoutMs = 10_000): Promise { +async function getFreeTcpPort(): Promise { + const server = net.createServer(); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + server.off("error", reject); + resolve(); + }); + }); + const address = server.address(); + await new Promise((resolve, reject) => { + server.close((error) => { + if (error) reject(error); + else resolve(); + }); + }); + if (!address || typeof address === "string") { + throw new Error("Unable to allocate a TCP port for test."); + } + return address.port; +} + +async function waitForConnection( + label: string, + connect: () => net.Socket, + timeoutMs = 10_000, +): Promise { const startedAt = Date.now(); let lastError: Error | null = null; while (Date.now() - startedAt < timeoutMs) { try { await new Promise((resolve, reject) => { - const socket = net.createConnection(socketPath); + const socket = connect(); const timer = setTimeout(() => { socket.destroy(); - reject(new Error(`Timed out connecting to ${socketPath}`)); + reject(new Error(`Timed out connecting to ${label}`)); }, 500); socket.once("connect", () => { clearTimeout(timer); @@ -58,7 +84,18 @@ async function waitForSocket(socketPath: string, timeoutMs = 10_000): Promise setTimeout(resolve, 100)); } } - throw lastError ?? new Error(`ADE runtime socket did not become available: ${socketPath}`); + throw lastError ?? new Error(`ADE runtime socket did not become available: ${label}`); +} + +async function waitForSocket(socketPath: string, timeoutMs = 10_000): Promise { + await waitForConnection(socketPath, () => net.createConnection(socketPath), timeoutMs); +} + +async function waitForTcpUrl(tcpUrl: string, timeoutMs = 10_000): Promise { + const parsed = new URL(tcpUrl); + const port = Number.parseInt(parsed.port, 10); + const host = parsed.hostname; + await waitForConnection(tcpUrl, () => net.createConnection({ host, port }), timeoutMs); } function startServeProcess(args: { @@ -66,12 +103,17 @@ function startServeProcess(args: { cwd: string; env: NodeJS.ProcessEnv; socketPath: string; + extraArgs?: string[]; }): ChildProcessWithoutNullStreams { - return spawn(process.execPath, [args.cliPath, "serve", "--socket", args.socketPath, "--no-sync"], { - cwd: args.cwd, - env: args.env, - stdio: ["pipe", "pipe", "pipe"], - }); + return spawn( + process.execPath, + [args.cliPath, "serve", "--socket", args.socketPath, "--no-sync", ...(args.extraArgs ?? [])], + { + cwd: args.cwd, + env: args.env, + stdio: ["pipe", "pipe", "pipe"], + }, + ); } class StdioRpcProcess { @@ -362,6 +404,63 @@ describe("ade rpc --stdio daemon bridge", () => { } }, 45_000); + itUnix("accepts a compatible TCP daemon without a build hash", async () => { + const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); + const cliPath = path.join(packageRoot, "src", "cli.ts"); + const adeHome = fs.mkdtempSync(path.join(os.tmpdir(), "ade-stdio-rpc-tcp-build-")); + const socketPath = path.join(adeHome, "sock", "ade.sock"); + const tcpPort = await getFreeTcpPort(); + const tcpUrl = `tcp://127.0.0.1:${tcpPort}`; + const baseEnv = { + ...process.env, + ADE_HOME: adeHome, + NODE_OPTIONS: withTsxNodeOptions(process.env.NODE_OPTIONS), + ADE_CLI_VERSION: "2.0.0", + }; + const tcpDaemon = startServeProcess({ + cliPath, + cwd: packageRoot, + env: baseEnv, + socketPath, + extraArgs: ["--port", String(tcpPort)], + }); + + let proxy: StdioRpcProcess | null = null; + try { + await Promise.all([waitForSocket(socketPath), waitForTcpUrl(tcpUrl)]); + + proxy = StdioRpcProcess.start({ + cliPath, + cwd: packageRoot, + env: { + ...baseEnv, + ADE_RUNTIME_SOCKET_PATH: tcpUrl, + }, + }); + const initialize = await proxy.request("ade/initialize", { + protocolVersion: "2025-06-18", + clientName: "stdio-daemon-tcp-build-test", + identity: { role: "external", callerId: "stdio-daemon-tcp-build-test" }, + }); + + expect(initialize).toMatchObject({ + runtimeInfo: { + version: "2.0.0", + buildHash: null, + multiProject: true, + pid: tcpDaemon.pid, + }, + }); + + await expect(proxy.request("shutdown")).resolves.toEqual({}); + proxy.closeInput(); + await expect(proxy.waitForExit()).resolves.toMatchObject({ code: 0, signal: null }); + } finally { + proxy?.kill(); + if (!tcpDaemon.killed) tcpDaemon.kill(); + } + }, 45_000); + itUnix("keeps a compatible cto daemon when the proxy requests an agent role", async () => { const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const cliPath = path.join(packageRoot, "src", "cli.ts"); From 045c8525d251cd58784940db0b86b31610091381 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Sun, 24 May 2026 20:00:03 -0400 Subject: [PATCH 8/9] Validate runtime roles for source CLI attach --- apps/ade-cli/src/cli.ts | 12 +++-- apps/ade-cli/src/stdioRpcDaemon.test.ts | 59 +++++++++++++++++++++++++ 2 files changed, 68 insertions(+), 3 deletions(-) diff --git a/apps/ade-cli/src/cli.ts b/apps/ade-cli/src/cli.ts index 3f0e26fcf..ff175fba5 100644 --- a/apps/ade-cli/src/cli.ts +++ b/apps/ade-cli/src/cli.ts @@ -10803,6 +10803,10 @@ function machineRuntimeMismatchReason( expectedDefaultRole: GlobalOptions["role"], ): string | null { const runtimeVersion = runtimeInfo.version; + const sourceCliTalkingToReleasedRuntime = + VERSION === PLACEHOLDER_VERSION && + Boolean(runtimeVersion) && + runtimeVersion !== PLACEHOLDER_VERSION; if (VERSION !== PLACEHOLDER_VERSION) { const versionMatches = runtimeVersion === VERSION; const placeholderBuildMatches = @@ -10812,11 +10816,13 @@ function machineRuntimeMismatchReason( if (!versionMatches && !placeholderBuildMatches) { return `version ${runtimeVersion ?? "missing"} does not match CLI version ${VERSION}`; } - } else if (runtimeVersion && runtimeVersion !== PLACEHOLDER_VERSION) { - return null; } - if (expectedBuildHash && runtimeInfo.buildHash !== expectedBuildHash) { + if ( + !sourceCliTalkingToReleasedRuntime && + expectedBuildHash && + runtimeInfo.buildHash !== expectedBuildHash + ) { return runtimeInfo.buildHash ? "build hash changed" : "build hash missing"; } if (!canRuntimeDefaultRoleServe(runtimeInfo.defaultRole, expectedDefaultRole)) { diff --git a/apps/ade-cli/src/stdioRpcDaemon.test.ts b/apps/ade-cli/src/stdioRpcDaemon.test.ts index da9f419ec..759e758e1 100644 --- a/apps/ade-cli/src/stdioRpcDaemon.test.ts +++ b/apps/ade-cli/src/stdioRpcDaemon.test.ts @@ -573,4 +573,63 @@ describe("ade rpc --stdio daemon bridge", () => { if (!realDaemon.killed) realDaemon.kill(); } }, 45_000); + + itUnix("restarts an incompatible-role daemon even when the proxy has only the placeholder version", async () => { + const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); + const cliPath = path.join(packageRoot, "src", "cli.ts"); + const adeHome = fs.mkdtempSync(path.join(os.tmpdir(), "ade-stdio-rpc-placeholder-role-")); + const socketPath = path.join(adeHome, "sock", "ade.sock"); + const baseEnv = { + ...process.env, + ADE_HOME: adeHome, + ADE_RUNTIME_SOCKET_PATH: socketPath, + NODE_OPTIONS: withTsxNodeOptions(process.env.NODE_OPTIONS), + }; + const realDaemon = startServeProcess({ + cliPath, + cwd: packageRoot, + env: { + ...baseEnv, + ADE_CLI_VERSION: "2.0.0", + ADE_DEFAULT_ROLE: "agent", + }, + socketPath, + }); + + let proxy: StdioRpcProcess | null = null; + try { + await waitForSocket(socketPath); + + const placeholderEnv: NodeJS.ProcessEnv = { + ...baseEnv, + ADE_DEFAULT_ROLE: "cto", + }; + delete placeholderEnv.ADE_CLI_VERSION; + proxy = StdioRpcProcess.start({ + cliPath, + cwd: packageRoot, + env: placeholderEnv, + }); + const initialize = await proxy.request("ade/initialize", { + protocolVersion: "2025-06-18", + clientName: "stdio-daemon-placeholder-role-test", + identity: { role: "external", callerId: "stdio-daemon-placeholder-role-test" }, + }); + + expect(initialize).toMatchObject({ + runtimeInfo: { + defaultRole: "cto", + multiProject: true, + }, + }); + expect((initialize as { runtimeInfo?: { pid?: number | null } }).runtimeInfo?.pid).not.toBe(realDaemon.pid); + + await expect(proxy.request("shutdown")).resolves.toEqual({}); + proxy.closeInput(); + await expect(proxy.waitForExit()).resolves.toMatchObject({ code: 0, signal: null }); + } finally { + proxy?.kill(); + if (!realDaemon.killed) realDaemon.kill(); + } + }, 45_000); }); From 7600c6b7b5163dde37c50d065a685b1a494dfd72 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Sun, 24 May 2026 20:24:22 -0400 Subject: [PATCH 9/9] Select uncovered failed mission steps --- .../aiOrchestratorService.test.ts | 17 ++++++++++-- .../orchestrator/aiOrchestratorService.ts | 26 ++++++++++++++----- 2 files changed, 34 insertions(+), 9 deletions(-) diff --git a/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.test.ts b/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.test.ts index b01c9b4ff..49075d3be 100644 --- a/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.test.ts +++ b/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.test.ts @@ -7063,13 +7063,27 @@ describe("aiOrchestratorService", () => { reasonCode: "terminal_run_failed_step", }, }); + const failedAt = new Date().toISOString(); + fixture.db.run( + `update mission_steps set status = 'failed', completed_at = ?, updated_at = ? where id in (?, ?)`, + [failedAt, failedAt, firstStep.id, secondStep.id], + ); const started = fixture.orchestratorService.startRun({ missionId: mission.id, steps: [ + { + stepKey: "first-step", + title: firstStep.title, + stepIndex: 0, + dependencyStepKeys: [], + executorKind: "manual", + missionStepId: firstStep.id, + metadata: { stepType: "implementation", stepKey: "first-step" } + }, { stepKey: "second-step", title: secondStep.title, - stepIndex: 0, + stepIndex: 1, dependencyStepKeys: [], executorKind: "manual", missionStepId: secondStep.id, @@ -7078,7 +7092,6 @@ describe("aiOrchestratorService", () => { ] }); const runId = started.run.id; - const failedAt = new Date().toISOString(); fixture.db.run( `update orchestrator_runs set status = 'failed', updated_at = ? where id = ?`, [failedAt, runId], diff --git a/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.ts b/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.ts index 1df8cb6c3..86e74d836 100644 --- a/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.ts +++ b/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.ts @@ -7055,20 +7055,32 @@ Check all worker statuses and continue managing the mission from here. Read work const mission = missionService.get(graph.run.missionId); if (!mission) return; - const failedRunStep = graph.steps.find((step) => step.status === "failed"); const missionStepById = new Map(mission.steps.map((step) => [step.id, step])); - const failedMissionStep = failedRunStep - ? resolveMissionStepForRunStep(mission, failedRunStep, missionStepById) - : mission.steps.find((step) => step.status === "failed") ?? null; - if (!failedRunStep && !failedMissionStep) return; - const hasMatchingOpenIntervention = mission.interventions.some((entry) => { + const hasMatchingOpenIntervention = ( + failedRunStep: OrchestratorRunGraph["steps"][number] | null, + failedMissionStep: MissionDetail["steps"][number] | null, + ) => mission.interventions.some((entry) => { if (entry.status !== "open" || entry.interventionType !== "failed_step") return false; const metadata = isRecord(entry.metadata) ? entry.metadata : null; if (!metadata) return false; if (failedMissionStep?.id && metadata.stepId === failedMissionStep.id) return true; return Boolean(!failedMissionStep && failedRunStep?.id && metadata.orchestratorStepId === failedRunStep.id); }); - if (hasMatchingOpenIntervention) return; + const failedRunPair = graph.steps + .filter((step) => step.status === "failed") + .map((runStep) => ({ + runStep, + missionStep: resolveMissionStepForRunStep(mission, runStep, missionStepById), + })) + .find(({ runStep, missionStep }) => !hasMatchingOpenIntervention(runStep, missionStep)); + const fallbackMissionStep = failedRunPair + ? null + : mission.steps.find((step) => + step.status === "failed" && !hasMatchingOpenIntervention(null, step) + ) ?? null; + const failedRunStep = failedRunPair?.runStep ?? null; + const failedMissionStep = failedRunPair?.missionStep ?? fallbackMissionStep; + if (!failedRunStep && !failedMissionStep) return; const failedStepTitle = failedMissionStep?.title ?? failedRunStep?.title ?? "Run step"; missionService.addIntervention({