diff --git a/apps/ade-cli/src/adeRpcServer.test.ts b/apps/ade-cli/src/adeRpcServer.test.ts index 17f23547c..f395ec2b8 100644 --- a/apps/ade-cli/src/adeRpcServer.test.ts +++ b/apps/ade-cli/src/adeRpcServer.test.ts @@ -2,7 +2,8 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { createAdeRpcRequestHandler, _resetGlobalAskUserRateLimit } from "./adeRpcServer"; +import { createAdeRpcRequestHandler, _resetGlobalAskUserRateLimit, resolveComputerUseOwners } from "./adeRpcServer"; +import { JsonRpcError, JsonRpcErrorCode } from "./jsonrpc"; type RuntimeFixture = ReturnType; const originalPlatform = process.platform; @@ -4481,4 +4482,85 @@ describe("adeRpcServer", () => { expect(response?.isError).toBeUndefined(); expect(fixture.runtime.eventBuffer.drain).toHaveBeenCalledWith(0, 100); }); + + describe("resolveComputerUseOwners explicit ownerKind/ownerId", () => { + function makeSession(): any { + return { + initialized: true, + protocolVersion: "2025-06-18", + identity: { + callerId: "caller-1", + role: "external", + chatSessionId: null, + standaloneChatSession: false, + missionId: null, + runId: null, + stepId: null, + attemptId: null, + ownerId: null, + }, + askUserEvents: [], + askUserRateLimit: { maxCalls: 1, windowMs: 1000 }, + memoryAddEvents: [], + memoryAddRateLimit: { maxCalls: 1, windowMs: 1000 }, + memorySearchEvents: [], + memorySearchRateLimit: { maxCalls: 1, windowMs: 1000 }, + }; + } + + it("includes an explicit lane owner", () => { + const owners = resolveComputerUseOwners(makeSession(), { ownerKind: "lane", ownerId: "lane-1" }); + expect(owners).toEqual( + expect.arrayContaining([ + expect.objectContaining({ kind: "lane", id: "lane-1", relation: "attached_to" }), + ]), + ); + // Explicit owner is prepended. + expect(owners[0]).toEqual(expect.objectContaining({ kind: "lane", id: "lane-1" })); + }); + + it("normalizes alias 'chat' to 'chat_session'", () => { + const owners = resolveComputerUseOwners(makeSession(), { ownerKind: "chat", ownerId: "c1" }); + expect(owners[0]).toEqual(expect.objectContaining({ kind: "chat_session", id: "c1" })); + expect(owners.some((o) => (o as any).kind === "chat")).toBe(false); + }); + + it("normalizes alias 'pr' to 'github_pr'", () => { + const owners = resolveComputerUseOwners(makeSession(), { ownerKind: "pr", ownerId: "p1" }); + expect(owners[0]).toEqual(expect.objectContaining({ kind: "github_pr", id: "p1" })); + }); + + it("throws JsonRpcError with invalidParams for an unsupported ownerKind", () => { + let caught: unknown = null; + try { + resolveComputerUseOwners(makeSession(), { ownerKind: "bogus", ownerId: "x" }); + } catch (err) { + caught = err; + } + expect(caught).toBeInstanceOf(JsonRpcError); + expect((caught as JsonRpcError).code).toBe(JsonRpcErrorCode.invalidParams); + }); + + it("rejects when ownerKind is provided without ownerId", () => { + let caught: unknown = null; + try { + resolveComputerUseOwners(makeSession(), { ownerKind: "lane" }); + } catch (error) { + caught = error; + } + expect(caught).toBeInstanceOf(JsonRpcError); + expect((caught as JsonRpcError).code).toBe(JsonRpcErrorCode.invalidParams); + }); + + it("rejects when ownerId is provided without ownerKind", () => { + let caught: unknown = null; + try { + resolveComputerUseOwners(makeSession(), { ownerId: "lane-123" }); + } catch (error) { + caught = error; + } + expect(caught).toBeInstanceOf(JsonRpcError); + expect((caught as JsonRpcError).code).toBe(JsonRpcErrorCode.invalidParams); + }); + }); }); diff --git a/apps/ade-cli/src/adeRpcServer.ts b/apps/ade-cli/src/adeRpcServer.ts index 99c11136a..53000b8a4 100644 --- a/apps/ade-cli/src/adeRpcServer.ts +++ b/apps/ade-cli/src/adeRpcServer.ts @@ -433,33 +433,37 @@ const TOOL_SPECS: ToolSpec[] = [ }, { name: "screenshot_environment", - description: "Fallback-only: capture a local screenshot and store it in ADE artifacts for proof attachment.", + description: "Fallback-only: capture a local screenshot/image and store it as visual ADE proof.", inputSchema: { type: "object", additionalProperties: false, properties: { name: { type: "string" }, displayId: { type: "number" }, + ownerKind: { type: "string" }, + ownerId: { type: "string" }, format: { type: "string", enum: ["png", "jpg"], default: "png" } } } }, { name: "record_environment", - description: "Fallback-only: record a short local screen video and store it in ADE artifacts for proof attachment.", + description: "Fallback-only: record a short local screen video and store it as visual ADE proof.", inputSchema: { type: "object", additionalProperties: false, properties: { name: { type: "string" }, displayId: { type: "number" }, + ownerKind: { type: "string" }, + ownerId: { type: "string" }, durationSec: { type: "number", minimum: 1, maximum: 120, default: 10 } } } }, { name: "ingest_computer_use_artifacts", - description: "Register externally-produced computer-use proof artifacts into ADE for ownership, closeout, and publishing.", + description: "Register externally-produced visual proof artifacts into ADE for ownership, closeout, and publishing. Console logs are supporting diagnostics and should not be the only proof unless explicitly requested.", inputSchema: { type: "object", additionalProperties: false, @@ -521,12 +525,14 @@ const TOOL_SPECS: ToolSpec[] = [ automationRunId: { type: "string" }, prUrl: { type: "string" }, linearIssueId: { type: "string" }, + ownerKind: { type: "string" }, + ownerId: { type: "string" }, } } }, { name: "list_computer_use_artifacts", - description: "List ADE-managed computer-use artifacts by owner or canonical proof type.", + description: "List ADE-managed proof artifacts by owner or canonical type, including visual proof and supporting diagnostics.", inputSchema: { type: "object", additionalProperties: false, @@ -2056,7 +2062,7 @@ function assertNonEmptyString(value: unknown, field: string): string { return text; } -function resolveComputerUseOwners(session: SessionState, toolArgs: Record): ComputerUseArtifactOwner[] { +export function resolveComputerUseOwners(session: SessionState, toolArgs: Record): ComputerUseArtifactOwner[] { const owners: ComputerUseArtifactOwner[] = []; const add = ( kind: ComputerUseArtifactOwner["kind"], @@ -2066,7 +2072,45 @@ function resolveComputerUseOwners(session: SessionState, toolArgs: Record { + const rawKind = asOptionalTrimmedString(toolArgs.ownerKind); + const ownerId = asOptionalTrimmedString(toolArgs.ownerId); + if (Boolean(rawKind) !== Boolean(ownerId)) { + throw new JsonRpcError( + JsonRpcErrorCode.invalidParams, + "ownerKind and ownerId must be provided together", + ); + } + if (!rawKind || !ownerId) return; + let normalizedKind = rawKind; + if (rawKind === "chat") normalizedKind = "chat_session"; + else if (rawKind === "pr") normalizedKind = "github_pr"; + switch (normalizedKind) { + case "lane": + case "mission": + case "orchestrator_run": + case "orchestrator_step": + case "orchestrator_attempt": + case "chat_session": + case "automation_run": + case "github_pr": + case "linear_issue": + add( + normalizedKind, + ownerId, + normalizedKind === "github_pr" || normalizedKind === "linear_issue" + ? "published_to" + : normalizedKind === "orchestrator_attempt" + ? "produced_by" + : "attached_to", + ); + break; + default: + throw new JsonRpcError(JsonRpcErrorCode.invalidParams, `Unsupported proof ownerKind: ${rawKind}`); + } + }; + addExplicitOwner(); add("mission", session.identity.missionId); add("orchestrator_run", session.identity.runId); add("orchestrator_step", session.identity.stepId); diff --git a/apps/ade-cli/src/cli.test.ts b/apps/ade-cli/src/cli.test.ts index 94836839f..dc71f469b 100644 --- a/apps/ade-cli/src/cli.test.ts +++ b/apps/ade-cli/src/cli.test.ts @@ -351,6 +351,55 @@ describe("ADE CLI", () => { expect(joined.command).toEqual(["lanes", "list"]); }); + it("prefers headless mode for local proof capture commands", () => { + const screenshot = buildCliPlan(["proof", "screenshot"]); + const capture = buildCliPlan(["proof", "capture", "--caption", "Done", "--owner-kind", "chat", "--owner-id", "chat-1"]); + const record = buildCliPlan(["proof", "record", "--seconds", "3"]); + const list = buildCliPlan(["proof", "list"]); + + expect(screenshot.kind).toBe("execute"); + expect(capture.kind).toBe("execute"); + expect(record.kind).toBe("execute"); + expect(list.kind).toBe("execute"); + if (screenshot.kind !== "execute" || capture.kind !== "execute" || record.kind !== "execute" || list.kind !== "execute") return; + + expect(screenshot.preferHeadless).toBe(true); + expect(capture.preferHeadless).toBe(true); + expect(capture.steps[0]?.params).toMatchObject({ + name: "screenshot_environment", + arguments: { + name: "Done", + ownerKind: "chat", + ownerId: "chat-1", + }, + }); + expect(record.preferHeadless).toBe(true); + expect(list.preferHeadless).toBeUndefined(); + }); + + it("maps proof attach to visual artifact ingestion", () => { + const plan = buildCliPlan(["proof", "attach", "/tmp/done.png", "--caption", "Checkout complete", "--owner-kind", "chat", "--owner-id", "chat-1"]); + expect(plan.kind).toBe("execute"); + if (plan.kind !== "execute") return; + + expect(plan.steps[0]?.params).toMatchObject({ + name: "ingest_computer_use_artifacts", + arguments: { + backendStyle: "manual", + backendName: "ade-cli", + toolName: "proof attach", + ownerKind: "chat", + ownerId: "chat-1", + inputs: [{ + kind: "screenshot", + title: "Checkout complete", + description: "Checkout complete", + path: "/tmp/done.png", + }], + }, + }); + }); + it("rejects invalid --role values", () => { expect(() => parseCliArgs(["--role", "bogus", "lanes", "list"])).toThrow( /--role must be one of/, diff --git a/apps/ade-cli/src/cli.ts b/apps/ade-cli/src/cli.ts index f528c5afe..ded15c76c 100644 --- a/apps/ade-cli/src/cli.ts +++ b/apps/ade-cli/src/cli.ts @@ -59,7 +59,7 @@ type FormatterId = type CliPlan = | { kind: "help"; text: string } - | { kind: "execute"; label: string; steps: InvocationStep[]; visualizer?: "lanes"; summary?: "status" | "doctor" | "auth"; formatter?: FormatterId }; + | { kind: "execute"; label: string; steps: InvocationStep[]; visualizer?: "lanes"; summary?: "status" | "doctor" | "auth"; formatter?: FormatterId; preferHeadless?: boolean }; type CliConnection = { mode: "desktop-socket" | "headless"; @@ -429,16 +429,20 @@ const HELP_BY_COMMAND: Record = { proof: `${ADE_BANNER} Proof and computer use - Proof commands capture or ingest artifacts that ADE can attach to work. - Local screenshot/video fallback is macOS-only; desktop socket mode has the - best parity with the app. + Proof commands capture or ingest reviewer-visible evidence for ADE work. + Prefer screenshots/images, screen recordings, and browser captures/traces. + Console logs are supporting diagnostics, not a replacement for visual proof. + Local screenshot/video fallback is macOS-only and runs headless by default + unless --socket is explicitly requested. Desktop socket mode has the best + parity for UI-owned proof state. $ ade proof status --text Show proof backend capabilities $ ade proof list --text List captured artifacts - $ ade proof screenshot Capture a screenshot artifact + $ ade proof capture --caption "Done" Capture a screenshot artifact + $ ade proof attach /tmp/proof.png --caption "Done" Attach an existing image/video $ ade proof record --seconds 20 Capture a short video proof $ ade proof launch --app "ADE" Launch an app for proof capture - $ ade proof ingest --input-json '{"artifacts":[]}' Ingest external proof artifacts + $ ade proof ingest --input-json '{"artifacts":[]}' Ingest external visual proof artifacts `, tests: `${ADE_BANNER} Tests @@ -1569,15 +1573,54 @@ function buildFilesPlan(args: string[]): CliPlan { function buildProofPlan(args: string[]): CliPlan { const sub = firstPositional(args) ?? "status"; + const proofOwnerBase = () => { + const ownerKind = readValue(args, ["--owner-kind", "--owner"]); + const ownerId = readValue(args, ["--owner-id"]); + return { + ...(ownerKind ? { ownerKind } : {}), + ...(ownerId ? { ownerId } : {}), + }; + }; + const inferAttachedProofKind = (filePath: string): string => { + const ext = path.extname(filePath).replace(/^\./, "").toLowerCase(); + if (["png", "jpg", "jpeg", "webp", "gif", "heic", "heif", "tif", "tiff"].includes(ext)) return "screenshot"; + if (["mov", "mp4", "m4v", "webm"].includes(ext)) return "video_recording"; + if (["zip", "har"].includes(ext)) return "browser_trace"; + return "browser_verification"; + }; if (sub === "actions") return { kind: "execute", label: "proof actions", steps: [listActionsStep("actions", "computer_use_artifacts")] }; if (sub === "status" || sub === "backends") return { kind: "execute", label: "proof backend status", steps: [actionCallStep("result", "get_computer_use_backend_status", collectGenericObjectArgs(args))] }; - if (sub === "environment") return { kind: "execute", label: "computer-use environment", steps: [actionCallStep("result", "get_environment_info", collectGenericObjectArgs(args))] }; + if (sub === "environment") return { kind: "execute", label: "computer-use environment", steps: [actionCallStep("result", "get_environment_info", collectGenericObjectArgs(args, proofOwnerBase()))], preferHeadless: true }; if (sub === "list" || sub === "ls") return { kind: "execute", label: "proof list", steps: [actionCallStep("result", "list_computer_use_artifacts", collectGenericObjectArgs(args))] }; if (sub === "ingest") return { kind: "execute", label: "proof ingest", steps: [actionCallStep("result", "ingest_computer_use_artifacts", collectGenericObjectArgs(args))] }; - if (sub === "screenshot") return { kind: "execute", label: "computer-use screenshot", steps: [actionCallStep("result", "screenshot_environment", collectGenericObjectArgs(args))] }; - if (sub === "record") return { kind: "execute", label: "computer-use record", steps: [actionCallStep("result", "record_environment", collectGenericObjectArgs(args, { durationSec: readNumberOption(args, ["--seconds", "--duration-sec"]) }))] }; - if (sub === "launch") return { kind: "execute", label: "computer-use launch", steps: [actionCallStep("result", "launch_app", collectGenericObjectArgs(args, { app: readValue(args, ["--app"]) ?? firstPositional(args) }))] }; - if (sub === "interact") return { kind: "execute", label: "computer-use interact", steps: [actionCallStep("result", "interact_gui", collectGenericObjectArgs(args))] }; + if (sub === "attach") { + const caption = readValue(args, ["--caption", "--description", "--desc"]); + const attachedPath = requireValue(readValue(args, ["--path"]) ?? firstPositional(args), "path"); + const title = readValue(args, ["--title", "--name"]) ?? caption ?? path.basename(attachedPath); + return { + kind: "execute", + label: "proof attach", + steps: [actionCallStep("result", "ingest_computer_use_artifacts", collectGenericObjectArgs(args, { + backendStyle: "manual", + backendName: "ade-cli", + toolName: "proof attach", + ...proofOwnerBase(), + inputs: [{ + kind: inferAttachedProofKind(attachedPath), + title, + ...(caption ? { description: caption } : {}), + path: attachedPath, + }], + }))], + }; + } + if (sub === "screenshot" || sub === "capture") { + const caption = readValue(args, ["--caption", "--description", "--desc"]); + return { kind: "execute", label: "computer-use screenshot", steps: [actionCallStep("result", "screenshot_environment", collectGenericObjectArgs(args, { ...proofOwnerBase(), name: readValue(args, ["--name", "--title"]) ?? caption }))], preferHeadless: true }; + } + if (sub === "record") return { kind: "execute", label: "computer-use record", steps: [actionCallStep("result", "record_environment", collectGenericObjectArgs(args, { ...proofOwnerBase(), name: readValue(args, ["--name", "--title"]) ?? readValue(args, ["--caption", "--description", "--desc"]), durationSec: readNumberOption(args, ["--seconds", "--duration-sec"]) }))], preferHeadless: true }; + if (sub === "launch") return { kind: "execute", label: "computer-use launch", steps: [actionCallStep("result", "launch_app", collectGenericObjectArgs(args, { app: readValue(args, ["--app"]) ?? firstPositional(args) }))], preferHeadless: true }; + if (sub === "interact") return { kind: "execute", label: "computer-use interact", steps: [actionCallStep("result", "interact_gui", collectGenericObjectArgs(args, proofOwnerBase()))], preferHeadless: true }; return { kind: "execute", label: `proof ${sub}`, steps: [actionStep("result", "computer_use_artifacts", sub, collectGenericObjectArgs(args))] }; } @@ -1867,7 +1910,7 @@ const VALUE_CARRIER_FLAGS: ReadonlySet = new Set([ "--automation", "--base", "--base-branch", "--body", "--branch", "--branch-name", "--branch-ref", "--category", "--color", "--cols", "--command", "--comment", "--comment-id", "--commit", "--compare-ref", - "--compare-to", "--content", "--context-file", "--cwd", "--data", + "--caption", "--compare-to", "--content", "--context-file", "--cwd", "--data", "--depth", "--desc", "--description", "--domain", "--duration-sec", "--enabled", "--event", "--from-file", "--group", "--group-id", "--head", "--icon", "--id", @@ -1876,7 +1919,8 @@ const VALUE_CARRIER_FLAGS: ReadonlySet = new Set([ "--max-log-bytes", "--max-prompt-chars", "--max-rounds", "--memory", "--memory-id", "--merge-method", "--message", "--method", "--mode", "--model", "--model-id", "--name", "--new", "--new-path", "--number", "--old", - "--old-path", "--params-json", "--parent", "--parent-lane", "--parent-lane-id", + "--old-path", "--owner", "--owner-id", "--owner-kind", + "--params-json", "--parent", "--parent-lane", "--parent-lane-id", "--path", "--permission-mode", "--permissions", "--pr", "--pr-id", "--pr-number", "--pr-url", "--process", "--process-id", "--project-root", "--prompt", "--provider", "--pty", "--pty-id", "--query", "--question", @@ -3108,8 +3152,11 @@ function summarizeExecution(args: { async function executePlan(plan: CliPlan & { kind: "execute" }, options: GlobalOptions): Promise { let connection: CliConnection; + const connectionOptions = plan.preferHeadless && !options.requireSocket + ? { ...options, headless: true } + : options; try { - connection = await createConnection(options); + connection = await createConnection(connectionOptions); } catch (error) { const roots = resolveRoots(options); let socketPath = path.join(roots.projectRoot, ".ade", "ade.sock"); @@ -3119,7 +3166,7 @@ async function executePlan(plan: CliPlan & { kind: "execute" }, options: GlobalO } catch { // Keep the conventional Unix fallback if shared layout loading fails. } - const requestedMode = options.requireSocket ? "desktop-socket" : options.headless ? "headless" : "auto"; + const requestedMode = connectionOptions.requireSocket ? "desktop-socket" : connectionOptions.headless ? "headless" : "auto"; const cause = error instanceof Error ? error.message : String(error); const sourceRuntimeInterop = isSourceRuntimeInteropError(cause); throw new CliExecutionError(`Failed to initialize ADE CLI connection for ${plan.label}.`, { diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index 11486ff09..0f7e4da77 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -50,12 +50,12 @@ import { toProjectInfo, upsertProjectRow, } from "./services/projects/projectService"; -import { toRecentProjectSummary } from "./services/projects/recentProjectSummary"; +import { inspectRecentProject, type RecentProjectInspection } from "./services/projects/recentProjectSummary"; import { createAdeProjectService } from "./services/projects/adeProjectService"; import { createConfigReloadService } from "./services/projects/configReloadService"; import { IPC } from "../shared/ipc"; import { resolveAdeLayout } from "../shared/adeLayout"; -import type { PortLease, ProjectInfo, RecentProjectSummary, SyncMobileProjectSummary, SyncProjectSwitchRequestPayload, SyncProjectSwitchResultPayload } from "../shared/types"; +import type { PortLease, ProjectInfo, SyncMobileProjectSummary, SyncProjectConnectionPayload, SyncProjectSwitchRequestPayload, SyncProjectSwitchResultPayload } from "../shared/types"; import type { AutomationTriggerType } from "../shared/types/config"; import type { AutomationTriggerLinearIssueContext } from "../shared/types/automations"; import type { LinearIngressEventRecord } from "../shared/types/linearSync"; @@ -891,6 +891,7 @@ app.whenReady().then(async () => { const MAX_WARM_IDLE_PROJECT_CONTEXTS = 1; const MOBILE_SYNC_HANDOFF_LEASE_MS = 60_000; let activeProjectRoot: string | null = null; + let mobileSyncSelectedRoot: string | null = null; let dormantContext!: AppContext; let projectContextRebalancePromise: Promise = Promise.resolve(); @@ -898,13 +899,61 @@ app.whenReady().then(async () => { broadcast(IPC.appProjectChanged, project); }; + const firstAvailableRecentProjectRoot = (): string | null => { + const recentProjects = readGlobalState(globalStatePath).recentProjects ?? []; + for (const project of recentProjects) { + if (typeof project.rootPath !== "string") continue; + const rootPath = normalizeProjectRoot(project.rootPath); + if (rootPath && isLikelyRepoRoot(rootPath)) return rootPath; + } + return null; + }; + + const getMobileSyncHostRoot = (): string | null => + mobileSyncSelectedRoot + ?? activeProjectRoot + ?? firstAvailableRecentProjectRoot(); + + const getMobileSyncService = (): ReturnType | null => { + const hostRoot = getMobileSyncHostRoot(); + return hostRoot ? projectContexts.get(hostRoot)?.syncService ?? null : null; + }; + + const notifyMobileSyncProjectCatalogChanged = (): void => { + const hostService = getMobileSyncService()?.getHostService(); + if (!hostService) return; + void hostService.broadcastProjectCatalog().catch((error) => { + const logger = + (activeProjectRoot ? projectContexts.get(activeProjectRoot)?.logger : null) + ?? (dormantContext as AppContext | undefined)?.logger; + logger?.warn("sync.mobile_project_catalog_broadcast_failed", { + error: error instanceof Error ? error.message : String(error), + }); + }); + }; + + let reconcileSyncHostContextsChain: Promise = Promise.resolve(); + const reconcileSyncHostContexts = (): Promise => { + const next = reconcileSyncHostContextsChain.then(async () => { + const hostRoot = getMobileSyncHostRoot(); + for (const [root, ctx] of projectContexts) { + const isSyncHost = hostRoot != null && root === hostRoot; + ctx.syncService?.setHostDiscoveryEnabled?.(isSyncHost); + } + for (const [root, ctx] of projectContexts) { + const isSyncHost = hostRoot != null && root === hostRoot; + await ctx.syncService?.setHostStartupEnabled?.(isSyncHost); + } + }); + reconcileSyncHostContextsChain = next.catch(() => {}); + return next; + }; + const setActiveProject = (projectRoot: string | null): void => { activeProjectRoot = projectRoot ? normalizeProjectRoot(projectRoot) : null; - for (const [root, ctx] of projectContexts) { - const isActive = activeProjectRoot != null && root === activeProjectRoot; - ctx.syncService?.setHostStartupEnabled?.(isActive); - ctx.syncService?.setHostDiscoveryEnabled?.(isActive); - } + void reconcileSyncHostContexts().then(() => { + notifyMobileSyncProjectCatalogChanged(); + }); if (activeProjectRoot) { projectLastActivatedAt.set(activeProjectRoot, Date.now()); try { @@ -2652,14 +2701,16 @@ app.whenReady().then(async () => { emitProjectEvent(projectRoot, IPC.orchestratorDagMutation, event), }); aiOrchestratorServiceRef = aiOrchestratorService; - // Only the project that matches the currently-active root should auto-start - // its sync host; background project contexts stay dormant until activated. + // Phone sync is an app-level feature. A single project context still backs + // the project-scoped data stream, but the backing context is selected by the + // app-level sync host root rather than the visible project tab alone. // ADE_DISABLE_SYNC_HOST=1 is a global kill switch for tests / CI. - const isActiveProjectContext = - activeProjectRoot != null - && normalizeProjectRoot(projectRoot) === activeProjectRoot; + const mobileSyncHostRoot = getMobileSyncHostRoot(); + const isMobileSyncHostContext = + mobileSyncHostRoot != null + && normalizeProjectRoot(projectRoot) === mobileSyncHostRoot; const syncHostAutoStart = - process.env.ADE_DISABLE_SYNC_HOST !== "1" && isActiveProjectContext; + process.env.ADE_DISABLE_SYNC_HOST !== "1" && isMobileSyncHostContext; const syncService = createSyncService({ db, logger, @@ -2697,17 +2748,21 @@ app.whenReady().then(async () => { processService, hostStartupEnabled: syncHostAutoStart, phonePairingStateDir: path.join(app.getPath("userData"), "phone-sync"), - hostDiscoveryEnabled: isActiveProjectContext, + hostDiscoveryEnabled: isMobileSyncHostContext, + forceHostRole: true, notificationEventBus, projectCatalogProvider: { listProjects: listMobileSyncProjects, prepareProjectConnection: prepareMobileSyncProjectConnection, + completeProjectConnection: completeMobileSyncProjectConnection, }, onStatusChanged: (snapshot) => { - if ( - activeProjectRoot == null - || normalizeProjectRoot(projectRoot) !== activeProjectRoot - ) { + const normalizedProjectRoot = normalizeProjectRoot(projectRoot); + if (mobileSyncSelectedRoot == null && snapshot.connectedPeers.length > 0) { + mobileSyncSelectedRoot = normalizedProjectRoot; + } + const currentSyncHostRoot = getMobileSyncHostRoot(); + if (currentSyncHostRoot == null || normalizedProjectRoot !== currentSyncHostRoot) { return; } broadcast(IPC.syncEvent, { @@ -3863,6 +3918,11 @@ app.whenReady().then(async () => { if (activeProjectRoot === normalizedRoot) { activeProjectRoot = null; } + if (mobileSyncSelectedRoot === normalizedRoot) { + mobileSyncSelectedRoot = null; + } + await reconcileSyncHostContexts(); + notifyMobileSyncProjectCatalogChanged(); })().finally(() => { closeContextPromises.delete(normalizedRoot); }); @@ -3880,10 +3940,10 @@ app.whenReady().then(async () => { async function mobileProjectSummaryForContext( ctx: AppContext, - recent?: RecentProjectSummary | null, + recent?: RecentProjectInspection | null, ): Promise { - let laneCount = recent?.laneCount ?? 0; - if (!recent?.laneCount) { + let laneCount = recent?.summary.laneCount ?? 0; + if (!recent?.summary.laneCount) { try { laneCount = (await ctx.laneService.list({ includeArchived: false })).length; } catch { @@ -3891,11 +3951,11 @@ app.whenReady().then(async () => { } } return { - id: `root:${normalizeProjectRoot(ctx.project.rootPath)}`, + id: ctx.projectId || recent?.projectId || `root:${normalizeProjectRoot(ctx.project.rootPath)}`, displayName: ctx.project.displayName, rootPath: ctx.project.rootPath, defaultBaseRef: ctx.project.baseRef, - lastOpenedAt: recent?.lastOpenedAt ?? null, + lastOpenedAt: recent?.summary.lastOpenedAt ?? null, laneCount, isAvailable: fs.existsSync(ctx.project.rootPath), isCached: false, @@ -3903,16 +3963,16 @@ app.whenReady().then(async () => { }; } - function mobileProjectSummaryForRecent(recent: RecentProjectSummary): SyncMobileProjectSummary { - const normalizedRoot = normalizeProjectRoot(recent.rootPath); + function mobileProjectSummaryForRecent(recent: RecentProjectInspection): SyncMobileProjectSummary { + const normalizedRoot = normalizeProjectRoot(recent.summary.rootPath); return { - id: `root:${normalizedRoot}`, - displayName: recent.displayName, - rootPath: recent.rootPath, - defaultBaseRef: null, - lastOpenedAt: recent.lastOpenedAt, - laneCount: recent.laneCount ?? 0, - isAvailable: recent.exists, + id: recent.projectId ?? `root:${normalizedRoot}`, + displayName: recent.summary.displayName, + rootPath: recent.summary.rootPath, + defaultBaseRef: recent.defaultBaseRef, + lastOpenedAt: recent.summary.lastOpenedAt, + laneCount: recent.summary.laneCount ?? 0, + isAvailable: recent.summary.exists, isCached: false, isOpen: false, }; @@ -3920,13 +3980,13 @@ app.whenReady().then(async () => { async function listMobileSyncProjects(): Promise<{ projects: SyncMobileProjectSummary[] }> { const recentProjects = (readGlobalState(globalStatePath).recentProjects ?? []) - .map(toRecentProjectSummary); + .map(inspectRecentProject); const recentByRoot = new Map( - recentProjects.map((entry) => [normalizeProjectRoot(entry.rootPath), entry] as const), + recentProjects.map((entry) => [normalizeProjectRoot(entry.summary.rootPath), entry] as const), ); const byRoot = new Map(); for (const recent of recentProjects) { - byRoot.set(normalizeProjectRoot(recent.rootPath), mobileProjectSummaryForRecent(recent)); + byRoot.set(normalizeProjectRoot(recent.summary.rootPath), mobileProjectSummaryForRecent(recent)); } const contextSummaries = await Promise.all( [...projectContexts.entries()].map(async ([root, ctx]) => @@ -4027,16 +4087,30 @@ app.whenReady().then(async () => { let createdLeaseExpiresAt: number | null = null; let createdLeaseTimer: ReturnType | null = null; try { - await switchProjectFromDialog(targetRoot); const ctx = await ensureProjectContextForMobileSync(targetRoot); if (!ctx.syncService) { throw new Error("Sync is not available for that project."); } + ctx.syncService.setHostDiscoveryEnabled?.(true); + await ctx.syncService.setHostStartupEnabled?.(true); await ctx.syncService.initialize(); const recent = (readGlobalState(globalStatePath).recentProjects ?? []) - .map(toRecentProjectSummary) - .find((entry) => normalizeProjectRoot(entry.rootPath) === targetRoot) ?? null; + .map(inspectRecentProject) + .find((entry) => normalizeProjectRoot(entry.summary.rootPath) === targetRoot) ?? null; const project = await mobileProjectSummaryForContext(ctx, recent); + const status = await ctx.syncService.getStatus(); + const connectInfo = status.pairingConnectInfo; + if (!connectInfo) { + throw new Error("Phone sync is not ready for that project yet."); + } + const connection: SyncProjectConnectionPayload = { + authKind: "paired", + token: null, + pairedDeviceId: null, + hostIdentity: connectInfo.hostIdentity, + port: connectInfo.port, + addressCandidates: connectInfo.addressCandidates, + }; const leaseExpiresAt = Date.now() + MOBILE_SYNC_HANDOFF_LEASE_MS; createdLeaseExpiresAt = leaseExpiresAt; mobileSyncHandoffLeases.set(targetRoot, leaseExpiresAt); @@ -4057,7 +4131,7 @@ app.whenReady().then(async () => { return { ok: true, project, - connection: null, + connection, }; } catch (error) { const currentLeaseTimer = mobileSyncHandoffLeaseTimers.get(targetRoot); @@ -4068,6 +4142,9 @@ app.whenReady().then(async () => { if (createdLeaseExpiresAt != null && mobileSyncHandoffLeases.get(targetRoot) === createdLeaseExpiresAt) { mobileSyncHandoffLeases.delete(targetRoot); } + if (mobileSyncSelectedRoot !== targetRoot) { + await reconcileSyncHostContexts(); + } if (!hadExistingContext && projectContexts.has(targetRoot) && !mobileSyncHandoffLeases.has(targetRoot)) { await closeProjectContext(targetRoot); } else { @@ -4089,6 +4166,39 @@ app.whenReady().then(async () => { } } + async function completeMobileSyncProjectConnection( + args: SyncProjectSwitchRequestPayload, + result: SyncProjectSwitchResultPayload, + ): Promise { + if (!result.ok) return; + const resultRoot = result.project?.rootPath ? normalizeProjectRoot(result.project.rootPath) : null; + const requestedRoot = typeof args.rootPath === "string" && args.rootPath.trim() + ? normalizeProjectRoot(args.rootPath) + : null; + const targetRoot = resultRoot ?? requestedRoot; + if (!targetRoot) return; + + mobileSyncSelectedRoot = targetRoot; + projectLastActivatedAt.set(targetRoot, Date.now()); + await reconcileSyncHostContexts(); + scheduleProjectContextRebalance(); + notifyMobileSyncProjectCatalogChanged(); + } + + async function ensureMobileSyncService(): Promise | null> { + const hostRoot = getMobileSyncHostRoot(); + if (!hostRoot) return null; + const normalizedRoot = normalizeProjectRoot(hostRoot); + let ctx = projectContexts.get(normalizedRoot) ?? null; + if (!ctx) { + ctx = await ensureProjectContextForMobileSync(normalizedRoot); + } + if (!ctx.syncService) return null; + await reconcileSyncHostContexts(); + await ctx.syncService.initialize(); + return ctx.syncService; + } + const persistRecentProject = ( project: ProjectInfo, options: { recordLastProject?: boolean; recordRecent?: boolean } = {}, @@ -4493,9 +4603,9 @@ app.whenReady().then(async () => { return ctx; }, getSyncService: () => { - if (!activeProjectRoot) return null; - return projectContexts.get(activeProjectRoot)?.syncService ?? null; + return getMobileSyncService(); }, + resolveSyncService: ensureMobileSyncService, switchProjectFromDialog, closeCurrentProject, closeProjectByPath, diff --git a/apps/desktop/src/main/services/automations/automationPlannerService.test.ts b/apps/desktop/src/main/services/automations/automationPlannerService.test.ts index 8f932b8ce..27a077bb0 100644 --- a/apps/desktop/src/main/services/automations/automationPlannerService.test.ts +++ b/apps/desktop/src/main/services/automations/automationPlannerService.test.ts @@ -259,6 +259,143 @@ describe("automationPlannerService.validateDraft", () => { expect(saved.rule.includeProjectContext).toBe(false); expect(getSnapshot().local.automations[0]?.includeProjectContext).toBe(false); }); + + it("normalizes issue-to-lane pipelines with per-step agent settings", () => { + const { planner } = getPlanner({ suites: [] }); + const draft = createDraft({ + name: "Issue pipeline", + triggers: [{ type: "github.issue_opened", repo: "arul28/ADE" }], + trigger: { type: "github.issue_opened", repo: "arul28/ADE" }, + execution: { kind: "built-in" } as any, + actions: [ + { + type: "create-lane", + laneNameTemplate: "{{trigger.issue.title}}", + laneDescriptionTemplate: "{{trigger.issue.url}}", + }, + { + type: "agent-session", + prompt: "Fix {{trigger.issue.title}}", + sessionTitle: "Fix issue", + modelConfig: { modelId: "opencode/openai/gpt-5.4", thinkingLevel: "high" }, + permissionConfig: { providers: { opencode: "full-auto" } }, + }, + ], + legacyActions: [ + { + type: "create-lane", + laneNameTemplate: "{{trigger.issue.title}}", + laneDescriptionTemplate: "{{trigger.issue.url}}", + }, + { + type: "agent-session", + prompt: "Fix {{trigger.issue.title}}", + sessionTitle: "Fix issue", + modelConfig: { modelId: "opencode/openai/gpt-5.4", thinkingLevel: "high" }, + permissionConfig: { providers: { opencode: "full-auto" } }, + }, + ], + } as any); + + const res = planner.validateDraft({ draft, confirmations: [] }); + expect(res.ok).toBe(true); + expect(res.normalized?.actions[0]).toMatchObject({ + type: "create-lane", + laneNameTemplate: "{{trigger.issue.title}}", + }); + expect(res.normalized?.actions[1]).toMatchObject({ + type: "agent-session", + modelConfig: { modelId: "opencode/openai/gpt-5.4", thinkingLevel: "high" }, + permissionConfig: { providers: { opencode: "full-auto" } }, + }); + }); + + it("defaults the create-lane name template to trigger.issue.title when blank", () => { + const { planner } = getPlanner({ suites: [] }); + const draft = createDraft({ + name: "Default lane name", + triggers: [{ type: "github.issue_opened" }], + trigger: { type: "github.issue_opened" }, + execution: { kind: "built-in" } as any, + actions: [{ type: "create-lane", laneNameTemplate: " " } as any], + legacyActions: [{ type: "create-lane", laneNameTemplate: " " } as any], + }); + + const res = planner.validateDraft({ draft, confirmations: [] }); + expect(res.ok).toBe(true); + expect(res.normalized?.actions[0]).toMatchObject({ + type: "create-lane", + laneNameTemplate: "{{trigger.issue.title}}", + }); + expect((res.normalized?.actions[0] as any).laneDescriptionTemplate).toBeUndefined(); + expect((res.normalized?.actions[0] as any).parentLaneId).toBeUndefined(); + }); + + it("preserves per-action targetLaneId on every action via the base spread", () => { + const { planner } = getPlanner({ suites: [{ id: "unit", name: "Unit Tests" }] }); + const draft = createDraft({ + name: "Per-action lanes", + execution: { kind: "built-in" } as any, + actions: [ + { + type: "ade-action", + targetLaneId: "lane-ade", + adeAction: { domain: "issue", action: "setLabels", args: { labels: ["x"] } }, + } as any, + { type: "run-tests", suite: "unit", targetLaneId: "lane-tests" } as any, + { type: "predict-conflicts", targetLaneId: "lane-conflict" } as any, + ], + legacyActions: [ + { + type: "ade-action", + targetLaneId: "lane-ade", + adeAction: { domain: "issue", action: "setLabels", args: { labels: ["x"] } }, + } as any, + { type: "run-tests", suite: "unit", targetLaneId: "lane-tests" } as any, + { type: "predict-conflicts", targetLaneId: "lane-conflict" } as any, + ], + }); + + const res = planner.validateDraft({ draft, confirmations: [] }); + expect(res.ok).toBe(true); + expect((res.normalized?.actions[0] as any).targetLaneId).toBe("lane-ade"); + expect((res.normalized?.actions[1] as any).targetLaneId).toBe("lane-tests"); + expect((res.normalized?.actions[2] as any).targetLaneId).toBe("lane-conflict"); + }); + + it("validates run-command cwd against the per-action targetLaneId before draft execution lane", () => { + const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-automation-planner-action-lane-")); + const actionLane = fs.mkdtempSync(path.join(os.tmpdir(), "ade-automation-planner-action-lane-target-")); + const draftLane = fs.mkdtempSync(path.join(os.tmpdir(), "ade-automation-planner-draft-lane-")); + + try { + fs.mkdirSync(path.join(actionLane, "scripts"), { recursive: true }); + const { planner } = getPlanner({ + suites: [], + projectRoot, + laneWorktrees: { "lane-action": actionLane, "lane-draft": draftLane }, + }); + + const draft = createDraft({ + name: "Per-action lane cwd", + execution: { kind: "built-in", targetLaneId: "lane-draft" } as any, + actions: [{ type: "run-command", command: "ls", cwd: "scripts", targetLaneId: "lane-action" } as any], + legacyActions: [{ type: "run-command", command: "ls", cwd: "scripts", targetLaneId: "lane-action" } as any], + }); + + const res = planner.validateDraft({ draft, confirmations: ["confirm.run-command"] }); + expect(res.ok).toBe(true); + expect(res.normalized?.actions[0]).toMatchObject({ + type: "run-command", + cwd: "scripts", + targetLaneId: "lane-action", + }); + } finally { + fs.rmSync(projectRoot, { recursive: true, force: true }); + fs.rmSync(actionLane, { recursive: true, force: true }); + fs.rmSync(draftLane, { recursive: true, force: true }); + } + }); }); function createDraft( diff --git a/apps/desktop/src/main/services/automations/automationPlannerService.ts b/apps/desktop/src/main/services/automations/automationPlannerService.ts index 4cc38aa78..dca34f417 100644 --- a/apps/desktop/src/main/services/automations/automationPlannerService.ts +++ b/apps/desktop/src/main/services/automations/automationPlannerService.ts @@ -638,8 +638,34 @@ function normalizeDraft(args: { const action = draftActions[idx] as any; const type = safeTrim(action?.type) as AutomationActionType; const condition = safeTrim(action?.condition); + const targetLaneId = safeTrim(action?.targetLaneId); + const rawModelConfig = action?.modelConfig; + const modelConfigModelId = safeTrim(rawModelConfig?.modelId); + const modelConfigThinkingLevel = safeTrim(rawModelConfig?.thinkingLevel); + const actionModelConfig = rawModelConfig && typeof rawModelConfig === "object" && modelConfigModelId + ? { + ...rawModelConfig, + modelId: modelConfigModelId, + ...(modelConfigThinkingLevel ? { thinkingLevel: modelConfigThinkingLevel } : {}), + } + : null; + const rawPermissionConfig = action?.permissionConfig; + const actionPermissionConfig = rawPermissionConfig && typeof rawPermissionConfig === "object" + ? { + ...(rawPermissionConfig.cli && typeof rawPermissionConfig.cli === "object" + ? { cli: rawPermissionConfig.cli } + : {}), + ...(rawPermissionConfig.providers && typeof rawPermissionConfig.providers === "object" + ? { providers: rawPermissionConfig.providers } + : {}), + ...(rawPermissionConfig.inProcess && typeof rawPermissionConfig.inProcess === "object" + ? { inProcess: rawPermissionConfig.inProcess } + : {}), + } + : null; const base = { type, + ...(targetLaneId ? { targetLaneId } : {}), ...(condition ? { condition } : {}), ...(typeof action?.continueOnFailure === "boolean" ? { continueOnFailure: action.continueOnFailure } : {}), ...(action?.timeoutMs != null ? { timeoutMs: clampNumber(Number(action.timeoutMs), 1000, MAX_TIMEOUT_MS) } : {}), @@ -647,6 +673,7 @@ function normalizeDraft(args: { } satisfies Partial; if ( + type !== "create-lane" && type !== "predict-conflicts" && type !== "run-tests" && type !== "run-command" && @@ -658,6 +685,19 @@ function normalizeDraft(args: { continue; } + if (type === "create-lane") { + const laneNameTemplate = safeTrim(action?.laneNameTemplate) || "{{trigger.issue.title}}"; + const laneDescriptionTemplate = safeTrim(action?.laneDescriptionTemplate); + const parentLaneId = safeTrim(action?.parentLaneId); + normalizedActions.push({ + ...(base as AutomationAction), + laneNameTemplate, + ...(laneDescriptionTemplate ? { laneDescriptionTemplate } : {}), + ...(parentLaneId ? { parentLaneId } : {}), + }); + continue; + } + if (type === "ade-action") { const adeAction = action?.adeAction; const domain = safeTrim(adeAction?.domain); @@ -684,6 +724,8 @@ function normalizeDraft(args: { ...(base as AutomationAction), ...(prompt ? { prompt } : {}), ...(safeTrim(action?.sessionTitle) ? { sessionTitle: safeTrim(action?.sessionTitle) } : {}), + ...(actionModelConfig ? { modelConfig: actionModelConfig } : {}), + ...(actionPermissionConfig ? { permissionConfig: actionPermissionConfig } : {}), }); continue; } @@ -729,7 +771,7 @@ function normalizeDraft(args: { const cwdRaw = safeTrim(action?.cwd); if (cwdRaw) { - const executionLaneId = safeTrim(args.draft.execution?.targetLaneId) || null; + const executionLaneId = targetLaneId || safeTrim(args.draft.execution?.targetLaneId) || null; const baseCwd = resolveAutomationCwdBase(args.projectRoot, args.laneService, executionLaneId); const cwdIssue = validateAutomationCwd(baseCwd, cwdRaw); if (cwdIssue) { diff --git a/apps/desktop/src/main/services/automations/automationService.test.ts b/apps/desktop/src/main/services/automations/automationService.test.ts index e0323a07b..ee2c967fc 100644 --- a/apps/desktop/src/main/services/automations/automationService.test.ts +++ b/apps/desktop/src/main/services/automations/automationService.test.ts @@ -527,6 +527,128 @@ describe("automationService integration", () => { } }); + it("creates a lane from a GitHub issue before launching a configured agent step", async () => { + const { db } = createInMemoryAdeDb(); + const logger = createLogger(); + const projectId = "proj"; + const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-automation-issue-lane-")); + const createLane = vi.fn(async () => ({ + id: "lane-issue", + name: "Fix checkout", + branchRef: "fix-checkout", + laneType: "feature", + worktreePath: projectRoot, + })); + const createSession = vi.fn(async () => ({ id: "session-issue" })); + const runSessionTurn = vi.fn(async () => ({ outputText: "fixed" })); + + const rule = { + id: "issue-pipeline", + name: "Issue pipeline", + enabled: true, + mode: "fix", + reviewProfile: "quick", + trigger: { type: "github.issue_opened" as const }, + triggers: [{ type: "github.issue_opened" as const }], + executor: { mode: "automation-bot", targetId: null }, + modelConfig: { + orchestratorModel: { modelId: "opencode/openai/gpt-5.4", thinkingLevel: "medium" }, + }, + permissionConfig: { providers: { opencode: "edit" } }, + toolPalette: [] as const, + contextSources: [], + memory: { mode: "project" as const }, + guardrails: { maxDurationMin: 5 }, + outputs: { disposition: "comment-only" as const, createArtifact: true }, + verification: { verifyBeforePublish: false, mode: "intervention" as const }, + billingCode: "auto:test", + execution: { + kind: "built-in" as const, + builtIn: { + actions: [ + { + type: "create-lane" as const, + laneNameTemplate: "{{trigger.issue.title}}", + laneDescriptionTemplate: "{{trigger.issue.url}}", + }, + { + type: "agent-session" as const, + prompt: "Fix {{trigger.issue.title}}", + sessionTitle: "Fix issue", + modelConfig: { modelId: "opencode/openai/gpt-5.4", thinkingLevel: "high" as const }, + permissionConfig: { providers: { opencode: "full-auto" as const } }, + }, + ], + }, + }, + actions: [], + }; + + const projectConfigService = { + get: () => ({ + trust: { requiresSharedTrust: false }, + effective: { automations: [rule], providerMode: "guest" } + }) + } as any; + + const laneService = { + create: createLane, + list: async () => [{ id: "lane-primary", laneType: "primary" }], + getLaneWorktreePath: () => projectRoot, + getLaneBaseAndBranch: () => ({ baseRef: "main", branchRef: "main", worktreePath: projectRoot }) + } as any; + + const service = createAutomationService({ + db: db as any, + logger, + projectId, + projectRoot, + laneService, + projectConfigService, + agentChatService: { + createSession, + runSessionTurn, + } as any, + }); + + try { + const event = await service.dispatchIngressTrigger({ + source: "github-polling", + eventKey: "arul28/ADE#123:opened", + triggerType: "github.issue_opened", + eventName: "github.issue_opened", + repo: "arul28/ADE", + issue: { + number: 123, + title: "Fix checkout", + body: "Broken checkout flow", + author: "arul28", + labels: ["bug"], + repo: "arul28/ADE", + url: "https://github.com/arul28/ADE/issues/123", + }, + }); + + expect(event?.status).toBe("dispatched"); + expect(createLane).toHaveBeenCalledWith(expect.objectContaining({ + name: "Fix checkout", + description: "https://github.com/arul28/ADE/issues/123", + })); + expect(createSession).toHaveBeenCalledWith(expect.objectContaining({ + laneId: "lane-issue", + modelId: "opencode/openai/gpt-5.4", + reasoningEffort: "high", + permissionMode: "full-auto", + })); + expect(runSessionTurn).toHaveBeenCalledWith(expect.objectContaining({ + text: "Fix Fix checkout", + reasoningEffort: "high", + })); + } finally { + fs.rmSync(projectRoot, { recursive: true, force: true }); + } + }); + it("rejects run-command cwd values that escape through symlinks", async () => { const { db } = createInMemoryAdeDb(); const logger = createLogger(); @@ -863,6 +985,234 @@ describe("automationService integration", () => { } }); + it("falls back to rule.name when create-lane template renders empty", async () => { + const { db } = createInMemoryAdeDb(); + const logger = createLogger(); + const projectId = "proj"; + const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-automation-create-lane-fallback-")); + const createLane = vi.fn(async () => ({ + id: "lane-new", + name: "Fallback rule name", + branchRef: "fallback-rule-name", + laneType: "feature", + worktreePath: projectRoot, + })); + + const rule = { + id: "create-lane-only", + name: "Fallback rule name", + enabled: true, + mode: "review", + reviewProfile: "quick", + trigger: { type: "manual" as const }, + triggers: [{ type: "manual" as const }], + executor: { mode: "automation-bot", targetId: null }, + toolPalette: [] as const, + contextSources: [], + memory: { mode: "project" as const }, + guardrails: { maxDurationMin: 5 }, + outputs: { disposition: "comment-only" as const, createArtifact: true }, + verification: { verifyBeforePublish: false, mode: "intervention" as const }, + billingCode: "auto:test", + execution: { + kind: "built-in" as const, + builtIn: { + actions: [ + // Embedded (non-whole-match) placeholders that don't resolve become empty + // strings, so the rendered name should be empty and fall back to rule.name. + { type: "create-lane" as const, laneNameTemplate: "{{trigger.issue.title}}{{trigger.issue.body}}" }, + ], + }, + }, + actions: [], + }; + + const projectConfigService = { + get: () => ({ + trust: { requiresSharedTrust: false }, + effective: { automations: [rule], providerMode: "guest" } + }) + } as any; + + const laneService = { + create: createLane, + list: async () => [{ id: "lane-primary", laneType: "primary" }], + getLaneWorktreePath: () => projectRoot, + getLaneBaseAndBranch: () => ({ baseRef: "main", branchRef: "main", worktreePath: projectRoot }) + } as any; + + const service = createAutomationService({ + db: db as any, + logger, + projectId, + projectRoot, + laneService, + projectConfigService, + }); + + try { + const run = await service.triggerManually({ id: "create-lane-only" }); + expect(run.status).toBe("succeeded"); + expect(createLane).toHaveBeenCalledWith(expect.objectContaining({ + name: "Fallback rule name", + })); + } finally { + fs.rmSync(projectRoot, { recursive: true, force: true }); + } + }); + + it("uses per-action targetLaneId for run-command instead of rule.execution.targetLaneId", async () => { + const { db, raw } = createInMemoryAdeDb(); + const logger = createLogger(); + const projectId = "proj"; + const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-automation-action-lane-root-")); + const ruleLane = fs.mkdtempSync(path.join(os.tmpdir(), "ade-automation-action-lane-rule-")); + const actionLane = fs.mkdtempSync(path.join(os.tmpdir(), "ade-automation-action-lane-action-")); + + const rule = { + id: "per-action-lane", + name: "Per-action lane", + trigger: { type: "manual" as const }, + triggers: [{ type: "manual" as const }], + execution: { + kind: "built-in" as const, + targetLaneId: "lane-rule", + builtIn: { + actions: [ + { type: "run-command" as const, command: "pwd", targetLaneId: "lane-action", timeoutMs: 10_000 }, + ], + }, + }, + actions: [{ type: "run-command" as const, command: "pwd", targetLaneId: "lane-action", timeoutMs: 10_000 }], + enabled: true, + }; + + const projectConfigService = { + get: () => ({ + trust: { requiresSharedTrust: false }, + effective: { automations: [rule], providerMode: "guest" } + }) + } as any; + + const laneService = { + list: async () => [ + { id: "lane-rule", laneType: "primary" }, + { id: "lane-action", laneType: "child" }, + ], + getLaneWorktreePath: (laneId: string) => laneId === "lane-action" ? actionLane : laneId === "lane-rule" ? ruleLane : projectRoot, + getLaneBaseAndBranch: () => ({ baseRef: "main", branchRef: "main", worktreePath: projectRoot }) + } as any; + + const service = createAutomationService({ + db: db as any, + logger, + projectId, + projectRoot, + laneService, + projectConfigService, + }); + + try { + const run = await service.triggerManually({ id: "per-action-lane" }); + expect(run.status).toBe("succeeded"); + const mapped = mapExecRows(raw.exec("select output from automation_action_results")); + const output = String(mapped[0]?.output ?? ""); + expect(output).toContain(actionLane); + expect(output).not.toContain(ruleLane); + } finally { + fs.rmSync(projectRoot, { recursive: true, force: true }); + fs.rmSync(ruleLane, { recursive: true, force: true }); + fs.rmSync(actionLane, { recursive: true, force: true }); + } + }); + + it("prefers per-action modelConfig.modelId and thinkingLevel over the rule defaults for agent-session", async () => { + const { db } = createInMemoryAdeDb(); + const logger = createLogger(); + const projectId = "proj"; + const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-automation-action-model-")); + const createSession = vi.fn(async () => ({ id: "session-action-model" })); + const runSessionTurn = vi.fn(async () => ({ outputText: "ok" })); + + const rule = { + id: "action-model", + name: "Action model", + enabled: true, + mode: "review", + reviewProfile: "quick", + trigger: { type: "manual" as const }, + triggers: [{ type: "manual" as const }], + executor: { mode: "automation-bot", targetId: null }, + modelConfig: { + orchestratorModel: { modelId: "openai/gpt-5.4-codex", thinkingLevel: "low" }, + }, + permissionConfig: { providers: { codex: "default" as const, opencode: "edit" as const } }, + toolPalette: [] as const, + contextSources: [], + memory: { mode: "project" as const }, + guardrails: { maxDurationMin: 5 }, + outputs: { disposition: "comment-only" as const, createArtifact: true }, + verification: { verifyBeforePublish: false, mode: "intervention" as const }, + billingCode: "auto:test", + execution: { + kind: "built-in" as const, + builtIn: { + actions: [ + { + type: "agent-session" as const, + prompt: "Summarize", + sessionTitle: "Summary", + modelConfig: { modelId: "opencode/openai/gpt-5.4", thinkingLevel: "high" as const }, + permissionConfig: { providers: { opencode: "full-auto" as const } }, + }, + ], + }, + }, + actions: [], + }; + + const projectConfigService = { + get: () => ({ + trust: { requiresSharedTrust: false }, + effective: { automations: [rule], providerMode: "guest" } + }) + } as any; + + const laneService = { + list: async () => [{ id: "lane-primary", laneType: "primary" }], + getLaneWorktreePath: () => projectRoot, + getLaneBaseAndBranch: () => ({ baseRef: "main", branchRef: "main", worktreePath: projectRoot }) + } as any; + + const service = createAutomationService({ + db: db as any, + logger, + projectId, + projectRoot, + laneService, + projectConfigService, + agentChatService: { + createSession, + runSessionTurn, + } as any, + }); + + try { + const run = await service.triggerManually({ id: "action-model" }); + expect(run.status).toBe("succeeded"); + expect(createSession).toHaveBeenCalledWith(expect.objectContaining({ + modelId: "opencode/openai/gpt-5.4", + reasoningEffort: "high", + permissionMode: "full-auto", + })); + expect(runSessionTurn).toHaveBeenCalledWith(expect.objectContaining({ + reasoningEffort: "high", + })); + } finally { + fs.rmSync(projectRoot, { recursive: true, force: true }); + } + }); + it("checks the budget cap against the resolved provider group", async () => { const { db } = createInMemoryAdeDb(); const logger = createLogger(); diff --git a/apps/desktop/src/main/services/automations/automationService.ts b/apps/desktop/src/main/services/automations/automationService.ts index e490cf173..944ec405a 100644 --- a/apps/desktop/src/main/services/automations/automationService.ts +++ b/apps/desktop/src/main/services/automations/automationService.ts @@ -703,6 +703,17 @@ function summarizeLegacyActions(actions: AutomationAction[]): string { return actions.map((action) => action.type).join(", "); } +function resolveTemplateString(template: string | undefined | null, trigger: TriggerContext): string { + const resolved = resolvePlaceholders(template ?? "", trigger); + if (typeof resolved === "string") return resolved.trim(); + if (resolved == null) return ""; + try { + return JSON.stringify(resolved).trim(); + } catch { + return String(resolved).trim(); + } +} + function mapMissionStatus(status: string, _verificationRequired: boolean): AutomationRunStatus { switch (status) { case "queued": @@ -1107,11 +1118,13 @@ export function createAutomationService({ return Math.max(0, Number(row?.max_position ?? 0)) + 1; }; - const computeAllowedToolList = (rule: AutomationRule, options: { publishPhase: boolean }): string[] => { + const computeAllowedToolList = (rule: AutomationRule, options: { publishPhase: boolean }, action?: AutomationAction | null): string[] => { const families = rule.toolPalette.filter((family) => options.publishPhase || !PUBLISH_CAPABLE_TOOL_FAMILIES.has(family)); const explicit = dedupeStrings([ ...(rule.permissionConfig?.cli?.allowedTools ?? []), ...(rule.permissionConfig?.providers?.allowedTools ?? []), + ...(action?.permissionConfig?.cli?.allowedTools ?? []), + ...(action?.permissionConfig?.providers?.allowedTools ?? []), ]); return dedupeStrings([ ...AUTOMATION_TOOL_BASELINE, @@ -1120,10 +1133,14 @@ export function createAutomationService({ ]); }; - const buildPermissionConfig = (rule: AutomationRule, options: { publishPhase: boolean }) => { - const allowedTools = computeAllowedToolList(rule, options); - const cli = rule.permissionConfig?.cli; - const providers = rule.permissionConfig?.providers; + const buildPermissionConfig = (rule: AutomationRule, options: { publishPhase: boolean }, action?: AutomationAction | null) => { + const allowedTools = computeAllowedToolList(rule, options, action); + const cli = action?.permissionConfig?.cli ?? rule.permissionConfig?.cli; + const inProcess = action?.permissionConfig?.inProcess ?? rule.permissionConfig?.inProcess; + const providers = { + ...(rule.permissionConfig?.providers ?? {}), + ...(action?.permissionConfig?.providers ?? {}), + }; return { ...(cli ? { @@ -1139,18 +1156,29 @@ export function createAutomationService({ allowedTools, }, }), - ...(rule.permissionConfig?.inProcess ? { inProcess: rule.permissionConfig.inProcess } : {}), + ...(inProcess ? { inProcess } : {}), providers: { - claude: rule.verification.mode === "dry-run" ? "plan" : (providers?.claude ?? "edit"), - codex: rule.verification.mode === "dry-run" ? "plan" : (providers?.codex ?? "default"), - opencode: rule.verification.mode === "dry-run" ? "plan" : (providers?.opencode ?? "edit"), - codexSandbox: providers?.codexSandbox ?? "workspace-write", - ...(providers?.writablePaths?.length ? { writablePaths: providers.writablePaths } : {}), + claude: rule.verification.mode === "dry-run" ? "plan" : (providers.claude ?? "edit"), + codex: rule.verification.mode === "dry-run" ? "plan" : (providers.codex ?? "default"), + cursor: rule.verification.mode === "dry-run" ? "plan" : (providers.cursor ?? "edit"), + opencode: rule.verification.mode === "dry-run" ? "plan" : (providers.opencode ?? "edit"), + codexSandbox: providers.codexSandbox ?? "workspace-write", + ...(providers.writablePaths?.length ? { writablePaths: providers.writablePaths } : {}), allowedTools, }, }; }; + const resolveProviderPermissionMode = ( + providerGroup: string, + permissionConfig: ReturnType, + ) => { + if (providerGroup === "claude") return permissionConfig.providers?.claude ?? "edit"; + if (providerGroup === "codex") return permissionConfig.providers?.codex ?? "default"; + if (providerGroup === "cursor") return permissionConfig.providers?.cursor ?? "edit"; + return permissionConfig.providers?.opencode ?? "edit"; + }; + const requiresPublishGate = (rule: AutomationRule): boolean => Boolean(rule.verification.verifyBeforePublish) && rule.verification.mode !== "dry-run" @@ -1624,9 +1652,14 @@ export function createAutomationService({ }; }; - const getConfiguredTargetLaneId = (rule: AutomationRule): string | null => { - const laneId = typeof rule.execution?.targetLaneId === "string" ? rule.execution.targetLaneId.trim() : ""; - return laneId.length ? laneId : null; + const trimToNull = (value: unknown): string | null => { + if (typeof value !== "string") return null; + const trimmed = value.trim(); + return trimmed.length ? trimmed : null; + }; + + const getConfiguredTargetLaneId = (rule: AutomationRule, action?: AutomationAction | null): string | null => { + return trimToNull(action?.targetLaneId) ?? trimToNull(rule.execution?.targetLaneId); }; const dispatchAdeAction = async ( @@ -1700,12 +1733,44 @@ export function createAutomationService({ await conflictService.runPrediction(trigger.laneId ? { laneId: trigger.laneId } : {}); return { status: "succeeded" }; } + if (action.type === "create-lane") { + const fallbackName = trigger.issue?.title ?? trigger.pr?.title ?? trigger.summary ?? rule.name; + const rendered = resolveTemplateString(action.laneNameTemplate, trigger); + const renderedClean = rendered && !/\{\{[^}]+\}\}/.test(rendered) ? rendered : ""; + const laneName = renderedClean || fallbackName; + if (!laneName.trim()) { + return { status: "failed", output: "create-lane action requires a lane name." }; + } + const description = resolveTemplateString(action.laneDescriptionTemplate, trigger) + || [ + trigger.issue ? `GitHub issue #${trigger.issue.number}` : null, + trigger.issue?.url ?? trigger.pr?.url ?? null, + trigger.summary ?? null, + ].filter(Boolean).join("\n"); + const parentLaneId = trimToNull(action.parentLaneId); + const lane = await laneService.create({ + name: laneName, + description, + ...(parentLaneId ? { parentLaneId } : {}), + }); + trigger.laneId = lane.id; + trigger.laneName = lane.name; + trigger.branch = lane.branchRef; + return { + status: "succeeded", + output: JSON.stringify({ + laneId: lane.id, + laneName: lane.name, + branchRef: lane.branchRef, + }), + }; + } if (action.type === "run-tests") { const suiteId = (action.suiteId ?? "").trim(); if (!suiteId) throw new Error("run-tests requires suiteId"); if (!testService) throw new Error("Test service unavailable"); const activeLanes = await laneService.list({ includeArchived: false }); - const configuredLaneId = getConfiguredTargetLaneId(rule); + const configuredLaneId = getConfiguredTargetLaneId(rule, action); const laneId = configuredLaneId ?? trigger.laneId ?? activeLanes.find((lane) => lane.laneType === "primary")?.id @@ -1729,7 +1794,7 @@ export function createAutomationService({ if (!agentChatServiceRef) { return { status: "failed", output: "Agent chat service is unavailable." }; } - const laneId = await resolveExecutionLaneId(rule, trigger); + const laneId = await resolveExecutionLaneId(rule, trigger, action); if (!laneId) { return { status: "failed", output: "No lane is available for this automation run." }; } @@ -1739,15 +1804,12 @@ export function createAutomationService({ } const interpolated = resolvePlaceholders(rawPrompt, trigger); const promptText = typeof interpolated === "string" ? interpolated : rawPrompt; - const { modelId, modelDescriptor, providerGroup } = resolveAutomationModelDescriptor(rule); + const { modelId, modelDescriptor, providerGroup } = resolveAutomationModelDescriptor(rule, action); const resolvedChat = resolveChatProviderForDescriptor(modelDescriptor); - const permissionConfig = buildPermissionConfig(rule, { publishPhase: false }); - const permissionMode = providerGroup === "claude" - ? permissionConfig.providers?.claude ?? "edit" - : providerGroup === "codex" - ? permissionConfig.providers?.codex ?? "default" - : permissionConfig.providers?.opencode ?? "edit"; - const reasoningEffort = rule.execution?.session?.reasoningEffort + const permissionConfig = buildPermissionConfig(rule, { publishPhase: false }, action); + const permissionMode = resolveProviderPermissionMode(providerGroup, permissionConfig); + const reasoningEffort = action.modelConfig?.thinkingLevel + ?? rule.execution?.session?.reasoningEffort ?? rule.modelConfig?.orchestratorModel?.thinkingLevel ?? null; const timeoutMs = Math.max( @@ -1811,7 +1873,7 @@ export function createAutomationService({ if (action.type === "run-command") { const command = (action.command ?? "").trim(); if (!command) throw new Error("run-command requires command"); - const laneId = getConfiguredTargetLaneId(rule) ?? trigger.laneId; + const laneId = getConfiguredTargetLaneId(rule, action) ?? trigger.laneId; const baseCwd = laneId ? laneService.getLaneWorktreePath(laneId) : projectRoot; const configuredCwd = (action.cwd ?? "").trim(); const cwdCandidate = configuredCwd.length @@ -1961,15 +2023,11 @@ export function createAutomationService({ } }; - const resolveExecutionLaneId = async (rule: AutomationRule, trigger: TriggerContext): Promise => { - const configuredLaneId = typeof rule.execution?.targetLaneId === "string" && rule.execution.targetLaneId.trim().length - ? rule.execution.targetLaneId.trim() - : null; + const resolveExecutionLaneId = async (rule: AutomationRule, trigger: TriggerContext, action?: AutomationAction | null): Promise => { + const configuredLaneId = trimToNull(action?.targetLaneId) ?? trimToNull(rule.execution?.targetLaneId); if (configuredLaneId) return configuredLaneId; - const triggerLaneId = typeof trigger.laneId === "string" && trigger.laneId.trim().length - ? trigger.laneId.trim() - : null; + const triggerLaneId = trimToNull(trigger.laneId); if (triggerLaneId) return triggerLaneId; try { @@ -1981,8 +2039,8 @@ export function createAutomationService({ } }; - const resolveAutomationModelDescriptor = (rule: AutomationRule) => { - const requestedModelId = rule.modelConfig?.orchestratorModel?.modelId; + const resolveAutomationModelDescriptor = (rule: AutomationRule, action?: AutomationAction | null) => { + const requestedModelId = action?.modelConfig?.modelId ?? rule.modelConfig?.orchestratorModel?.modelId; if (requestedModelId && !getModelById(requestedModelId)) { throw new Error(`Unknown model '${requestedModelId}'.`); } @@ -2048,11 +2106,7 @@ export function createAutomationService({ const dryRun = args.rule.verification.mode === "dry-run"; const permissionMode = verificationRequired || dryRun ? "plan" - : providerGroup === "claude" - ? permissionConfig.providers?.claude ?? "edit" - : providerGroup === "codex" - ? permissionConfig.providers?.codex ?? "default" - : permissionConfig.providers?.opencode ?? "edit"; + : resolveProviderPermissionMode(providerGroup, permissionConfig); const reasoningEffort = args.rule.execution?.session?.reasoningEffort ?? args.rule.modelConfig?.orchestratorModel?.thinkingLevel ?? null; const timeoutMs = Math.max( 15_000, diff --git a/apps/desktop/src/main/services/chat/agentChatService.test.ts b/apps/desktop/src/main/services/chat/agentChatService.test.ts index fa6346ea6..41fb806e9 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.test.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.test.ts @@ -939,6 +939,8 @@ describe("buildComputerUseDirective", () => { const result = buildComputerUseDirective(status); expect(result).toContain("Proof Capture"); expect(result).toContain("ingest_computer_use_artifacts"); + expect(result).toContain("capture visual proof first"); + expect(result).toContain("Console logs and text files are supporting diagnostics only"); }); }); @@ -2356,6 +2358,112 @@ describe("createAgentChatService", () => { expect(send).not.toHaveBeenCalledWith(expect.stringContaining("Continuity Summary")); }); + it("recreates Claude sessions fresh when a resumed SDK session rejects bypassPermissions", async () => { + let initialStreamCall = 0; + const initialSession = { + send: vi.fn().mockResolvedValue(undefined), + stream: vi.fn(() => (async function* () { + initialStreamCall += 1; + yield { + type: "system", + subtype: "init", + session_id: "sdk-initial", + slash_commands: [], + }; + if (initialStreamCall > 1) { + yield { + type: "assistant", + session_id: "sdk-initial", + message: { + content: [{ type: "text", text: "Primed" }], + usage: { input_tokens: 1, output_tokens: 1 }, + }, + }; + } + yield { type: "result", usage: { input_tokens: 1, output_tokens: 1 } }; + })()), + close: vi.fn(), + sessionId: "sdk-initial", + setPermissionMode: vi.fn().mockResolvedValue(undefined), + }; + vi.mocked(unstable_v2_createSession).mockReturnValue(initialSession as any); + + const { service } = createService(); + const session = await service.createSession({ + laneId: "lane-1", + provider: "claude", + model: "sonnet", + }); + await service.runSessionTurn({ + sessionId: session.id, + text: "prime", + timeoutMs: 15_000, + }); + const persistedAfterPrime = readPersistedChatState(session.id); + expect(persistedAfterPrime.lastLaneDirectiveKey).toBeTruthy(); + + writePersistedChatState(session.id, { + ...persistedAfterPrime, + sdkSessionId: "sdk-stale", + lastLaneDirectiveKey: persistedAfterPrime.lastLaneDirectiveKey, + claudePermissionMode: "bypassPermissions", + permissionMode: "full-auto", + }); + + const staleSession = { + send: vi.fn().mockResolvedValue(undefined), + stream: vi.fn(), + close: vi.fn(), + sessionId: "sdk-stale", + setPermissionMode: vi.fn().mockRejectedValue(new Error("mode rejected")), + }; + const freshSession = { + send: vi.fn().mockResolvedValue(undefined), + stream: vi.fn(() => (async function* () { + yield { + type: "system", + subtype: "init", + session_id: "sdk-fresh", + slash_commands: [], + }; + yield { + type: "assistant", + session_id: "sdk-fresh", + message: { + content: [{ type: "text", text: "Recovered" }], + usage: { input_tokens: 1, output_tokens: 1 }, + }, + }; + yield { type: "result", usage: { input_tokens: 1, output_tokens: 1 } }; + })()), + close: vi.fn(), + sessionId: "sdk-fresh", + setPermissionMode: vi.fn().mockResolvedValue(undefined), + }; + vi.mocked(unstable_v2_resumeSession).mockReset(); + vi.mocked(unstable_v2_resumeSession).mockReturnValue(staleSession as any); + vi.mocked(unstable_v2_createSession).mockReset(); + vi.mocked(unstable_v2_createSession).mockReturnValue(freshSession as any); + + const resumed = createService().service; + await resumed.resumeSession({ sessionId: session.id }); + const result = await resumed.runSessionTurn({ + sessionId: session.id, + text: "continue", + timeoutMs: 15_000, + }); + + expect(result.outputText).toContain("Recovered"); + expect(unstable_v2_resumeSession).toHaveBeenCalledWith("sdk-stale", expect.any(Object)); + expect(unstable_v2_createSession).toHaveBeenCalledWith(expect.objectContaining({ + permissionMode: "bypassPermissions", + allowDangerouslySkipPermissions: true, + })); + expect(staleSession.close).toHaveBeenCalled(); + expect(freshSession.send).toHaveBeenCalled(); + expect(readPersistedChatState(session.id).sdkSessionId).toBe("sdk-fresh"); + }); + it("persists a continuity snapshot and prewarms a fresh Claude session after identity session reset errors", async () => { const primarySend = vi.fn().mockResolvedValue(undefined); const recoverySend = vi.fn().mockResolvedValue(undefined); @@ -3994,6 +4102,7 @@ describe("createAgentChatService", () => { params: { turn: { id: "foreign-turn", + status: "inProgress", }, }, }); @@ -4019,6 +4128,67 @@ describe("createAgentChatService", () => { expect(events.filter((event) => event.turnId === "foreign-turn")).toHaveLength(0); }); + it("attaches to in-progress Codex turn notifications after app-server resume", async () => { + const events: Array<{ type: string; turnId?: string; text?: string; status?: string; turnStatus?: string }> = []; + const { service } = createService({ + onEvent: (event: AgentChatEventEnvelope) => { + events.push({ + type: event.event.type, + turnId: "turnId" in event.event ? event.event.turnId ?? undefined : undefined, + text: "text" in event.event ? event.event.text : undefined, + status: "status" in event.event ? event.event.status : undefined, + turnStatus: "turnStatus" in event.event ? event.event.turnStatus : undefined, + }); + }, + }); + + const session = await service.createSession({ + laneId: "lane-1", + provider: "codex", + model: "gpt-5.4", + }); + writePersistedChatState(session.id, { + ...readPersistedChatState(session.id), + threadId: "thread-resumed", + }); + + await service.resumeSession({ sessionId: session.id }); + mockState.emitCodexPayload({ + jsonrpc: "2.0", + method: "turn/started", + params: { + turn: { + id: "resumed-turn", + status: "inProgress", + }, + }, + }); + mockState.emitCodexPayload({ + jsonrpc: "2.0", + method: "item/agentMessage/delta", + params: { + turnId: "resumed-turn", + delta: "Continuing after reconnect", + }, + }); + mockState.emitCodexPayload({ + jsonrpc: "2.0", + method: "turn/completed", + params: { + turn: { + id: "resumed-turn", + status: "completed", + }, + }, + }); + + expect(events).toEqual(expect.arrayContaining([ + expect.objectContaining({ type: "status", turnId: "resumed-turn", turnStatus: "started" }), + expect.objectContaining({ type: "text", turnId: "resumed-turn", text: "Continuing after reconnect" }), + expect.objectContaining({ type: "done", turnId: "resumed-turn", status: "completed" }), + ])); + }); + it("ignores stale Codex lifecycle notifications from a foreign turn", async () => { const events: Array<{ type: string; turnId?: string; text?: string }> = []; const { service } = createService({ diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index b21f218d8..ad2a7b7b7 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -361,6 +361,8 @@ type ClaudeRuntime = { interruptEventsEmitted: boolean; /** Set when a reasoning effort change is requested mid-turn; flushed when idle. */ pendingSessionReset?: boolean; + /** Clear Claude SDK continuity on the deferred reset; used after mode changes the old session cannot apply. */ + pendingSessionResetClearSdkSessionId?: boolean; turnMemoryPolicyState: TurnMemoryPolicyState | null; /** Tool names the user has approved for the session via "Allow for Session". */ approvalOverrides: Set; @@ -627,6 +629,10 @@ function isCurrentCodexLifecycleTurn( return activeTurnId === turnId; } +function isCodexInProgressTurnStatus(value: unknown): boolean { + return value === "inProgress" || value === "in_progress"; +} + function rememberInterruptedCodexTurn(runtime: CodexRuntime, turnId: string | null | undefined): void { const normalizedTurnId = turnId?.trim() || null; if (!normalizedTurnId) return; @@ -1763,7 +1769,9 @@ export function buildComputerUseDirective( sections.push( [ "## Computer Use", - "You have computer-use capabilities available. ADE will automatically capture screenshots and other artifacts from your tool calls into the proof drawer — you do not need to manually call ingest_computer_use_artifacts.", + "You have computer-use capabilities available. The proof drawer is for reviewer-visible evidence: screenshots/images, screen recordings, and browser captures or traces.", + "When the user asks for proof, capture visual proof first. Console logs and text files are supporting diagnostics only; do not use them as the only proof unless the user explicitly asks for logs or visual capture fails and you say so.", + "ADE will automatically capture screenshots and other visual artifacts from your computer-use tool calls into the proof drawer — you do not need to manually call ingest_computer_use_artifacts for normal captures.", "", "Call `get_computer_use_backend_status` to check available backends before attempting computer use.", ].join("\n"), @@ -1819,7 +1827,7 @@ export function buildComputerUseDirective( sections.push( [ "### Proof Capture", - "ADE automatically captures artifacts from your computer-use tool calls. Screenshots, recordings, and traces are saved to the proof drawer automatically. You can also explicitly call `ingest_computer_use_artifacts` if you need to add additional context or artifacts from non-standard sources.", + "ADE automatically saves screenshots, recordings, and browser traces from computer-use tool calls to the proof drawer. Use `ingest_computer_use_artifacts` only to attach externally produced visual proof such as a screenshot/image/video/trace, or to add logs as secondary context alongside visual proof.", ].join("\n"), ); @@ -6944,37 +6952,44 @@ export function createAgentChatService(args: { } } - // Build the message — plain string for text-only, or SDKUserMessage with - // image content blocks (streaming input format per SDK docs). - const messageToSend = buildClaudeV2Message(basePromptText, resolvedAttachments, { - baseDir: managed.laneWorktreePath, - sessionId: runtime.sdkSessionId, - forceUserMessage: args.forceClaudeUserMessage, - }); const turnPermissionMode = resolveClaudeTurnPermissionMode(managed); - const sessionControl = getClaudeV2SessionControl(runtime.v2Session); + let sessionControl = getClaudeV2SessionControl(runtime.v2Session); if (typeof sessionControl.setPermissionMode === "function") { try { await sessionControl.setPermissionMode(turnPermissionMode); } catch (permErr) { - // Invalidate the V2 session so it is recreated with the correct - // mode, then rethrow so the turn follows the normal failure path. + // Invalidate the resumed V2 session and immediately start a fresh + // one. Some Claude modes, notably bypassPermissions, must be enabled + // when the underlying CLI session starts; resuming the old SDK id and + // trying to flip it in place can be rejected forever. logger.warn("agent_chat.v2_set_permission_mode_failed", { sessionId: managed.session.id, turnPermissionMode, error: String(permErr), }); - cancelClaudeWarmup(managed, runtime, "session_reset"); - try { runtime.v2Session?.close(); } catch { /* ignore */ } - runtime.v2Session = null; - runtime.v2WarmupDone = null; - throw new Error(`Permission mode change to '${turnPermissionMode}' was rejected by the SDK. The session will be recreated on the next attempt.`); + resetClaudeV2Session(managed, runtime, "session_reset", { clearSdkSessionId: true }); + const v2Opts = buildClaudeV2SessionOpts(managed, runtime); + runtime.v2Session = unstable_v2_createSession(v2Opts as any) as unknown as ClaudeV2Session; + sessionControl = getClaudeV2SessionControl(runtime.v2Session); + if (typeof sessionControl.setPermissionMode === "function") { + await sessionControl.setPermissionMode(turnPermissionMode); + } else if (turnPermissionMode === "plan") { + throw new Error("Claude plan mode is not available in this Claude SDK build."); + } } } else if (turnPermissionMode === "plan") { throw new Error("Claude plan mode is not available in this Claude SDK build."); } + // Build the message after permission-mode recovery, because rebuilding a + // fresh Claude SDK session clears runtime.sdkSessionId. + const messageToSend = buildClaudeV2Message(basePromptText, resolvedAttachments, { + baseDir: managed.laneWorktreePath, + sessionId: runtime.sdkSessionId, + forceUserMessage: args.forceClaudeUserMessage, + }); + // V2 pattern: send() then stream() per turn. Session stays alive between turns. bumpClaudeIdleDeadline(); await runtime.v2Session.send(messageToSend); @@ -7618,11 +7633,10 @@ export function createAgentChatService(args: { // Flush deferred session reset from mid-turn reasoning effort change if (runtime.pendingSessionReset) { + const clearSdkSessionId = runtime.pendingSessionResetClearSdkSessionId === true; runtime.pendingSessionReset = false; - cancelClaudeWarmup(managed, runtime, "session_reset"); - try { runtime.v2Session?.close(); } catch { /* ignore */ } - runtime.v2Session = null; - runtime.v2WarmupDone = null; + runtime.pendingSessionResetClearSdkSessionId = false; + resetClaudeV2Session(managed, runtime, "session_reset", { clearSdkSessionId }); } const doneModel = buildDoneModelPayload(); @@ -9129,6 +9143,18 @@ export function createAgentChatService(args: { const params = (payload.params as Record | null) ?? {}; const turnIdFromParams = extractCodexTurnId(params); const threadIdFromParams = extractCodexThreadId(params); + const startedTurn = method === "turn/started" + ? ((params.turn as { id?: unknown; status?: unknown } | null) ?? null) + : null; + const isResumedInProgressTurnStart = Boolean( + startedTurn + && runtime.threadResumed + && managed.session.threadId + && !runtime.awaitingTurnStart + && !runtime.activeTurnId + && !runtime.startedTurnId + && isCodexInProgressTurnStatus(startedTurn.status), + ); if ( threadIdFromParams @@ -9164,6 +9190,7 @@ export function createAgentChatService(args: { if ( turnIdFromParams && !isExpectedTurnStart + && !isResumedInProgressTurnStart && !isCurrentCodexLifecycleTurn(runtime, turnIdFromParams) ) { logger.warn(`[codex] ignoring ${method} for inactive turn ${turnIdFromParams} in session ${managed.session.id}`); @@ -9175,9 +9202,9 @@ export function createAgentChatService(args: { } if (method === "turn/started") { - const turn = (params.turn as { id?: unknown } | null) ?? null; + const turn = startedTurn; const turnId = typeof turn?.id === "string" ? turn.id : null; - if (!runtime.awaitingTurnStart && !runtime.activeTurnId && !runtime.startedTurnId) { + if (!runtime.awaitingTurnStart && !runtime.activeTurnId && !runtime.startedTurnId && !isResumedInProgressTurnStart) { logger.warn(`[codex] ignoring unsolicited turn/started for session ${managed.session.id}`); if (turnId) { runtime.ignoredTurnIds.add(turnId); @@ -10126,6 +10153,30 @@ export function createAgentChatService(args: { }); }; + const resetClaudeV2Session = ( + managed: ManagedChatSession, + runtime: ClaudeRuntime, + reason: "interrupt" | "teardown" | "session_reset" | "timeout", + options: { clearSdkSessionId?: boolean } = {}, + ): void => { + cancelClaudeWarmup(managed, runtime, reason); + try { runtime.v2Session?.close(); } catch { /* ignore */ } + runtime.v2Session = null; + runtime.v2WarmupDone = null; + if (options.clearSdkSessionId && runtime.sdkSessionId) { + logger.info("agent_chat.claude_sdk_session_cleared", { + sessionId: managed.session.id, + sdkSessionId: runtime.sdkSessionId, + reason, + }); + runtime.sdkSessionId = null; + managed.runtimeInvalidated = true; + refreshReconstructionContext(managed); + void maybeRefreshIdentityContinuitySummary(managed, "provider_reset"); + clearLaneDirectiveKey(managed); + } + }; + const cancelQueuedSteers = ( managed: ManagedChatSession, runtime: Pick, @@ -10398,7 +10449,17 @@ export function createAgentChatService(args: { const initialPermissionMode = resolveClaudeTurnPermissionMode(managed); const sessionControl = getClaudeV2SessionControl(runtime.v2Session); if (typeof sessionControl.setPermissionMode === "function") { - await sessionControl.setPermissionMode(initialPermissionMode); + try { + await sessionControl.setPermissionMode(initialPermissionMode); + } catch (permErr) { + logger.warn("agent_chat.v2_set_permission_mode_failed", { + sessionId: managed.session.id, + turnPermissionMode: initialPermissionMode, + error: String(permErr), + }); + resetClaudeV2Session(managed, runtime, "session_reset", { clearSdkSessionId: true }); + throw permErr; + } } await runtime.v2Session.send("System initialization check. Respond with only the word READY."); @@ -12907,9 +12968,7 @@ export function createAgentChatService(args: { } catch { // Session was created without --dangerously-skip-permissions. // Invalidate so it is recreated with the correct mode. - try { managed.runtime.v2Session?.close(); } catch { /* ignore */ } - managed.runtime.v2Session = null; - managed.runtime.v2WarmupDone = null; + resetClaudeV2Session(managed, managed.runtime, "session_reset", { clearSdkSessionId: true }); } } } @@ -12926,9 +12985,7 @@ export function createAgentChatService(args: { try { await control.setPermissionMode(claudePermMode); } catch { - try { managed.runtime.v2Session?.close(); } catch { /* ignore */ } - managed.runtime.v2Session = null; - managed.runtime.v2WarmupDone = null; + resetClaudeV2Session(managed, managed.runtime, "session_reset", { clearSdkSessionId: true }); } } } @@ -13927,11 +13984,9 @@ export function createAgentChatService(args: { // Defer session reset until the current turn completes — tearing down // a live session mid-turn would force the stream down the failure path. managed.runtime.pendingSessionReset = true; + managed.runtime.pendingSessionResetClearSdkSessionId = false; } else { - cancelClaudeWarmup(managed, managed.runtime, "session_reset"); - try { managed.runtime.v2Session?.close(); } catch { /* ignore */ } - managed.runtime.v2Session = null; - managed.runtime.v2WarmupDone = null; + resetClaudeV2Session(managed, managed.runtime, "session_reset"); } } } @@ -14028,17 +14083,17 @@ export function createAgentChatService(args: { // bypassPermissions on a session not started with // --dangerously-skip-permissions), invalidate the V2 session // so it is recreated with the correct mode on the next turn. - // When busy, only log the failure — don't tear down the active session. + // When busy, defer the reset so the active stream can finish. logger.warn("agent_chat.v2_set_permission_mode_failed", { sessionId: managed.session.id, turnPermissionMode, error: String(permErr), }); if (!managed.runtime.busy) { - cancelClaudeWarmup(managed, managed.runtime, "session_reset"); - try { managed.runtime.v2Session?.close(); } catch { /* ignore */ } - managed.runtime.v2Session = null; - managed.runtime.v2WarmupDone = null; + resetClaudeV2Session(managed, managed.runtime, "session_reset", { clearSdkSessionId: true }); + } else { + managed.runtime.pendingSessionReset = true; + managed.runtime.pendingSessionResetClearSdkSessionId = true; } } } diff --git a/apps/desktop/src/main/services/computerUse/computerUseArtifactBrokerService.test.ts b/apps/desktop/src/main/services/computerUse/computerUseArtifactBrokerService.test.ts index 114b7a03d..c67598580 100644 --- a/apps/desktop/src/main/services/computerUse/computerUseArtifactBrokerService.test.ts +++ b/apps/desktop/src/main/services/computerUse/computerUseArtifactBrokerService.test.ts @@ -74,6 +74,9 @@ describe("computerUseArtifactBrokerService", () => { }); const artifactId = ingested.artifacts[0]!.id; + const initial = broker.listArtifacts({ artifactId }); + expect(initial[0]?.reviewState).toBe("accepted"); + expect(initial[0]?.workflowState).toBe("evidence_only"); const routed = broker.routeArtifact({ artifactId, diff --git a/apps/desktop/src/main/services/computerUse/computerUseArtifactBrokerService.ts b/apps/desktop/src/main/services/computerUse/computerUseArtifactBrokerService.ts index 3af4cd33a..3e27fd1ef 100644 --- a/apps/desktop/src/main/services/computerUse/computerUseArtifactBrokerService.ts +++ b/apps/desktop/src/main/services/computerUse/computerUseArtifactBrokerService.ts @@ -58,7 +58,7 @@ type StoredArtifactRow = { created_at: string; }; -const DEFAULT_REVIEW_STATE: ComputerUseArtifactReviewState = "pending"; +const DEFAULT_REVIEW_STATE: ComputerUseArtifactReviewState = "accepted"; const DEFAULT_WORKFLOW_STATE: ComputerUseArtifactWorkflowState = "evidence_only"; type StoredLinkRow = { diff --git a/apps/desktop/src/main/services/ipc/registerIpc.ts b/apps/desktop/src/main/services/ipc/registerIpc.ts index a43bef464..fcd05aa6d 100644 --- a/apps/desktop/src/main/services/ipc/registerIpc.ts +++ b/apps/desktop/src/main/services/ipc/registerIpc.ts @@ -1539,6 +1539,7 @@ function buildIssueResolutionInstructionsFromThread(arg: LaunchPrIssueResolution export function registerIpc({ getCtx, getSyncService, + resolveSyncService, switchProjectFromDialog, closeCurrentProject, closeProjectByPath, @@ -1546,6 +1547,7 @@ export function registerIpc({ }: { getCtx: () => AppContext; getSyncService?: () => ReturnType | null | undefined; + resolveSyncService?: () => Promise | null | undefined>; switchProjectFromDialog: (selectedPath: string) => Promise; closeCurrentProject: () => Promise; closeProjectByPath: (projectRoot: string) => Promise; @@ -1560,8 +1562,10 @@ export function registerIpc({ return getCtx().syncService ?? null; }; - const requireSyncService = (): ReturnType => { - const service = getOptionalSyncService(); + const requireSyncService = async (): Promise> => { + const service = resolveSyncService + ? await resolveSyncService() + : getOptionalSyncService(); if (!service) { throw new Error("Sync service is not available."); } @@ -2353,15 +2357,15 @@ export function registerIpc({ }); ipcMain.handle(IPC.syncGetStatus, async (): Promise => { - return await requireSyncService().getStatus(); + return await (await requireSyncService()).getStatus(); }); ipcMain.handle(IPC.syncRefreshDiscovery, async (): Promise => { - return await requireSyncService().refreshDiscovery(); + return await (await requireSyncService()).refreshDiscovery(); }); ipcMain.handle(IPC.syncListDevices, async (): Promise => { - return await requireSyncService().listDevices(); + return await (await requireSyncService()).listDevices(); }); ipcMain.handle( @@ -2370,7 +2374,7 @@ export function registerIpc({ _event, arg: { name?: string; deviceType?: SyncPeerDeviceType }, ): Promise => { - return await requireSyncService().updateLocalDevice({ + return await (await requireSyncService()).updateLocalDevice({ name: typeof arg?.name === "string" ? arg.name : undefined, deviceType: arg?.deviceType, }); @@ -2380,42 +2384,42 @@ export function registerIpc({ ipcMain.handle( IPC.syncConnectToBrain, async (_event, arg: SyncDesktopConnectionDraft): Promise => { - return await requireSyncService().connectToBrain(arg); + return await (await requireSyncService()).connectToBrain(arg); }, ); ipcMain.handle(IPC.syncDisconnectFromBrain, async (): Promise => { - return await requireSyncService().disconnectFromBrain(); + return await (await requireSyncService()).disconnectFromBrain(); }); ipcMain.handle(IPC.syncForgetDevice, async (_event, arg: { deviceId: string }): Promise => { - return await requireSyncService().forgetDevice(typeof arg?.deviceId === "string" ? arg.deviceId : ""); + return await (await requireSyncService()).forgetDevice(typeof arg?.deviceId === "string" ? arg.deviceId : ""); }); ipcMain.handle(IPC.syncGetTransferReadiness, async (): Promise => { - return await requireSyncService().getTransferReadiness(); + return await (await requireSyncService()).getTransferReadiness(); }); ipcMain.handle(IPC.syncTransferBrainToLocal, async (): Promise => { - return await requireSyncService().transferBrainToLocal(); + return await (await requireSyncService()).transferBrainToLocal(); }); ipcMain.handle(IPC.syncGetPin, async (): Promise<{ pin: string | null }> => { - return { pin: requireSyncService().getPin() }; + return { pin: (await requireSyncService()).getPin() }; }); ipcMain.handle(IPC.syncSetPin, async (_event, pin: string): Promise => { - return await requireSyncService().setPin(typeof pin === "string" ? pin : ""); + return await (await requireSyncService()).setPin(typeof pin === "string" ? pin : ""); }); ipcMain.handle(IPC.syncClearPin, async (): Promise => { - return await requireSyncService().clearPin(); + return await (await requireSyncService()).clearPin(); }); ipcMain.handle( IPC.syncSetActiveLanePresence, async (_event, arg: { laneIds?: string[] | null }): Promise => { - await requireSyncService().setActiveLanePresence( + await (await requireSyncService()).setActiveLanePresence( Array.isArray(arg?.laneIds) ? arg.laneIds : [], ); }, diff --git a/apps/desktop/src/main/services/orchestrator/baseOrchestratorAdapter.ts b/apps/desktop/src/main/services/orchestrator/baseOrchestratorAdapter.ts index b67fce0a8..257096996 100644 --- a/apps/desktop/src/main/services/orchestrator/baseOrchestratorAdapter.ts +++ b/apps/desktop/src/main/services/orchestrator/baseOrchestratorAdapter.ts @@ -328,6 +328,8 @@ export function buildFullPrompt( [ "PROOF CAPTURE:", `- This step requires proof artifacts: ${hardProofRequirements.join(", ")}.`, + "- Treat generic proof as visual evidence first: screenshots/images, screen recordings, browser screenshots, or browser traces. Do not substitute console logs for visual proof when the user asks to see proof.", + "- Console logs are supporting diagnostics. Use `console_logs` alone only when that exact requirement is listed or the user explicitly asks for logs.", "- Prefer external computer-use backends first. Use `get_computer_use_backend_status` to see what ADE can ingest and prefer approved external tools such as `ext.*` backends or external CLIs like agent-browser when available.", "- After an external backend produces proof, call `ingest_computer_use_artifacts` so ADE can normalize, store, link, and publish the resulting evidence.", "- Use ADE-local tools (`get_environment_info`, `launch_app`, `interact_gui`, `screenshot_environment`, `record_environment`) only as fallback compatibility support when an external backend is not available for the step.", diff --git a/apps/desktop/src/main/services/projects/recentProjectSummary.test.ts b/apps/desktop/src/main/services/projects/recentProjectSummary.test.ts index e6316de86..930712ca0 100644 --- a/apps/desktop/src/main/services/projects/recentProjectSummary.test.ts +++ b/apps/desktop/src/main/services/projects/recentProjectSummary.test.ts @@ -4,7 +4,7 @@ import path from "node:path"; import { describe, expect, it } from "vitest"; import { openKvDb } from "../state/kvDb"; import { resolveAdeLayout } from "../../../shared/adeLayout"; -import { toRecentProjectSummary } from "./recentProjectSummary"; +import { inspectRecentProject, toRecentProjectSummary } from "./recentProjectSummary"; function createLogger() { return { @@ -122,14 +122,17 @@ describe("toRecentProjectSummary", () => { }); db.close(); - const summary = toRecentProjectSummary({ + const inspection = inspectRecentProject({ rootPath: projectRoot, displayName: "demo", lastOpenedAt: now, }); + const summary = inspection.summary; expect(summary.exists).toBe(true); expect(summary.laneCount).toBe(3); + expect(inspection.projectId).toBe("proj-recent"); + expect(inspection.defaultBaseRef).toBe("main"); }); it("falls back to git worktree metadata when no ADE lane registry exists", () => { diff --git a/apps/desktop/src/main/services/projects/recentProjectSummary.ts b/apps/desktop/src/main/services/projects/recentProjectSummary.ts index f5b803970..713db958e 100644 --- a/apps/desktop/src/main/services/projects/recentProjectSummary.ts +++ b/apps/desktop/src/main/services/projects/recentProjectSummary.ts @@ -16,6 +16,12 @@ type RecentProjectEntry = { lastOpenedAt: string; }; +export type RecentProjectInspection = { + summary: RecentProjectSummary; + projectId: string | null; + defaultBaseRef: string | null; +}; + type LaneCountRow = { lane_type: string | null; worktree_path: string | null; @@ -37,19 +43,62 @@ function laneExistsOnDisk(row: LaneCountRow, projectRoot: string): boolean { return candidatePath ? fs.existsSync(candidatePath) : false; } -function readAdeLaneCount(projectRoot: string): number | null { +function hasTable(db: DatabaseSyncType, tableName: string): boolean { + return Boolean( + db.prepare("select 1 as present from sqlite_master where type = 'table' and name = ? limit 1") + .get<{ present?: number }>(tableName)?.present, + ); +} + +type AdeProjectInspection = { + projectId: string | null; + defaultBaseRef: string | null; + laneCount: number | null; +}; + +const EMPTY_ADE_PROJECT: AdeProjectInspection = { + projectId: null, + defaultBaseRef: null, + laneCount: null, +}; + +function inspectAdeProject(projectRoot: string): AdeProjectInspection { const dbPath = resolveAdeLayout(projectRoot).dbPath; - if (!fs.existsSync(dbPath)) return null; + if (!fs.existsSync(dbPath)) return EMPTY_ADE_PROJECT; let db: DatabaseSyncType | null = null; try { db = new DatabaseSync(dbPath); db.exec("PRAGMA busy_timeout = 5000"); - const hasLanesTable = Boolean( - db.prepare("select 1 as present from sqlite_master where type = 'table' and name = ? limit 1") - .get<{ present?: number }>("lanes")?.present, - ); - if (!hasLanesTable) return null; + const hasProjectsTable = hasTable(db, "projects"); + const hasLanesTable = hasTable(db, "lanes"); + + const projectRow = hasProjectsTable + ? db.prepare( + ` + select id, default_base_ref as defaultBaseRef + from projects + where root_path = ? + order by last_opened_at desc, created_at desc + limit 1 + `, + ).get<{ id?: string; defaultBaseRef?: string | null }>(projectRoot) + ?? db.prepare( + ` + select id, default_base_ref as defaultBaseRef + from projects + order by last_opened_at desc, created_at desc + limit 1 + `, + ).get<{ id?: string; defaultBaseRef?: string | null }>() + : null; + + const projectId = projectRow?.id ?? null; + const defaultBaseRef = projectRow?.defaultBaseRef ?? null; + + if (!hasLanesTable) { + return { projectId, defaultBaseRef, laneCount: null }; + } const rows = db.prepare( ` @@ -64,9 +113,9 @@ function readAdeLaneCount(projectRoot: string): number | null { for (const row of rows) { if (laneExistsOnDisk(row, projectRoot)) count += 1; } - return count > 0 ? count : null; + return { projectId, defaultBaseRef, laneCount: count > 0 ? count : null }; } catch { - return null; + return EMPTY_ADE_PROJECT; } finally { db?.close(); } @@ -102,15 +151,24 @@ function readGitLaneCount(projectRoot: string): number | undefined { } } -export function toRecentProjectSummary(entry: RecentProjectEntry): RecentProjectSummary { +export function inspectRecentProject(entry: RecentProjectEntry): RecentProjectInspection { const exists = fs.existsSync(entry.rootPath); - const laneCount = exists ? (readAdeLaneCount(entry.rootPath) ?? readGitLaneCount(entry.rootPath)) : undefined; + const adeProject = exists ? inspectAdeProject(entry.rootPath) : EMPTY_ADE_PROJECT; + const laneCount = exists ? (adeProject.laneCount ?? readGitLaneCount(entry.rootPath)) : undefined; return { - rootPath: entry.rootPath, - displayName: entry.displayName, - lastOpenedAt: entry.lastOpenedAt, - exists, - laneCount, + summary: { + rootPath: entry.rootPath, + displayName: entry.displayName, + lastOpenedAt: entry.lastOpenedAt, + exists, + laneCount, + }, + projectId: adeProject.projectId, + defaultBaseRef: adeProject.defaultBaseRef, }; } + +export function toRecentProjectSummary(entry: RecentProjectEntry): RecentProjectSummary { + return inspectRecentProject(entry).summary; +} diff --git a/apps/desktop/src/main/services/sync/syncHostService.test.ts b/apps/desktop/src/main/services/sync/syncHostService.test.ts index d0b577e18..cf4964a66 100644 --- a/apps/desktop/src/main/services/sync/syncHostService.test.ts +++ b/apps/desktop/src/main/services/sync/syncHostService.test.ts @@ -672,6 +672,7 @@ describe.skipIf(!isCrsqliteAvailable())("syncHostService", () => { project: { ...project, id: "project-row-1", isCached: true }, connection, })), + completeProjectConnection: vi.fn(async () => {}), }; const host = createSyncHostService({ @@ -765,6 +766,21 @@ describe.skipIf(!isCrsqliteAvailable())("syncHostService", () => { projectId: project.id, rootPath: project.rootPath, }); + await vi.waitFor(() => { + expect(projectCatalogProvider.completeProjectConnection).toHaveBeenCalledWith({ + projectId: project.id, + rootPath: project.rootPath, + }, switchResult.payload); + }); + + projectCatalogProvider.listProjects.mockResolvedValueOnce({ + projects: [{ ...project, id: "project-row-2", isOpen: true }], + }); + await host.broadcastProjectCatalog(); + const broadcastCatalog = await client.queue.next("project_catalog"); + expect(broadcastCatalog.payload).toEqual({ + projects: [{ ...project, id: "project-row-2", isOpen: true }], + }); await client.close(); }); diff --git a/apps/desktop/src/main/services/sync/syncHostService.ts b/apps/desktop/src/main/services/sync/syncHostService.ts index 5388fe724..ba29978e3 100644 --- a/apps/desktop/src/main/services/sync/syncHostService.ts +++ b/apps/desktop/src/main/services/sync/syncHostService.ts @@ -183,6 +183,10 @@ type SyncHostServiceArgs = { projectCatalogProvider?: { listProjects: () => Promise; prepareProjectConnection: (args: SyncProjectSwitchRequestPayload) => Promise; + completeProjectConnection?: ( + args: SyncProjectSwitchRequestPayload, + result: SyncProjectSwitchResultPayload, + ) => Promise; }; onStateChanged?: () => void; notificationEventBus?: NotificationEventBus | null; @@ -1089,6 +1093,26 @@ export function createSyncHostService(args: SyncHostServiceArgs) { ws.send(encodeSyncEnvelope({ type, payload, requestId, compressionThresholdBytes })); } + function sendAndWait( + ws: WebSocket, + type: SyncEnvelope["type"], + payload: TPayload, + requestId?: string | null, + ): Promise { + if (ws.readyState === WebSocket.CLOSING || ws.readyState === WebSocket.CLOSED) { + return Promise.reject(new Error("Cannot send on closed WebSocket.")); + } + return new Promise((resolve, reject) => { + ws.send( + encodeSyncEnvelope({ type, payload, requestId, compressionThresholdBytes }), + (error) => { + if (error) reject(error); + else resolve(); + }, + ); + }); + } + async function buildProjectCatalogPayload(): Promise { if (!args.projectCatalogProvider) { return { projects: [] }; @@ -1117,7 +1141,14 @@ export function createSyncHostService(args: SyncHostServiceArgs) { } try { const result = await args.projectCatalogProvider.prepareProjectConnection(payload ?? {}); - send(peer.ws, "project_switch_result", result, requestId); + await sendAndWait(peer.ws, "project_switch_result", result, requestId); + try { + await args.projectCatalogProvider.completeProjectConnection?.(payload ?? {}, result); + } catch (completionError) { + args.logger.warn("sync_host.project_switch_completion_failed", { + message: completionError instanceof Error ? completionError.message : String(completionError), + }); + } } catch (error) { const message = error instanceof Error ? error.message : String(error); args.logger.warn("sync_host.project_switch_failed", { message }); @@ -2169,6 +2200,14 @@ export function createSyncHostService(args: SyncHostServiceArgs) { return buildBrainStatus(); }, + async broadcastProjectCatalog(): Promise { + const payload = await buildProjectCatalogPayload(); + for (const peer of peers) { + if (!peer.authenticated || peer.ws.readyState !== WebSocket.OPEN) continue; + send(peer.ws, "project_catalog", payload); + } + }, + /** * Push an in-app notification to a specific iOS peer over the WebSocket. * Used by the notification event bus as the foreground-delivery path. diff --git a/apps/desktop/src/main/services/sync/syncService.test.ts b/apps/desktop/src/main/services/sync/syncService.test.ts index cdfbefa0b..ff3481117 100644 --- a/apps/desktop/src/main/services/sync/syncService.test.ts +++ b/apps/desktop/src/main/services/sync/syncService.test.ts @@ -611,7 +611,7 @@ describe.skipIf(!isCrsqliteAvailable())("syncService", () => { // so simulate it here so we can assert the toggle re-exposes the token via getStatus. fs.writeFileSync(path.join(appPairingDir, "sync-bootstrap-token"), "toggled-token\n", "utf8"); - service.setHostStartupEnabled(true); + await service.setHostStartupEnabled(true); await vi.waitFor(() => { expect(createSyncHostServiceMock).toHaveBeenCalled(); @@ -828,4 +828,206 @@ describe.skipIf(!isCrsqliteAvailable())("syncService", () => { expect(disposeFirstAttempt).toHaveBeenCalledTimes(1); expect(service.getHostService()?.getPort()).toBe(8788); }, 30_000); + + describe("forceHostRole", () => { + it("ignores a persisted viewer-style saved draft and reports role 'brain'", async () => { + const projectRoot = makeProjectRoot("ade-sync-service-force-draft-"); + const appPairingDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-sync-service-force-draft-app-")); + // Pre-seed a saved draft + bootstrap token on disk so readSavedDraft would + // return a non-null draft pointing at a remote brain in the absence of forceHostRole. + fs.writeFileSync(path.join(appPairingDir, "sync-bootstrap-token"), "force-draft-token\n", "utf8"); + fs.writeFileSync( + path.join(appPairingDir, "sync-peer-draft.json"), + JSON.stringify({ + host: "10.0.0.9", + port: 8787, + authKind: "paired", + pairedDeviceId: "remote-brain", + lastRemoteDbVersion: 0, + }), + "utf8", + ); + const db = await openKvDb( + path.join(projectRoot, ".ade", "ade.db"), + createLogger() as any, + ); + + const service = createSyncService({ + db, + logger: createLogger() as any, + projectRoot, + phonePairingStateDir: appPairingDir, + fileService: { dispose: () => {} } as any, + laneService: { list: async () => [] } as any, + prService: {} as any, + sessionService: { list: () => [] } as any, + ptyService: {} as any, + computerUseArtifactBrokerService: {} as any, + missionService: { list: () => [] } as any, + agentChatService: { listSessions: async () => [] } as any, + processService: { listRuntime: () => [] } as any, + hostStartupEnabled: true, + forceHostRole: true, + } as any); + + activeDisposers.push(async () => { + await service.dispose(); + db.close(); + }); + + await service.initialize(); + const status = await service.getStatus(); + expect(status.role).toBe("brain"); + expect(status.clusterState?.brainDeviceId).toBe(status.localDevice.deviceId); + }, 30_000); + + it("reassigns the cluster brainDeviceId to the local device on init", async () => { + const projectRoot = makeProjectRoot("ade-sync-service-force-cluster-"); + const db = await openKvDb( + path.join(projectRoot, ".ade", "ade.db"), + createLogger() as any, + ); + // Seed a remote-brain cluster row so refreshRoleState must override it. + const now = "2026-04-01T00:00:00.000Z"; + db.run( + `insert into devices( + device_id, site_id, name, platform, device_type, created_at, updated_at, last_seen_at, last_host, last_port, tailscale_ip, ip_addresses_json, metadata_json + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + "remote-brain", + "remote-site", + "Remote host", + "macOS", + "desktop", + now, + now, + now, + "10.0.0.9", + 8787, + null, + JSON.stringify(["10.0.0.9"]), + JSON.stringify({}), + ], + ); + db.run( + `insert into sync_cluster_state(cluster_id, brain_device_id, brain_epoch, updated_at, updated_by_device_id) + values (?, ?, ?, ?, ?)`, + ["default", "remote-brain", 5, now, "remote-brain"], + ); + + const service = createSyncService({ + db, + logger: createLogger() as any, + projectRoot, + fileService: { dispose: () => {} } as any, + laneService: { list: async () => [] } as any, + prService: {} as any, + sessionService: { list: () => [] } as any, + ptyService: {} as any, + computerUseArtifactBrokerService: {} as any, + missionService: { list: () => [] } as any, + agentChatService: { listSessions: async () => [] } as any, + processService: { listRuntime: () => [] } as any, + forceHostRole: true, + } as any); + + activeDisposers.push(async () => { + await service.dispose(); + db.close(); + }); + + await service.initialize(); + const status = await service.getStatus(); + expect(status.role).toBe("brain"); + expect(status.clusterState?.brainDeviceId).toBe(status.localDevice.deviceId); + expect(status.clusterState?.brainDeviceId).not.toBe("remote-brain"); + expect(status.clusterState?.brainEpoch).toBe(6); + }, 30_000); + }); + + describe("setHostDiscoveryEnabled / setHostStartupEnabled", () => { + it("setHostDiscoveryEnabled no-ops when called with the unchanged value", async () => { + const projectRoot = makeProjectRoot("ade-sync-service-discovery-noop-"); + const db = await openKvDb( + path.join(projectRoot, ".ade", "ade.db"), + createLogger() as any, + ); + + const service = createSyncService({ + db, + logger: createLogger() as any, + projectRoot, + fileService: { dispose: () => {} } as any, + laneService: { list: async () => [] } as any, + prService: {} as any, + sessionService: { list: () => [] } as any, + ptyService: {} as any, + computerUseArtifactBrokerService: {} as any, + missionService: { list: () => [] } as any, + agentChatService: { listSessions: async () => [] } as any, + processService: { listRuntime: () => [] } as any, + }); + + activeDisposers.push(async () => { + await service.dispose(); + db.close(); + }); + + await service.initialize(); + const hostService = service.getHostService(); + expect(hostService).not.toBeNull(); + const setDiscoveryEnabledSpy = (hostService as any).setDiscoveryEnabled as ReturnType; + // Default is enabled; calling with the same value should not invoke the host again. + setDiscoveryEnabledSpy.mockClear(); + service.setHostDiscoveryEnabled(true); + service.setHostDiscoveryEnabled(true); + expect(setDiscoveryEnabledSpy).not.toHaveBeenCalled(); + // A change still propagates exactly once. + service.setHostDiscoveryEnabled(false); + expect(setDiscoveryEnabledSpy).toHaveBeenCalledTimes(1); + expect(setDiscoveryEnabledSpy).toHaveBeenCalledWith(false); + }, 30_000); + + it("setHostStartupEnabled returns an awaitable promise that resolves after refreshRoleState", async () => { + const projectRoot = makeProjectRoot("ade-sync-service-startup-await-"); + const appPairingDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-sync-service-startup-await-app-")); + const db = await openKvDb( + path.join(projectRoot, ".ade", "ade.db"), + createLogger() as any, + ); + + const service = createSyncService({ + db, + logger: createLogger() as any, + projectRoot, + phonePairingStateDir: appPairingDir, + fileService: { dispose: () => {} } as any, + laneService: { list: async () => [] } as any, + prService: {} as any, + sessionService: { list: () => [] } as any, + ptyService: {} as any, + computerUseArtifactBrokerService: {} as any, + missionService: { list: () => [] } as any, + agentChatService: { listSessions: async () => [] } as any, + processService: { listRuntime: () => [] } as any, + hostStartupEnabled: false, + } as any); + + activeDisposers.push(async () => { + await service.dispose(); + db.close(); + }); + + await service.initialize(); + expect(createSyncHostServiceMock).not.toHaveBeenCalled(); + + fs.writeFileSync(path.join(appPairingDir, "sync-bootstrap-token"), "await-token\n", "utf8"); + const result = service.setHostStartupEnabled(true); + expect(typeof (result as any)?.then).toBe("function"); + await result; + expect(createSyncHostServiceMock).toHaveBeenCalled(); + const enabledStatus = await service.getStatus(); + expect(enabledStatus.bootstrapToken).toBe("await-token"); + }, 30_000); + }); }); diff --git a/apps/desktop/src/main/services/sync/syncService.ts b/apps/desktop/src/main/services/sync/syncService.ts index 5e53cbfcf..b7e32845a 100644 --- a/apps/desktop/src/main/services/sync/syncService.ts +++ b/apps/desktop/src/main/services/sync/syncService.ts @@ -107,6 +107,12 @@ type SyncServiceArgs = { processService: ReturnType; hostStartupEnabled?: boolean; hostDiscoveryEnabled?: boolean; + /** + * Phone sync is hosted by the local desktop app. When enabled, legacy + * desktop-to-desktop viewer state stored in a project DB cannot demote the + * phone sync surface into viewer mode. + */ + forceHostRole?: boolean; onStatusChanged?: (snapshot: SyncRoleSnapshot) => void; /** * Optional notification bus forwarded to the sync host. The host publishes @@ -117,6 +123,10 @@ type SyncServiceArgs = { projectCatalogProvider?: { listProjects: () => Promise; prepareProjectConnection: (args: SyncProjectSwitchRequestPayload) => Promise; + completeProjectConnection?: ( + args: SyncProjectSwitchRequestPayload, + result: SyncProjectSwitchResultPayload, + ) => Promise; }; }; @@ -361,6 +371,7 @@ export function createSyncService(args: SyncServiceArgs) { let initialized = false; let hostStartupEnabled = args.hostStartupEnabled !== false; let hostDiscoveryEnabled = args.hostDiscoveryEnabled !== false; + const forceHostRole = args.forceHostRole === true; const isCrdtSyncAvailable = (): boolean => args.db.sync.isAvailable?.() !== false; const assertPhonePairingAvailable = (): void => { if (!hostStartupEnabled) { @@ -391,6 +402,7 @@ export function createSyncService(args: SyncServiceArgs) { }; const readSavedDraft = (): SyncDesktopConnectionDraft | null => { + if (forceHostRole) return null; if (!fs.existsSync(draftPath)) return null; const token = readToken(); return sanitizeDraft( @@ -430,6 +442,7 @@ export function createSyncService(args: SyncServiceArgs) { logger: args.logger, deviceRegistryService, onStatusChange: (status) => { + if (forceHostRole) return; if (status.savedDraft) { const token = readToken(); if (token) { @@ -572,6 +585,7 @@ export function createSyncService(args: SyncServiceArgs) { const resolveViewerDraftFromRegistry = (): SyncDesktopConnectionDraft | null => { + if (forceHostRole) return null; const cluster = deviceRegistryService.getClusterState(); const token = readToken(); if (!cluster || !token) return null; @@ -603,12 +617,20 @@ export function createSyncService(args: SyncServiceArgs) { syncPeerService.setSavedDraft(savedDraft); const localDevice = deviceRegistryService.ensureLocalDevice(); let cluster = deviceRegistryService.getClusterState(); - if (!cluster && !savedDraft) { + if (forceHostRole) { + if (!cluster || cluster.brainDeviceId !== localDevice.deviceId) { + cluster = deviceRegistryService.setClusterState({ + brainDeviceId: localDevice.deviceId, + brainEpoch: (cluster?.brainEpoch ?? 0) + 1, + updatedByDeviceId: localDevice.deviceId, + }); + } + } else if (!cluster && !savedDraft) { cluster = deviceRegistryService.bootstrapLocalBrainIfNeeded(); } - const isLocalBrain = cluster + const isLocalBrain = forceHostRole || (cluster ? cluster.brainDeviceId === localDevice.deviceId - : !savedDraft; + : !savedDraft); if (isLocalBrain) { if (syncPeerService.isConnected()) { syncPeerService.disconnect({ preserveDraft: true }); @@ -767,9 +789,9 @@ export function createSyncService(args: SyncServiceArgs) { const currentBrain = cluster ? deviceRegistryService.getDevice(cluster.brainDeviceId) : localDevice; - const isLocalBrain = cluster + const isLocalBrain = forceHostRole || (cluster ? cluster.brainDeviceId === localDevice.deviceId - : !savedDraft && !syncPeerService.isConnected(); + : !savedDraft && !syncPeerService.isConnected()); const role = isLocalBrain ? "brain" : "viewer"; const crdtSyncAvailable = isCrdtSyncAvailable(); const canHostPhonePairing = role === "brain" && hostStartupEnabled && crdtSyncAvailable; @@ -829,14 +851,16 @@ export function createSyncService(args: SyncServiceArgs) { }, setHostDiscoveryEnabled(enabled: boolean): void { + if (hostDiscoveryEnabled === enabled) return; hostDiscoveryEnabled = enabled; hostService?.setDiscoveryEnabled(enabled); void emitStatus(); }, - setHostStartupEnabled(enabled: boolean): void { + async setHostStartupEnabled(enabled: boolean): Promise { + if (hostStartupEnabled === enabled) return; hostStartupEnabled = enabled; - void refreshRoleState(); + await refreshRoleState(); }, async updateLocalDevice(argsIn: { diff --git a/apps/desktop/src/renderer/components/automations/ActionList.tsx b/apps/desktop/src/renderer/components/automations/ActionList.tsx index f50c257c7..f65052d6f 100644 --- a/apps/desktop/src/renderer/components/automations/ActionList.tsx +++ b/apps/desktop/src/renderer/components/automations/ActionList.tsx @@ -1,6 +1,7 @@ import { useRef, useState } from "react"; import { Code, + GitBranch, Lightning, Plus, Rocket, @@ -9,11 +10,12 @@ import { Warning, } from "@phosphor-icons/react"; import type { ElementType } from "react"; -import type { TestSuiteDefinition } from "../../../shared/types"; +import type { ModelConfig, TestSuiteDefinition } from "../../../shared/types"; import { cn } from "../ui/cn"; import { ActionRow, type ActionRowKind, type ActionRowValue } from "./ActionRow"; const ADD_OPTIONS: Array<{ kind: ActionRowKind; label: string; icon: ElementType; disabled?: boolean; hint?: string }> = [ + { kind: "create-lane", label: "Create lane", icon: GitBranch }, { kind: "agent-session", label: "Agent session", icon: Lightning }, { kind: "ade-action", label: "Run ADE action", icon: Code }, { kind: "run-tests", label: "Run tests", icon: TestTube }, @@ -24,6 +26,12 @@ const ADD_OPTIONS: Array<{ kind: ActionRowKind; label: string; icon: ElementType function createBlankAction(kind: ActionRowKind, suites: TestSuiteDefinition[]): ActionRowValue { switch (kind) { + case "create-lane": + return { + kind, + laneNameTemplate: "{{trigger.issue.title}}", + laneDescriptionTemplate: "GitHub issue #{{trigger.issue.number}}\n{{trigger.issue.url}}\n\n{{trigger.issue.body}}", + }; case "agent-session": return { kind, prompt: "", sessionTitle: "" }; case "ade-action": @@ -48,12 +56,18 @@ function newKey(): string { export function ActionList({ actions, + lanes, suites, + fallbackModel, onChange, + onOpenAiSettings, }: { actions: ActionRowValue[]; + lanes: Array<{ id: string; name: string }>; suites: TestSuiteDefinition[]; + fallbackModel: ModelConfig; onChange: (next: ActionRowValue[]) => void; + onOpenAiSettings?: () => void; }) { const [menuOpen, setMenuOpen] = useState(false); // Stable per-row keys that survive reorders so React preserves focus/DOM @@ -108,10 +122,13 @@ export function ActionList({ index={index} total={actions.length} value={action} + lanes={lanes} suites={suites} + fallbackModel={fallbackModel} onChange={(next) => updateAction(index, next)} onRemove={() => removeAction(index)} onMove={(direction) => moveAction(index, direction)} + onOpenAiSettings={onOpenAiSettings} /> ))} diff --git a/apps/desktop/src/renderer/components/automations/ActionRow.tsx b/apps/desktop/src/renderer/components/automations/ActionRow.tsx index 63c25c8c0..bf4a7296f 100644 --- a/apps/desktop/src/renderer/components/automations/ActionRow.tsx +++ b/apps/desktop/src/renderer/components/automations/ActionRow.tsx @@ -3,6 +3,7 @@ import { ArrowUp, Code, Gear, + GitBranch, Lightning, Rocket, TerminalWindow, @@ -11,13 +12,20 @@ import { Warning, } from "@phosphor-icons/react"; import type { ElementType } from "react"; -import type { TestSuiteDefinition } from "../../../shared/types"; +import type { + MissionPermissionConfig, + ModelConfig, + TestSuiteDefinition, +} from "../../../shared/types"; import { Chip } from "../ui/Chip"; import { cn } from "../ui/cn"; +import { ModelSelector } from "../missions/ModelSelector"; +import { permissionControlsForModel, patchPermissionConfig } from "./permissionControls"; import { INPUT_CLS, INPUT_STYLE } from "./shared"; import { AdeActionEditor, type AdeActionValue } from "./AdeActionEditor"; export type ActionRowKind = + | "create-lane" | "agent-session" | "ade-action" | "run-tests" @@ -30,6 +38,13 @@ export type ActionRowValue = { // Agent-session prompt?: string; sessionTitle?: string; + targetLaneId?: string | null; + modelConfig?: ModelConfig; + permissionConfig?: MissionPermissionConfig; + // Create lane + laneNameTemplate?: string; + laneDescriptionTemplate?: string; + parentLaneId?: string | null; // ade-action adeAction?: AdeActionValue; // run-tests @@ -42,6 +57,7 @@ export type ActionRowValue = { }; const KIND_META: Record = { + "create-lane": { label: "Create lane", icon: GitBranch, accent: "#2DD4BF" }, "agent-session": { label: "Agent session", icon: Lightning, accent: "#38BDF8" }, "ade-action": { label: "Run ADE action", icon: Code, accent: "#A78BFA" }, "run-tests": { label: "Run tests", icon: TestTube, accent: "#22C55E" }, @@ -54,21 +70,32 @@ export function ActionRow({ index, total, value, + lanes, suites, + fallbackModel, onChange, onRemove, onMove, + onOpenAiSettings, }: { index: number; total: number; value: ActionRowValue; + lanes: Array<{ id: string; name: string }>; suites: TestSuiteDefinition[]; + fallbackModel: ModelConfig; onChange: (next: ActionRowValue) => void; onRemove: () => void; onMove: (direction: -1 | 1) => void; + onOpenAiSettings?: () => void; }) { const meta = KIND_META[value.kind]; const Icon = meta.icon; + const activeModel = value.modelConfig ?? fallbackModel; + const permissionMeta = permissionControlsForModel(activeModel.modelId); + const currentPermission = permissionMeta + ? value.permissionConfig?.providers?.[permissionMeta.key] ?? "" + : ""; return (
{index + 1}. {meta.label} + {value.kind === "create-lane" ? ( + sets lane + ) : null} + {value.kind === "agent-session" && value.modelConfig ? ( + custom model + ) : null}