diff --git a/.ade/.gitignore b/.ade/.gitignore index d93703639..ed1aa1c15 100644 --- a/.ade/.gitignore +++ b/.ade/.gitignore @@ -1,32 +1,19 @@ -# Machine-local ADE state -local.yaml -local.secret.yaml -ade.db -ade.db-* -ade.db-wal -embeddings.db -ade.sock -artifacts/ -transcripts/ -cache/ -worktrees/ -secrets/ +# ADE ignores local runtime state by default. +* -# Local-only generated runtime docs/state -agents/ -cto/CURRENT.md -cto/MEMORY.md -cto/core-memory.json -cto/daily/ -cto/sessions.jsonl -cto/subordinate-activity.jsonl -cto/openclaw-history.json -cto/openclaw-idempotency.json -cto/openclaw-outbox.json -cto/openclaw-routes.json -cto/openclaw-device.json -context/ -memory/ -history/ -reflections/ -context/*.ade.md +# Shared ADE project config +!.gitignore +!ade.yaml +!cto/ +!cto/identity.yaml + +# Shared user-authored ADE assets +!templates/ +!templates/** +!skills/ +!skills/** +!workflows/ +!workflows/linear/ +!workflows/linear/** +!project-icons/ +!project-icons/** diff --git a/apps/ade-cli/src/cli.test.ts b/apps/ade-cli/src/cli.test.ts index 8c340f74d..3b738cc99 100644 --- a/apps/ade-cli/src/cli.test.ts +++ b/apps/ade-cli/src/cli.test.ts @@ -1515,6 +1515,65 @@ describe("ADE CLI", () => { }); }); + it("automations create with implicit reuse accepts --lane", () => { + const plan = buildCliPlan([ + "automations", + "create", + "--text", + "id: r1\n", + "--lane", + "lane-99", + ]); + expect(plan.kind).toBe("execute"); + if (plan.kind !== "execute") return; + expect(plan.steps[0]?.params).toMatchObject({ + arguments: { + args: { + draft: { + execution: { targetLaneId: "lane-99" }, + }, + }, + }, + }); + }); + + it("automations create accepts require-on-trigger lane mode without a target lane", () => { + const plan = buildCliPlan([ + "automations", + "create", + "--text", + "id: r1\n", + "--lane-mode", + "require-on-trigger", + ]); + expect(plan.kind).toBe("execute"); + if (plan.kind !== "execute") return; + expect(plan.steps[0]?.params).toMatchObject({ + arguments: { + args: { + draft: { + execution: { laneMode: "require-on-trigger" }, + }, + }, + }, + }); + }); + + it("automations create rejects --lane with --lane-mode require-on-trigger", () => { + expect(() => + buildCliPlan([ + "automations", + "create", + "--text", + "id: r1\n", + "--lane-mode", + "require-on-trigger", + "--lane", + "lane-1", + ]), + ).toThrow(/--lane is only valid with --lane-mode reuse/); + }); + it("automations create with --lane-name-preset custom accepts --lane-name-template", () => { const plan = buildCliPlan([ "automations", @@ -1602,7 +1661,7 @@ describe("ADE CLI", () => { "--lane-mode", "bogus", ]), - ).toThrow(/--lane-mode must be one of create, reuse/); + ).toThrow(/--lane-mode must be one of create, reuse, require-on-trigger/); }); it("automations runs accepts a --status filter", () => { @@ -1731,6 +1790,19 @@ describe("ADE CLI", () => { }); }); + it("automations trigger aliases run and forwards --lane as laneId", () => { + const plan = buildCliPlan(["automations", "trigger", "rule-42", "--lane", "lane-7"]); + expect(plan.kind).toBe("execute"); + if (plan.kind !== "execute") return; + expect(plan.steps[0]?.params).toMatchObject({ + arguments: { + domain: "automations", + action: "triggerManually", + args: { id: "rule-42", laneId: "lane-7" }, + }, + }); + }); + it("automations runs passes through --rule and --limit as filters", () => { const plan = buildCliPlan([ "automations", diff --git a/apps/ade-cli/src/cli.ts b/apps/ade-cli/src/cli.ts index 8f60afdd0..b72c4e426 100644 --- a/apps/ade-cli/src/cli.ts +++ b/apps/ade-cli/src/cli.ts @@ -1209,13 +1209,15 @@ const HELP_BY_COMMAND: Record = { $ ade automations update --from-file $ ade automations delete Remove a local rule $ ade automations toggle --enabled true|false - $ ade automations run [--dry-run] Trigger a rule manually + $ ade automations run [--lane ] [--dry-run] + $ ade automations trigger [--lane ] + Trigger a rule manually $ ade automations runs [--rule ] [--status ] [--limit 50] $ ade automations run-show [--json] Inspect a run $ ade automations example Print an example rule (stdout) Lane mode flags (apply to create/update on top of --from-file/--stdin/--text): - --lane-mode Spawn a new lane per run, or reuse one + --lane-mode Create, reuse, or require lane at trigger time --lane Target lane (only with --lane-mode reuse) --lane-name-preset --lane-name-template Template (only with preset custom) @@ -3706,7 +3708,7 @@ function parseDraftInput(args: string[]): JsonObject { return parsed; } -const AUTOMATION_LANE_MODES = ["create", "reuse"] as const; +const AUTOMATION_LANE_MODES = ["create", "reuse", "require-on-trigger"] as const; const AUTOMATION_LANE_NAME_PRESETS = ["issue-title", "issue-num-title", "pr-title-author", "custom"] as const; const AUTOMATION_RUN_STATUSES = ["queued", "running", "succeeded", "failed", "cancelled", "paused", "all"] as const; @@ -3737,20 +3739,24 @@ function applyLaneFlagsToDraft(draft: JsonObject, args: string[]): JsonObject { return draft; } - if (laneId != null && laneMode === "create") { + const existingExecution = isRecord(draft.execution) ? draft.execution : {}; + const effectiveLaneMode = + laneMode + ?? (asString(existingExecution.laneMode) as AutomationLaneModeFlag | null); + + if (laneId != null && effectiveLaneMode != null && effectiveLaneMode !== "reuse") { throw new CliUsageError("--lane is only valid with --lane-mode reuse."); } - if (preset != null && laneMode === "reuse") { + if (preset != null && effectiveLaneMode !== "create") { throw new CliUsageError("--lane-name-preset is only valid with --lane-mode create."); } if (template != null && preset != null && preset !== "custom") { throw new CliUsageError("--lane-name-template is only valid with --lane-name-preset custom."); } - if (template != null && preset == null && laneMode !== "create") { + if (template != null && preset == null && effectiveLaneMode !== "create") { throw new CliUsageError("--lane-name-template requires --lane-mode create (with --lane-name-preset custom)."); } - const existingExecution = isRecord(draft.execution) ? draft.execution : {}; const execution: JsonObject = { ...existingExecution }; if (laneMode != null) execution.laneMode = laneMode; if (laneId != null) execution.targetLaneId = laneId; @@ -3867,7 +3873,7 @@ function buildAutomationsPlan(args: string[]): CliPlan { }; } - if (sub === "run") { + if (sub === "run" || sub === "trigger") { const id = requireValue(readValue(args, ["--id"]) ?? firstPositional(args), "rule id"); const dryRun = readFlag(args, ["--dry-run"]); const laneId = readLaneId(args); diff --git a/apps/desktop/src/main/services/automations/automationPlannerService.test.ts b/apps/desktop/src/main/services/automations/automationPlannerService.test.ts index 27a077bb0..456f19693 100644 --- a/apps/desktop/src/main/services/automations/automationPlannerService.test.ts +++ b/apps/desktop/src/main/services/automations/automationPlannerService.test.ts @@ -363,6 +363,86 @@ describe("automationPlannerService.validateDraft", () => { expect((res.normalized?.actions[2] as any).targetLaneId).toBe("lane-conflict"); }); + it("preserves require-on-trigger lane mode without requiring a target lane", () => { + const { planner } = getPlanner({ suites: [] }); + const draft = createDraft({ + name: "Require trigger lane", + execution: { kind: "agent-session", laneMode: "require-on-trigger" } as any, + prompt: "Use the lane supplied by the caller.", + }); + + const res = planner.validateDraft({ draft, confirmations: [] }); + expect(res.ok).toBe(true); + expect(res.normalized?.execution).toMatchObject({ + kind: "agent-session", + laneMode: "require-on-trigger", + }); + }); + + it("normalizes legacy prompt-at-run lane mode to require-on-trigger", () => { + const { planner } = getPlanner({ suites: [] }); + const draft = createDraft({ + name: "Legacy prompt at run", + execution: { kind: "agent-session", laneMode: "prompt-at-run" } as any, + prompt: "Use the selected lane.", + }); + + const res = planner.validateDraft({ draft, confirmations: [] }); + expect(res.ok).toBe(true); + expect(res.normalized?.execution).toMatchObject({ + kind: "agent-session", + laneMode: "require-on-trigger", + }); + }); + + it("rejects targetLaneId when lane mode requires the trigger lane", () => { + const { planner } = getPlanner({ suites: [] }); + const draft = createDraft({ + name: "Conflicting trigger lane", + execution: { + kind: "agent-session", + laneMode: "require-on-trigger", + targetLaneId: "lane-fixed", + } as any, + prompt: "This should choose at trigger time.", + }); + + const res = planner.validateDraft({ draft, confirmations: [] }); + expect(res.ok).toBe(false); + expect(res.issues).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + level: "error", + path: "execution.targetLaneId", + }), + ]), + ); + }); + + it("rejects per-action targetLaneId when lane mode requires the trigger lane", () => { + const { planner } = getPlanner({ suites: [] }); + const draft = createDraft({ + name: "Conflicting step lane", + execution: { + kind: "built-in", + laneMode: "require-on-trigger", + } as any, + actions: [{ type: "run-command", command: "pwd", targetLaneId: "lane-fixed" } as any], + legacyActions: [{ type: "run-command", command: "pwd", targetLaneId: "lane-fixed" } as any], + }); + + const res = planner.validateDraft({ draft, confirmations: ["confirm.run-command"] }); + expect(res.ok).toBe(false); + expect(res.issues).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + level: "error", + path: "actions[0].targetLaneId", + }), + ]), + ); + }); + 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-")); @@ -396,6 +476,36 @@ describe("automationPlannerService.validateDraft", () => { fs.rmSync(draftLane, { recursive: true, force: true }); } }); + + it("preserves output disposition and verification settings", () => { + const { planner } = getPlanner({ suites: [] }); + const draft = createDraft({ + name: "Publish settings", + execution: { kind: "agent-session" } as any, + prompt: "Prepare a draft PR.", + outputs: { + disposition: "open-pr-draft", + createArtifact: false, + notificationChannel: "automation-alerts", + }, + verification: { + verifyBeforePublish: true, + mode: "dry-run", + }, + }); + + const res = planner.validateDraft({ draft, confirmations: [] }); + expect(res.ok).toBe(true); + expect(res.normalized?.outputs).toMatchObject({ + disposition: "open-pr-draft", + createArtifact: false, + notificationChannel: "automation-alerts", + }); + expect(res.normalized?.verification).toMatchObject({ + verifyBeforePublish: true, + mode: "dry-run", + }); + }); }); function createDraft( diff --git a/apps/desktop/src/main/services/automations/automationPlannerService.ts b/apps/desktop/src/main/services/automations/automationPlannerService.ts index dca34f417..bb3d7bc6c 100644 --- a/apps/desktop/src/main/services/automations/automationPlannerService.ts +++ b/apps/desktop/src/main/services/automations/automationPlannerService.ts @@ -78,6 +78,34 @@ function safeTrim(value: unknown): string { return typeof value === "string" ? value.trim() : ""; } +function normalizeLaneMode(value: unknown): NonNullable["laneMode"] | undefined { + const raw = safeTrim(value); + if (raw === "provided" || raw === "prompt-at-run") return "require-on-trigger"; + return raw === "create" || raw === "reuse" || raw === "require-on-trigger" ? raw : undefined; +} + +function normalizeLaneNamePreset(value: unknown): NonNullable["laneNamePreset"] | undefined { + const raw = safeTrim(value); + return raw === "issue-title" || raw === "issue-num-title" || raw === "pr-title-author" || raw === "custom" + ? raw + : undefined; +} + +function normalizeOutputDisposition(value: unknown): AutomationRule["outputs"]["disposition"] { + const raw = safeTrim(value); + return raw === "open-task" || + raw === "open-lane" || + raw === "prepare-patch" || + raw === "open-pr-draft" || + raw === "comment-only" + ? raw + : "comment-only"; +} + +function normalizeVerificationMode(value: unknown): NonNullable { + return safeTrim(value) === "dry-run" ? "dry-run" : "intervention"; +} + function extractFirstJsonObject(text: string): string | null { const raw = text.trim(); if (!raw) return null; @@ -797,10 +825,40 @@ function normalizeDraft(args: { } const requestedExecution = args.draft.execution; + const requestedLaneMode = normalizeLaneMode(requestedExecution?.laneMode); + const requestedLaneNamePreset = normalizeLaneNamePreset(requestedExecution?.laneNamePreset); + const requestedLaneNameTemplate = requestedLaneNamePreset === "custom" + ? safeTrim(requestedExecution?.laneNameTemplate) + : ""; + if (requestedLaneMode === "require-on-trigger" && safeTrim(requestedExecution?.targetLaneId)) { + issues.push({ + level: "error", + path: "execution.targetLaneId", + message: "targetLaneId is not allowed when lane must be supplied at trigger time.", + }); + } + if (requestedLaneMode === "require-on-trigger") { + normalizedActions.forEach((action, index) => { + if (!safeTrim(action.targetLaneId)) return; + issues.push({ + level: "error", + path: `actions[${index}].targetLaneId`, + message: "Step lane overrides are ignored when lane must be supplied at trigger time.", + }); + }); + } + const laneExecutionFields = { + ...(requestedLaneMode ? { laneMode: requestedLaneMode } : {}), + ...(requestedLaneMode === "create" && requestedLaneNamePreset ? { laneNamePreset: requestedLaneNamePreset } : {}), + ...(requestedLaneMode === "create" && requestedLaneNamePreset === "custom" && requestedLaneNameTemplate + ? { laneNameTemplate: requestedLaneNameTemplate } + : {}), + }; const execution = requestedExecution?.kind === "agent-session" || requestedExecution?.kind === "mission" || requestedExecution?.kind === "built-in" ? { kind: requestedExecution.kind, + ...laneExecutionFields, ...(safeTrim(requestedExecution.targetLaneId) ? { targetLaneId: safeTrim(requestedExecution.targetLaneId) } : {}), ...(requestedExecution.kind === "agent-session" ? { @@ -822,8 +880,8 @@ function normalizeDraft(args: { ...(requestedExecution.kind === "built-in" ? { builtIn: { actions: normalizedActions } } : {}), } : normalizedActions.length > 0 - ? { kind: "built-in" as const, builtIn: { actions: normalizedActions } } - : { kind: "agent-session" as const, session: {} }; + ? { kind: "built-in" as const, ...laneExecutionFields, builtIn: { actions: normalizedActions } } + : { kind: "agent-session" as const, ...laneExecutionFields, session: {} }; if (execution.kind === "built-in" && normalizedActions.length === 0) { issues.push({ @@ -903,13 +961,13 @@ function normalizeDraft(args: { ...(args.draft.guardrails?.activeHours ? { activeHours: args.draft.guardrails.activeHours } : {}), }, outputs: { - disposition: "comment-only", + disposition: normalizeOutputDisposition(args.draft.outputs?.disposition), ...(typeof args.draft.outputs?.createArtifact === "boolean" ? { createArtifact: args.draft.outputs.createArtifact } : { createArtifact: true }), ...(safeTrim(args.draft.outputs?.notificationChannel) ? { notificationChannel: safeTrim(args.draft.outputs?.notificationChannel) } : {}), }, verification: { - verifyBeforePublish: false, - mode: "intervention", + verifyBeforePublish: Boolean(args.draft.verification?.verifyBeforePublish), + mode: normalizeVerificationMode(args.draft.verification?.mode), }, billingCode: safeTrim(args.draft.billingCode) || `auto:${slugify(name)}`, includeProjectContext, @@ -1349,6 +1407,9 @@ export function createAutomationPlannerService({ if (execution.kind === "built-in") { notes.push("Built-in tasks run directly without launching a mission or chat thread."); } + if (execution.laneMode === "require-on-trigger") { + notes.push("Lane resolution: trigger caller must supply a lane."); + } return { normalized, actions, notes, issues }; } diff --git a/apps/desktop/src/main/services/automations/automationService.test.ts b/apps/desktop/src/main/services/automations/automationService.test.ts index cfee67633..826c90447 100644 --- a/apps/desktop/src/main/services/automations/automationService.test.ts +++ b/apps/desktop/src/main/services/automations/automationService.test.ts @@ -5,7 +5,7 @@ import path from "node:path"; import { createRequire } from "node:module"; import initSqlJs from "sql.js"; import type { Database, SqlJsStatic } from "sql.js"; -import { createAutomationService, presetToTemplate, triggerMatches } from "./automationService"; +import { createAutomationService, normalizeRuntimeRule, presetToTemplate, triggerMatches } from "./automationService"; type SqlValue = string | number | null | Uint8Array; @@ -78,6 +78,33 @@ describe("triggerMatches", () => { }); }); +describe("normalizeRuntimeRule", () => { + it("normalizes legacy prompt-at-run lane mode to require-on-trigger", () => { + const normalized = normalizeRuntimeRule({ + id: "legacy-prompt-at-run", + name: "Legacy prompt at run", + enabled: true, + mode: "review", + triggers: [{ type: "manual" }], + trigger: { type: "manual" }, + execution: { kind: "agent-session", laneMode: "prompt-at-run" } as any, + executor: { mode: "automation-bot" }, + prompt: "Run in the supplied lane.", + reviewProfile: "quick", + toolPalette: ["repo"], + contextSources: [], + memory: { mode: "none" }, + guardrails: {}, + outputs: { disposition: "comment-only", createArtifact: true }, + verification: { verifyBeforePublish: false, mode: "intervention" }, + billingCode: "auto:test", + actions: [], + }); + + expect(normalized.execution?.laneMode).toBe("require-on-trigger"); + }); +}); + function createInMemoryAdeDb(): { db: AdeDb; raw: Database } { const raw = new SQL.Database(); raw.run(` @@ -125,6 +152,32 @@ function createInMemoryAdeDb(): { db: AdeDb; raw: Database } { output text ) `); + raw.run(` + create table automation_ingress_events( + id text primary key, + project_id text not null, + source text not null, + event_key text not null, + automation_ids_json text not null, + trigger_type text not null, + event_name text, + status text not null, + summary text, + error_message text, + cursor text, + raw_payload_json text, + received_at text not null + ) + `); + raw.run(` + create table automation_ingress_cursors( + project_id text not null, + source text not null, + cursor text, + updated_at text not null, + primary key(project_id, source) + ) + `); const run = (sql: string, params: SqlValue[] = []) => raw.run(sql, params); const all = = Record>(sql: string, params: SqlValue[] = []): T[] => @@ -294,6 +347,171 @@ describe("automationService integration", () => { } }); + it("requires manual triggers to pass laneId when laneMode is require-on-trigger", async () => { + const { db } = createInMemoryAdeDb(); + const logger = createLogger(); + const projectId = "proj"; + const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-automation-require-lane-")); + + const rule = { + id: "manual-require-lane", + name: "Manual require lane", + trigger: { type: "manual" as const }, + triggers: [{ type: "manual" as const }], + execution: { + kind: "built-in" as const, + laneMode: "require-on-trigger" as const, + builtIn: { actions: [{ type: "run-command" as const, command: "pwd", timeoutMs: 10_000 }] }, + }, + actions: [{ type: "run-command" as const, command: "pwd", timeoutMs: 10_000 }], + enabled: true + }; + + 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 + }); + + try { + await expect(service.triggerManually({ id: "manual-require-lane" })).rejects.toThrow(/requires a lane/); + } finally { + fs.rmSync(projectRoot, { recursive: true, force: true }); + } + }); + + it("uses the supplied laneId for require-on-trigger manual runs", async () => { + const { db, raw } = createInMemoryAdeDb(); + const logger = createLogger(); + const projectId = "proj"; + const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-automation-require-project-")); + const suppliedLaneRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-automation-require-supplied-")); + const actionLaneRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-automation-require-action-")); + + const rule = { + id: "manual-supplied-lane", + name: "Manual supplied lane", + trigger: { type: "manual" as const }, + triggers: [{ type: "manual" as const }], + execution: { + kind: "built-in" as const, + laneMode: "require-on-trigger" as const, + 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-primary", laneType: "primary" }, { id: "lane-supplied", laneType: "worktree" }, { id: "lane-action", laneType: "worktree" }], + getLaneWorktreePath: (laneId: string) => laneId === "lane-supplied" ? suppliedLaneRoot : laneId === "lane-action" ? actionLaneRoot : 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: "manual-supplied-lane", laneId: "lane-supplied" }); + expect(run.status).toBe("succeeded"); + const mapped = mapExecRows(raw.exec("select output from automation_action_results")); + expect(String(mapped[0]?.output ?? "")).toContain(suppliedLaneRoot); + expect(String(mapped[0]?.output ?? "")).not.toContain(actionLaneRoot); + } finally { + fs.rmSync(projectRoot, { recursive: true, force: true }); + fs.rmSync(suppliedLaneRoot, { recursive: true, force: true }); + fs.rmSync(actionLaneRoot, { recursive: true, force: true }); + } + }); + + it("fails non-manual require-on-trigger runs when the event has no lane", async () => { + const { db, raw } = createInMemoryAdeDb(); + const logger = createLogger(); + const projectId = "proj"; + const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-automation-require-event-")); + + const rule = { + id: "event-require-lane", + name: "Event require lane", + trigger: { type: "github.issue_opened" as const }, + triggers: [{ type: "github.issue_opened" as const }], + execution: { + kind: "built-in" as const, + laneMode: "require-on-trigger" as const, + builtIn: { actions: [{ type: "run-command" as const, command: "pwd", timeoutMs: 10_000 }] }, + }, + actions: [{ type: "run-command" as const, command: "pwd", timeoutMs: 10_000 }], + enabled: true + }; + + 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 + }); + + try { + const event = await service.dispatchIngressTrigger({ + source: "github-polling", + eventKey: "issue:require-lane", + triggerType: "github.issue_opened", + eventName: "github.issue_opened", + issue: { number: 1, title: "No lane", labels: [] }, + } as any); + expect(event?.status).toBe("dispatched"); + const runs = mapExecRows(raw.exec("select status, error_message from automation_runs where automation_id = 'event-require-lane'")); + expect(runs[0]?.status).toBe("failed"); + expect(String(runs[0]?.error_message ?? "")).toContain("trigger payload to include a laneId"); + } finally { + fs.rmSync(projectRoot, { recursive: true, force: true }); + } + }); + it("launches mission automations on the configured target lane", async () => { const { db } = createInMemoryAdeDb(); const logger = createLogger(); @@ -455,6 +673,163 @@ describe("automationService integration", () => { } }); + it("uses the step lane override when a built-in action launches a mission", async () => { + const { db } = createInMemoryAdeDb(); + const logger = createLogger(); + const projectId = "proj"; + const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-automation-built-in-mission-lane-")); + const createMission = vi.fn(() => ({ + id: "mission-built-in-lane", + status: "in_progress", + outcomeSummary: null, + completedAt: null, + lastError: null, + })); + + const rule = { + id: "built-in-mission-lane", + name: "Built-in mission lane", + 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, + targetLaneId: "lane-rule", + builtIn: { actions: [{ type: "launch-mission" as const, sessionTitle: "Follow-up mission", targetLaneId: "lane-action" }] }, + }, + actions: [{ type: "launch-mission" as const, sessionTitle: "Follow-up mission", targetLaneId: "lane-action" }], + prompt: "Run a mission from a built-in action.", + }; + + const projectConfigService = { + get: () => ({ + trust: { requiresSharedTrust: false }, + effective: { automations: [rule], providerMode: "guest" } + }) + } as any; + + const laneService = { + list: async () => [ + { id: "lane-primary", laneType: "primary" }, + { id: "lane-rule", laneType: "worktree" }, + { id: "lane-action", laneType: "worktree" }, + ], + getLaneWorktreePath: () => projectRoot, + getLaneBaseAndBranch: () => ({ baseRef: "main", branchRef: "main", worktreePath: projectRoot }) + } as any; + + const service = createAutomationService({ + db: db as any, + logger, + projectId, + projectRoot, + laneService, + projectConfigService, + missionService: { + create: createMission, + patchMetadata: vi.fn(), + } as any, + aiOrchestratorService: { + startMissionRun: vi.fn(async () => undefined), + } as any, + }); + + try { + await service.triggerManually({ id: "built-in-mission-lane" }); + expect(createMission).toHaveBeenCalledWith(expect.objectContaining({ laneId: "lane-action" })); + } finally { + fs.rmSync(projectRoot, { recursive: true, force: true }); + } + }); + + it("fails built-in launch-mission actions when required trigger lane is missing", async () => { + const { db, raw } = createInMemoryAdeDb(); + const logger = createLogger(); + const projectId = "proj"; + const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-automation-built-in-mission-require-lane-")); + const createMission = vi.fn(); + + const rule = { + id: "built-in-mission-require-lane", + name: "Built-in mission require lane", + enabled: true, + mode: "review", + reviewProfile: "quick", + trigger: { type: "github.issue_opened" as const }, + triggers: [{ type: "github.issue_opened" 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, + laneMode: "require-on-trigger" as const, + builtIn: { actions: [{ type: "launch-mission" as const, sessionTitle: "Follow-up mission" }] }, + }, + actions: [{ type: "launch-mission" as const, sessionTitle: "Follow-up mission" }], + prompt: "Run a mission from a built-in action.", + }; + + 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, + missionService: { + create: createMission, + patchMetadata: vi.fn(), + } as any, + aiOrchestratorService: { + startMissionRun: vi.fn(async () => undefined), + } as any, + }); + + try { + await service.dispatchIngressTrigger({ + source: "github-polling", + eventKey: "issue:built-in-mission-require-lane", + triggerType: "github.issue_opened", + eventName: "github.issue_opened", + issue: { number: 1, title: "No lane", labels: [] }, + } as any); + expect(createMission).not.toHaveBeenCalled(); + const runs = mapExecRows(raw.exec("select status, error_message from automation_runs where automation_id = 'built-in-mission-require-lane'")); + expect(runs[0]?.status).toBe("failed"); + expect(String(runs[0]?.error_message ?? "")).toContain("trigger payload to include a laneId"); + } finally { + fs.rmSync(projectRoot, { recursive: true, force: true }); + } + }); + it("attaches built-in agent-session actions to the automation run", async () => { const { db } = createInMemoryAdeDb(); const logger = createLogger(); @@ -1376,6 +1751,82 @@ describe("automationService integration", () => { } }); + it("reuses the created lane for every built-in step in the run", async () => { + const { db, raw, logger, projectId, projectRoot } = buildLaneModeFixtures(); + const laneRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-automation-lane-mode-created-")); + const createLane = vi.fn(async ({ name }: { name: string }) => ({ + id: "lane-fresh", + name, + branchRef: name.replace(/\s+/g, "-").toLowerCase(), + laneType: "feature", + worktreePath: laneRoot, + })); + + const rule = { + id: "built-in-create-lane", + name: "Built-in create lane", + 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, + laneMode: "create" as const, + laneNamePreset: "issue-title" as const, + builtIn: { + actions: [ + { type: "run-command" as const, command: "pwd", timeoutMs: 10_000 }, + { type: "run-command" as const, command: "pwd", timeoutMs: 10_000 }, + ], + }, + }, + actions: [], + }; + + const projectConfigService = { + get: () => ({ trust: { requiresSharedTrust: false }, effective: { automations: [rule], providerMode: "guest" } }) + } as any; + const laneService = { + create: createLane, + list: async () => [{ id: "lane-primary", name: "primary", laneType: "primary" }], + getLaneWorktreePath: (laneId: string) => laneId === "lane-fresh" ? laneRoot : 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: "built-in-create-lane" }); + expect(run.status).toBe("succeeded"); + expect(createLane).toHaveBeenCalledTimes(1); + const commandRows = mapExecRows(raw.exec("select output from automation_action_results where action_type = 'run-command' order by action_index asc")); + expect(commandRows).toHaveLength(2); + expect(String(commandRows[0]?.output ?? "")).toContain(laneRoot); + expect(String(commandRows[1]?.output ?? "")).toContain(laneRoot); + const setupRows = mapExecRows(raw.exec("select status from automation_action_results where action_type = 'lane-setup'")); + expect(setupRows).toHaveLength(1); + } finally { + fs.rmSync(projectRoot, { recursive: true, force: true }); + fs.rmSync(laneRoot, { recursive: true, force: true }); + } + }); + it("appends issue number on collision then a random suffix on a second collision", async () => { const { db, logger, projectId, projectRoot } = buildLaneModeFixtures(); const createLane = vi.fn(async ({ name }: { name: string }) => ({ diff --git a/apps/desktop/src/main/services/automations/automationService.ts b/apps/desktop/src/main/services/automations/automationService.ts index 733239bcf..1d7d72e31 100644 --- a/apps/desktop/src/main/services/automations/automationService.ts +++ b/apps/desktop/src/main/services/automations/automationService.ts @@ -618,6 +618,11 @@ function deriveIncludeProjectContext(rule: AutomationRule): boolean { return false; } +function normalizeAutomationLaneMode(mode: unknown): AutomationExecution["laneMode"] | undefined { + if (mode === "provided" || mode === "prompt-at-run") return "require-on-trigger"; + return mode === "create" || mode === "reuse" || mode === "require-on-trigger" ? mode : undefined; +} + export function normalizeRuntimeRule(rule: AutomationRule): AutomationRule { const triggers = normalizedRuleTriggers(rule).map(canonicalizeTriggerForRuntime); const legacyActions = Array.isArray(rule.legacy?.actions) @@ -630,8 +635,9 @@ export function normalizeRuntimeRule(rule: AutomationRule): AutomationRule { const rawExecution = rule.execution ?? (legacyActions.length > 0 ? { kind: "built-in" as const, builtIn: { actions: legacyActions } } : { kind: "mission" as const }); + const laneMode = normalizeAutomationLaneMode(rawExecution.laneMode); const sharedLaneFields = { - ...(rawExecution.laneMode ? { laneMode: rawExecution.laneMode } : {}), + ...(laneMode ? { laneMode } : {}), ...(rawExecution.laneNamePreset ? { laneNamePreset: rawExecution.laneNamePreset } : {}), ...(rawExecution.laneNamePreset === "custom" && rawExecution.laneNameTemplate ? { laneNameTemplate: rawExecution.laneNameTemplate } @@ -660,9 +666,9 @@ export function normalizeRuntimeRule(rule: AutomationRule): AutomationRule { ...(rawExecution.mission ? { mission: rawExecution.mission } : {}), }; const outputDisposition = rule.outputs?.disposition ?? "comment-only"; - // Silently drop per-rule budget/review fields that are no longer surfaced - // in the UI. We keep them in the on-disk YAML so a downgrade doesn't lose - // data, but the in-memory runtime shape zeroes them out. + // Per-rule budget fields are deprecated in favor of global usage caps. We + // keep them in YAML for downgrade compatibility, but do not let runtime + // consume them. const sanitizedGuardrails = { ...(rule.guardrails ?? {}) } as AutomationRule["guardrails"] & { budgetCapUsd?: number; maxSpendUsd?: number; @@ -1405,6 +1411,26 @@ export function createAutomationService({ ); }; + const loadLaneSetupLaneId = (runId: string | null | undefined): string | null => { + if (!runId) return null; + const row = db.get<{ output: string | null }>( + ` + select output + from automation_action_results + where project_id = ? + and run_id = ? + and action_type = 'lane-setup' + and status = 'succeeded' + order by started_at asc + limit 1 + `, + [projectId, runId] + ); + const parsed = safeJsonParse | null>(row?.output, null); + if (!isRecord(parsed)) return null; + return trimToNull(parsed.laneId); + }; + const loadRunRow = (runId: string): AutomationRunRow | null => db.get( ` select @@ -1693,6 +1719,14 @@ export function createAutomationService({ return trimToNull(action?.targetLaneId) ?? trimToNull(rule.execution?.targetLaneId); }; + const requiresTriggerLane = (rule: AutomationRule): boolean => + rule.execution?.laneMode === "require-on-trigger"; + + const missingTriggerLaneMessage = (trigger: Pick): string => + trigger.triggerType === "manual" + ? "This automation requires a lane when triggered manually. Pass laneId / --lane." + : "This automation requires the trigger payload to include a laneId."; + const dispatchAdeAction = async ( config: RunAdeActionConfig, trigger: TriggerContext, @@ -1761,7 +1795,10 @@ export function createAutomationService({ } if (action.type === "predict-conflicts") { if (!conflictService) throw new Error("Conflict service unavailable"); - await conflictService.runPrediction(trigger.laneId ? { laneId: trigger.laneId } : {}); + const laneId = requiresTriggerLane(rule) || rule.execution?.laneMode === "create" + ? await resolveExecutionLaneId(rule, trigger, action, runId) + : getConfiguredTargetLaneId(rule, action) ?? trigger.laneId; + await conflictService.runPrediction(laneId ? { laneId } : {}); return { status: "succeeded" }; } if (action.type === "create-lane") { @@ -1802,11 +1839,13 @@ export function createAutomationService({ if (!testService) throw new Error("Test service unavailable"); const activeLanes = await laneService.list({ includeArchived: false }); const configuredLaneId = getConfiguredTargetLaneId(rule, action); - const laneId = configuredLaneId - ?? trigger.laneId - ?? activeLanes.find((lane) => lane.laneType === "primary")?.id - ?? activeLanes[0]?.id - ?? null; + const laneId = requiresTriggerLane(rule) || rule.execution?.laneMode === "create" + ? await resolveExecutionLaneId(rule, trigger, action, runId) + : configuredLaneId + ?? trigger.laneId + ?? activeLanes.find((lane) => lane.laneType === "primary")?.id + ?? activeLanes[0]?.id + ?? null; if (!laneId) throw new Error("No lane available to run tests"); await testService.run({ laneId, suiteId }); return { status: "succeeded" }; @@ -1881,11 +1920,19 @@ export function createAutomationService({ } } if (action.type === "launch-mission") { + const laneMode = rule.execution?.laneMode; + const actionTargetLaneId = trimToNull(action.targetLaneId); + const ruleTargetLaneId = trimToNull(rule.execution?.targetLaneId); const missionRule: AutomationRule = { ...rule, execution: { kind: "mission", - ...(rule.execution?.targetLaneId ? { targetLaneId: rule.execution.targetLaneId } : {}), + ...(laneMode ? { laneMode } : {}), + ...(laneMode === "create" && rule.execution?.laneNamePreset ? { laneNamePreset: rule.execution.laneNamePreset } : {}), + ...(laneMode === "create" && rule.execution?.laneNameTemplate ? { laneNameTemplate: rule.execution.laneNameTemplate } : {}), + ...(laneMode !== "require-on-trigger" && (actionTargetLaneId ?? ruleTargetLaneId) + ? { targetLaneId: actionTargetLaneId ?? ruleTargetLaneId } + : {}), mission: { title: action.sessionTitle?.trim() || rule.execution?.mission?.title || null }, }, }; @@ -1905,7 +1952,9 @@ 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, action) ?? trigger.laneId; + const laneId = requiresTriggerLane(rule) || rule.execution?.laneMode === "create" + ? await resolveExecutionLaneId(rule, trigger, action, runId) + : getConfiguredTargetLaneId(rule, action) ?? trigger.laneId; const baseCwd = laneId ? laneService.getLaneWorktreePath(laneId) : projectRoot; const configuredCwd = (action.cwd ?? "").trim(); const cwdCandidate = configuredCwd.length @@ -2123,9 +2172,18 @@ export function createAutomationService({ runId?: string | null, ): Promise => { const actionLaneId = trimToNull(action?.targetLaneId); + if (requiresTriggerLane(rule)) { + const triggerLaneId = trimToNull(trigger.laneId); + if (triggerLaneId) return triggerLaneId; + throw new Error(missingTriggerLaneMessage(trigger)); + } + if (actionLaneId) return actionLaneId; if (rule.execution?.laneMode === "create") { + const existingCreatedLaneId = loadLaneSetupLaneId(runId); + if (existingCreatedLaneId) return existingCreatedLaneId; + const setupActionId = runId ? insertAction(runId, -1, "lane-setup") : null; try { const { laneId, laneName } = await createLaneForRun(rule, trigger); @@ -3278,9 +3336,13 @@ export function createAutomationService({ if (!id) throw new Error("Automation id is required"); const rule = findRule(id); if (!rule) throw new Error(`Automation not found: ${id}`); + const laneId = typeof args.laneId === "string" && args.laneId.trim().length ? args.laneId.trim() : undefined; + if (requiresTriggerLane(rule) && !laneId) { + throw new Error(missingTriggerLaneMessage({ triggerType: "manual" })); + } return await runRule(rule, { triggerType: "manual", - laneId: typeof args.laneId === "string" && args.laneId.trim().length ? args.laneId.trim() : undefined, + laneId, reason: id, scheduledAt: nowIso(), reviewProfileOverride: args.reviewProfileOverride ?? null, diff --git a/apps/desktop/src/main/services/config/projectConfigService.automationExecution.test.ts b/apps/desktop/src/main/services/config/projectConfigService.automationExecution.test.ts index 1c5eda9ca..e9dba7be9 100644 --- a/apps/desktop/src/main/services/config/projectConfigService.automationExecution.test.ts +++ b/apps/desktop/src/main/services/config/projectConfigService.automationExecution.test.ts @@ -73,6 +73,22 @@ describe("projectConfigService automation execution normalization", () => { laneNameTemplate: "Should be dropped", }, }, + { + id: "require-trigger-lane-rule", + trigger: { type: "manual" }, + execution: { + kind: "agent-session", + laneMode: "require-on-trigger", + }, + }, + { + id: "legacy-prompt-at-run-rule", + trigger: { type: "manual" }, + execution: { + kind: "agent-session", + laneMode: "prompt-at-run", + }, + }, ], }), "utf8", @@ -86,7 +102,7 @@ describe("projectConfigService automation execution normalization", () => { logger: makeLogger(), }); - const [customRule, presetRule] = service.get().effective.automations; + const [customRule, presetRule, requireTriggerLaneRule, legacyPromptAtRunRule] = service.get().effective.automations; expect(customRule.execution).toMatchObject({ kind: "mission", @@ -101,5 +117,108 @@ describe("projectConfigService automation execution normalization", () => { laneNamePreset: "issue-title", }); expect(presetRule.execution?.laneNameTemplate).toBeUndefined(); + expect(requireTriggerLaneRule.execution).toMatchObject({ + kind: "agent-session", + laneMode: "require-on-trigger", + }); + expect(legacyPromptAtRunRule.execution).toMatchObject({ + kind: "agent-session", + laneMode: "require-on-trigger", + }); + }); + + it("flags fixed target lanes on require-on-trigger automation execution", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-project-config-automation-execution-")); + tempDirs.push(root); + + const adeDir = path.join(root, ".ade"); + fs.mkdirSync(adeDir, { recursive: true }); + + const service = createProjectConfigService({ + projectRoot: root, + adeDir, + projectId: "project-automation-execution-validation", + db: makeDb(), + logger: makeLogger(), + }); + + const validation = service.validate({ + shared: { + version: 1, + processes: [], + stackButtons: [], + testSuites: [], + laneOverlayPolicies: [], + automations: [], + }, + local: { + version: 1, + processes: [], + stackButtons: [], + testSuites: [], + laneOverlayPolicies: [], + automations: [ + { + id: "bad-trigger-lane", + name: "Bad trigger lane", + enabled: true, + mode: "review", + trigger: { type: "manual" }, + triggers: [{ type: "manual" }], + execution: { + kind: "agent-session", + laneMode: "require-on-trigger", + targetLaneId: "lane-fixed", + }, + executor: { mode: "automation-bot" }, + prompt: "Run.", + reviewProfile: "quick", + toolPalette: ["repo"], + contextSources: [], + memory: { mode: "none" }, + guardrails: {}, + outputs: { disposition: "comment-only", createArtifact: true }, + verification: { verifyBeforePublish: false, mode: "intervention" }, + billingCode: "auto:test", + }, + { + id: "bad-step-lane", + name: "Bad step lane", + enabled: true, + mode: "review", + trigger: { type: "manual" }, + triggers: [{ type: "manual" }], + execution: { + kind: "built-in", + laneMode: "require-on-trigger", + builtIn: { + actions: [{ type: "run-command", command: "pwd", targetLaneId: "lane-fixed" }], + }, + }, + executor: { mode: "automation-bot" }, + reviewProfile: "quick", + toolPalette: ["repo"], + contextSources: [], + memory: { mode: "none" }, + guardrails: {}, + outputs: { disposition: "comment-only", createArtifact: true }, + verification: { verifyBeforePublish: false, mode: "intervention" }, + billingCode: "auto:test", + }, + ], + }, + } as any); + + expect(validation.ok).toBe(false); + expect(validation.issues).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + path: "effective.automations[0].execution.targetLaneId", + }), + expect.objectContaining({ + path: "effective.automations[1].execution.builtIn.actions[0].targetLaneId", + }), + ]), + ); }); }); diff --git a/apps/desktop/src/main/services/config/projectConfigService.ts b/apps/desktop/src/main/services/config/projectConfigService.ts index e6a6470c7..0aaf1b8a0 100644 --- a/apps/desktop/src/main/services/config/projectConfigService.ts +++ b/apps/desktop/src/main/services/config/projectConfigService.ts @@ -477,6 +477,7 @@ function coerceAutomationAction(value: unknown): AutomationAction | null { if (!type) return null; const out: AutomationAction = { type }; + const targetLaneId = asString(value.targetLaneId)?.trim(); const suiteId = asString(value.suiteId); const command = asString(value.command); const cwd = asString(value.cwd); @@ -488,6 +489,7 @@ function coerceAutomationAction(value: unknown): AutomationAction | null { const prompt = asString(value.prompt); const sessionTitle = asString(value.sessionTitle); + if (targetLaneId) out.targetLaneId = targetLaneId; if (suiteId != null) out.suiteId = suiteId; if (command != null) out.command = command; if (cwd != null) out.cwd = cwd; @@ -527,8 +529,10 @@ function coerceAutomationExecution(value: unknown): AutomationExecution | undefi const targetLaneId = asString(value.targetLaneId)?.trim() || undefined; const laneModeRaw = asString(value.laneMode)?.trim(); - const laneMode: AutomationExecution["laneMode"] = laneModeRaw === "create" || laneModeRaw === "reuse" + const laneMode: AutomationExecution["laneMode"] = laneModeRaw === "create" || laneModeRaw === "reuse" || laneModeRaw === "require-on-trigger" ? laneModeRaw + : laneModeRaw === "provided" || laneModeRaw === "prompt-at-run" + ? "require-on-trigger" : undefined; const laneNamePresetRaw = asString(value.laneNamePreset)?.trim(); const laneNamePreset: AutomationExecution["laneNamePreset"] = laneNamePresetRaw === "issue-title" || @@ -2348,6 +2352,7 @@ function resolveEffectiveConfig(shared: ProjectConfigFile, local: ProjectConfigF ...(typeof entry.includeProjectContext === "boolean" ? { includeProjectContext: entry.includeProjectContext } : {}), actions: (entry.actions ?? []).map((action) => ({ type: action.type, + ...(action.targetLaneId ? { targetLaneId: action.targetLaneId.trim() } : {}), ...(action.suiteId ? { suiteId: action.suiteId.trim() } : {}), ...(action.command ? { command: action.command } : {}), ...(action.cwd ? { cwd: action.cwd.trim() } : {}), @@ -2364,6 +2369,7 @@ function resolveEffectiveConfig(shared: ProjectConfigFile, local: ProjectConfigF ...(entry.actions ? { actions: entry.actions.map((action) => ({ type: action.type, + ...(action.targetLaneId ? { targetLaneId: action.targetLaneId.trim() } : {}), ...(action.suiteId ? { suiteId: action.suiteId.trim() } : {}), ...(action.command ? { command: action.command } : {}), ...(action.cwd ? { cwd: action.cwd.trim() } : {}), @@ -2921,6 +2927,29 @@ function validateEffectiveConfig( } else if (rule.execution.kind === "built-in" && !(rule.execution.builtIn?.actions?.length ?? 0)) { issues.push({ path: `${p}.execution.builtIn.actions`, message: "Built-in automations need at least one task." }); } + if ( + rule.execution?.laneMode + && rule.execution.laneMode !== "create" + && rule.execution.laneMode !== "reuse" + && rule.execution.laneMode !== "require-on-trigger" + ) { + issues.push({ path: `${p}.execution.laneMode`, message: `Unknown lane mode '${String(rule.execution.laneMode)}'` }); + } + if (rule.execution?.laneMode === "require-on-trigger" && (rule.execution.targetLaneId ?? "").trim()) { + issues.push({ + path: `${p}.execution.targetLaneId`, + message: "targetLaneId is not allowed when lane must be supplied at trigger time.", + }); + } + if (rule.execution?.laneMode === "require-on-trigger" && rule.execution.kind === "built-in") { + rule.execution.builtIn?.actions.forEach((action, actionIndex) => { + if (!(action.targetLaneId ?? "").trim()) return; + issues.push({ + path: `${p}.execution.builtIn.actions[${actionIndex}].targetLaneId`, + message: "Step lane overrides are not allowed when lane must be supplied at trigger time.", + }); + }); + } if ( rule.execution?.kind && diff --git a/apps/desktop/src/renderer/components/app/AppShell.tsx b/apps/desktop/src/renderer/components/app/AppShell.tsx index 5cfe2699f..7825b89c1 100644 --- a/apps/desktop/src/renderer/components/app/AppShell.tsx +++ b/apps/desktop/src/renderer/components/app/AppShell.tsx @@ -370,10 +370,17 @@ export function AppShell({ children }: { children: React.ReactNode }) { const currentProjectRoot = useAppStore.getState().project?.rootPath ?? null; const currentShowWelcome = useAppStore.getState().showWelcome; + const currentIsNewTabOpen = useAppStore.getState().isNewTabOpen; const hasStoredProject = Boolean(nextProject); const projectChanged = nextProjectRoot !== currentProjectRoot; const welcomeChanged = currentShowWelcome === hasStoredProject; + if (currentIsNewTabOpen && nextProject && !projectChanged) { + setProject(nextProject); + if (currentShowWelcome) setShowWelcome(false); + return; + } + if (nextProject) { setProject(nextProject); setShowWelcome(false); diff --git a/apps/desktop/src/renderer/components/app/TopBar.test.tsx b/apps/desktop/src/renderer/components/app/TopBar.test.tsx index 97ff2fcde..2cf12252f 100644 --- a/apps/desktop/src/renderer/components/app/TopBar.test.tsx +++ b/apps/desktop/src/renderer/components/app/TopBar.test.tsx @@ -220,6 +220,49 @@ describe("TopBar", () => { expect(await screen.findByText("1 phone connected")).toBeTruthy(); }); + it("does not refresh phone sync status on an idle interval", async () => { + vi.useFakeTimers(); + try { + const getStatus = vi.fn(async () => makeSyncSnapshot()); + globalThis.window.ade.sync.getStatus = getStatus as any; + + render(); + + await act(async () => { + await Promise.resolve(); + }); + + expect(getStatus).toHaveBeenCalledTimes(1); + + await act(async () => { + vi.advanceTimersByTime(15_000); + await Promise.resolve(); + }); + + expect(getStatus).toHaveBeenCalledTimes(1); + } finally { + vi.useRealTimers(); + } + }); + + it("refreshes phone sync status when the window regains focus", async () => { + const getStatus = vi.fn() + .mockResolvedValueOnce(makeSyncSnapshot({ connectedPeers: [] })) + .mockResolvedValueOnce(makeSyncSnapshot()); + globalThis.window.ade.sync.getStatus = getStatus as any; + + render(); + + expect(await screen.findByText("Phone sync ready")).toBeTruthy(); + + await act(async () => { + window.dispatchEvent(new Event("focus")); + }); + + expect(await screen.findByText("1 phone connected")).toBeTruthy(); + expect(getStatus).toHaveBeenCalledTimes(2); + }); + it("shows project icon replacement errors", async () => { globalThis.window.ade.project.chooseIcon = vi.fn(async () => { throw new Error("Failed to set project icon: Project icon must be 10 MB or smaller."); diff --git a/apps/desktop/src/renderer/components/app/TopBar.tsx b/apps/desktop/src/renderer/components/app/TopBar.tsx index 6f534a798..0bcc41378 100644 --- a/apps/desktop/src/renderer/components/app/TopBar.tsx +++ b/apps/desktop/src/renderer/components/app/TopBar.tsx @@ -540,6 +540,7 @@ export function TopBar() { useEffect(() => { let cancelled = false; + let statusRequestVersion = 0; if (!project?.rootPath) { setSyncSnapshot(null); setPhoneSyncOpen(false); @@ -548,31 +549,31 @@ export function TopBar() { }; } const refreshSyncStatus = () => { + const requestVersion = ++statusRequestVersion; void window.ade.sync.getStatus({ includeTransferReadiness: false }).then((snapshot) => { - if (!cancelled) setSyncSnapshot(snapshot); + if (!cancelled && requestVersion === statusRequestVersion) setSyncSnapshot(snapshot); }).catch(() => { - if (!cancelled) setSyncSnapshot(null); + if (!cancelled && requestVersion === statusRequestVersion) setSyncSnapshot(null); }); }; setSyncSnapshot(null); refreshSyncStatus(); - const interval = window.setInterval(refreshSyncStatus, 5_000); window.addEventListener("focus", refreshSyncStatus); const dispose = window.ade.sync.onEvent((event) => { if (!cancelled && event.type === "sync-status") { + statusRequestVersion += 1; setSyncSnapshot(event.snapshot); } }); return () => { cancelled = true; - window.clearInterval(interval); window.removeEventListener("focus", refreshSyncStatus); dispose(); }; // Background projects don't broadcast sync-status events (main.ts filters - // them to the active project), so we re-run this effect on rootPath - // change to force an immediate refetch instead of waiting up to 5s for - // the next polling tick. + // them to the active project), so we re-run this effect on rootPath change + // to force an immediate refetch. Focus refresh covers state changes that + // happen while ADE is not active. }, [project?.rootPath]); const checkForActiveWorkloads = useCallback(async (projectRootPath: string): Promise => { @@ -845,7 +846,7 @@ export function TopBar() { onDrop={(e) => handleDrop(e, idx)} onDragEnd={handleDragEnd} className={cn( - "ade-shell-project-tab group inline-flex w-[clamp(128px,16vw,220px)] max-w-[220px] shrink-0 items-center gap-2 px-3 py-0.5", + "ade-shell-project-tab group inline-flex w-[clamp(128px,16vw,220px)] max-w-[220px] min-w-0 shrink-0 items-center gap-2 px-3 py-0.5", "transition-[background-color,color,border-color,box-shadow,opacity] duration-150", !isMissing && "cursor-pointer", isCurrent && "font-semibold", @@ -894,14 +895,14 @@ export function TopBar() { ) : null} {rp.displayName} {isMissing ? ( - + + + + + + ) : null} ); } diff --git a/apps/desktop/src/renderer/components/automations/components/RuleEditorPanel.tsx b/apps/desktop/src/renderer/components/automations/components/RuleEditorPanel.tsx index 505704387..ae5b24b03 100644 --- a/apps/desktop/src/renderer/components/automations/components/RuleEditorPanel.tsx +++ b/apps/desktop/src/renderer/components/automations/components/RuleEditorPanel.tsx @@ -29,7 +29,11 @@ import type { AutomationDraftIssue, AutomationLaneMode, AutomationLaneNamePreset, + AutomationMode, + AutomationOutputDisposition, + AutomationReviewProfile, AutomationRuleDraft, + AutomationToolFamily, AutomationTrigger, TestSuiteDefinition, } from "../../../../shared/types"; @@ -124,6 +128,40 @@ const SCHEDULE_PRESETS: Array<{ label: string; cron: string }> = [ { label: "Fridays at 4 PM", cron: "0 16 * * 5" }, ]; +const REVIEW_PROFILES: Array<{ value: AutomationReviewProfile; label: string }> = [ + { value: "quick", label: "Quick" }, + { value: "incremental", label: "Incremental" }, + { value: "full", label: "Full" }, + { value: "security", label: "Security" }, + { value: "release-risk", label: "Release risk" }, + { value: "cross-repo-contract", label: "Cross-repo contract" }, +]; + +const RULE_MODES: Array<{ value: AutomationMode; label: string }> = [ + { value: "review", label: "Review" }, + { value: "fix", label: "Fix" }, + { value: "monitor", label: "Monitor" }, +]; + +const TOOL_FAMILIES: Array<{ value: AutomationToolFamily; label: string }> = [ + { value: "repo", label: "Repo" }, + { value: "git", label: "Git" }, + { value: "tests", label: "Tests" }, + { value: "github", label: "GitHub" }, + { value: "linear", label: "Linear" }, + { value: "browser", label: "Browser" }, + { value: "memory", label: "Memory" }, + { value: "mission", label: "Mission" }, +]; + +const OUTPUT_DISPOSITIONS: Array<{ value: AutomationOutputDisposition; label: string }> = [ + { value: "comment-only", label: "Comment only" }, + { value: "open-task", label: "Open task" }, + { value: "open-lane", label: "Open lane" }, + { value: "prepare-patch", label: "Prepare patch" }, + { value: "open-pr-draft", label: "Open PR draft" }, +]; + const LANE_NAME_PRESETS: Array<{ value: AutomationLaneNamePreset; label: string; @@ -136,6 +174,8 @@ const LANE_NAME_PRESETS: Array<{ { value: "custom", label: "Custom template…", template: "", helpEvent: "any" }, ]; +type DraftLaneMode = AutomationLaneMode | (string & {}); + function presetTemplate(preset: AutomationLaneNamePreset, customTemplate: string | undefined): string { if (preset === "custom") return customTemplate ?? ""; return LANE_NAME_PRESETS.find((p) => p.value === preset)?.template ?? ""; @@ -225,6 +265,23 @@ function triggerFamilyForType(type: AutomationTrigger["type"]): TriggerFamily { return "manual"; } +function readLaneMode(draft: AutomationRuleDraft): DraftLaneMode { + const raw = (draft.execution as { laneMode?: unknown } | undefined)?.laneMode; + return typeof raw === "string" && raw.trim() ? (raw.trim() as DraftLaneMode) : "reuse"; +} + +function isRequireLaneAtRunTimeMode(mode: DraftLaneMode | null | undefined): boolean { + return mode === "require-on-trigger" || mode === "provided" || mode === "prompt-at-run"; +} + +function humanizeLaneMode(mode: string): string { + return mode + .split(/[-_]/g) + .filter(Boolean) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(" "); +} + function defaultTriggerForFamily(family: TriggerFamily): AutomationTrigger { switch (family) { case "github": @@ -268,6 +325,60 @@ function computeIncludeProjectContext(draft: AutomationRuleDraft): boolean { // --- draft <-> ActionRow[] bridge --- +type ActionRowRuntimeOptions = Pick; +type AutomationActionRuntimeOptions = Pick; + +function actionRuntimeOptions(action: AutomationAction): Partial { + return { + ...(action.targetLaneId ? { targetLaneId: action.targetLaneId } : {}), + ...(action.condition ? { condition: action.condition } : {}), + ...(typeof action.continueOnFailure === "boolean" ? { continueOnFailure: action.continueOnFailure } : {}), + ...(Number.isFinite(action.timeoutMs) ? { timeoutMs: action.timeoutMs } : {}), + ...(Number.isFinite(action.retry) ? { retry: action.retry } : {}), + }; +} + +function rowRuntimeOptions(row: ActionRowValue): Partial { + return { + ...(row.targetLaneId ? { targetLaneId: row.targetLaneId } : {}), + ...(row.condition?.trim() ? { condition: row.condition.trim() } : {}), + ...(row.continueOnFailure ? { continueOnFailure: true } : {}), + ...(Number.isFinite(row.timeoutMs) ? { timeoutMs: row.timeoutMs } : {}), + ...(Number.isFinite(row.retry) ? { retry: row.retry } : {}), + }; +} + +function rowHasRuntimeOptions(row: ActionRowValue): boolean { + return Boolean( + row.targetLaneId + || row.condition?.trim() + || row.continueOnFailure + || Number.isFinite(row.timeoutMs) + || Number.isFinite(row.retry), + ); +} + +function stripActionTargetLaneId(action: T): Omit { + const { targetLaneId: _targetLaneId, ...rest } = action; + return rest; +} + +function stripActionTargetLaneIdsFromDraft(draft: AutomationRuleDraft): AutomationRuleDraft { + const execution = draft.execution ? { ...draft.execution } : undefined; + if (execution) delete execution.targetLaneId; + if (execution?.kind === "built-in") { + execution.builtIn = { + actions: (execution.builtIn?.actions ?? []).map((action) => stripActionTargetLaneId(action) as AutomationAction), + }; + } + return { + ...draft, + ...(execution ? { execution } : {}), + actions: draft.actions.map((action) => stripActionTargetLaneId(action) as AutomationRuleDraft["actions"][number]), + legacyActions: draft.legacyActions?.map((action) => stripActionTargetLaneId(action) as AutomationRuleDraft["actions"][number]), + }; +} + function draftToActionRows(draft: AutomationRuleDraft): ActionRowValue[] { const rows: ActionRowValue[] = []; const execution = draft.execution; @@ -290,15 +401,16 @@ function draftToActionRows(draft: AutomationRuleDraft): ActionRowValue[] { laneNameTemplate: action.laneNameTemplate ?? "", laneDescriptionTemplate: action.laneDescriptionTemplate ?? "", parentLaneId: action.parentLaneId ?? null, + ...actionRuntimeOptions(action), }); } else if (action.type === "run-tests") { - rows.push({ kind: "run-tests", suiteId: action.suiteId ?? "" }); + rows.push({ kind: "run-tests", suiteId: action.suiteId ?? "", ...actionRuntimeOptions(action) }); } else if (action.type === "run-command") { - rows.push({ kind: "run-command", command: action.command ?? "", cwd: action.cwd ?? "" }); + rows.push({ kind: "run-command", command: action.command ?? "", cwd: action.cwd ?? "", ...actionRuntimeOptions(action) }); } else if (action.type === "predict-conflicts") { - rows.push({ kind: "predict-conflicts" }); + rows.push({ kind: "predict-conflicts", ...actionRuntimeOptions(action) }); } else if (action.type === "ade-action") { - rows.push({ kind: "ade-action", adeAction: action.adeAction ?? { domain: "", action: "" } }); + rows.push({ kind: "ade-action", adeAction: action.adeAction ?? { domain: "", action: "" }, ...actionRuntimeOptions(action) }); } else if (action.type === "agent-session") { rows.push({ kind: "agent-session", @@ -306,9 +418,10 @@ function draftToActionRows(draft: AutomationRuleDraft): ActionRowValue[] { sessionTitle: action.sessionTitle ?? "", modelConfig: action.modelConfig, permissionConfig: action.permissionConfig, + ...actionRuntimeOptions(action), }); } else if (action.type === "launch-mission") { - rows.push({ kind: "launch-mission", missionTitle: action.sessionTitle ?? "" }); + rows.push({ kind: "launch-mission", missionTitle: action.sessionTitle ?? "", ...actionRuntimeOptions(action) }); } } } @@ -316,17 +429,20 @@ function draftToActionRows(draft: AutomationRuleDraft): ActionRowValue[] { } function applyActionRowsToDraft(draft: AutomationRuleDraft, rows: ActionRowValue[]): AutomationRuleDraft { - const soloAgent = rows.length === 1 && rows[0]!.kind === "agent-session"; - const soloMission = rows.length === 1 && rows[0]!.kind === "launch-mission"; + const rowsForSave = isRequireLaneAtRunTimeMode(readLaneMode(draft)) + ? rows.map((row) => stripActionTargetLaneId(row) as ActionRowValue) + : rows; + const soloAgent = rowsForSave.length === 1 && rowsForSave[0]!.kind === "agent-session" && !rowHasRuntimeOptions(rowsForSave[0]!); + const soloMission = rowsForSave.length === 1 && rowsForSave[0]!.kind === "launch-mission" && !rowHasRuntimeOptions(rowsForSave[0]!); if (soloAgent) { - const first = rows[0]!; + const first = rowsForSave[0]!; return { ...draft, execution: { ...(draft.execution ?? { kind: "agent-session" }), kind: "agent-session", - session: { title: first.sessionTitle || null }, + session: { ...(draft.execution?.kind === "agent-session" ? draft.execution.session : {}), title: first.sessionTitle || null }, }, ...(first.modelConfig ? { modelConfig: { orchestratorModel: first.modelConfig } } : {}), ...(first.permissionConfig ? { permissionConfig: first.permissionConfig } : {}), @@ -337,20 +453,20 @@ function applyActionRowsToDraft(draft: AutomationRuleDraft, rows: ActionRowValue } if (soloMission) { - const first = rows[0]!; + const first = rowsForSave[0]!; return { ...draft, execution: { ...(draft.execution ?? { kind: "mission" }), kind: "mission", - mission: { title: first.missionTitle || null }, + mission: { ...(draft.execution?.kind === "mission" ? draft.execution.mission : {}), title: first.missionTitle || null }, }, actions: [], legacyActions: [], }; } - const builtInActions: AutomationAction[] = rows.map((row) => rowToAutomationAction(row)); + const builtInActions: AutomationAction[] = rowsForSave.map((row) => rowToAutomationAction(row)); const legacyDraftActions: AutomationRuleDraft["actions"] = builtInActions .map((action) => automationActionToDraftAction(action)) .filter((entry): entry is AutomationRuleDraft["actions"][number] => entry != null); @@ -373,28 +489,32 @@ function rowToAutomationAction(row: ActionRowValue): AutomationAction { case "create-lane": return { type: "create-lane", + ...rowRuntimeOptions(row), ...(row.laneNameTemplate ? { laneNameTemplate: row.laneNameTemplate } : {}), ...(row.laneDescriptionTemplate ? { laneDescriptionTemplate: row.laneDescriptionTemplate } : {}), ...(row.parentLaneId ? { parentLaneId: row.parentLaneId } : {}), }; case "run-tests": - return { type: "run-tests", suiteId: row.suiteId ?? "" }; + return { type: "run-tests", ...rowRuntimeOptions(row), suiteId: row.suiteId ?? "" }; case "run-command": return { type: "run-command", + ...rowRuntimeOptions(row), command: row.command ?? "", ...(row.cwd ? { cwd: row.cwd } : {}), }; case "predict-conflicts": - return { type: "predict-conflicts" }; + return { type: "predict-conflicts", ...rowRuntimeOptions(row) }; case "ade-action": return { type: "ade-action", + ...rowRuntimeOptions(row), adeAction: row.adeAction ?? { domain: "", action: "" }, }; case "agent-session": return { type: "agent-session", + ...rowRuntimeOptions(row), ...(row.modelConfig ? { modelConfig: row.modelConfig } : {}), ...(row.permissionConfig ? { permissionConfig: row.permissionConfig } : {}), ...(row.prompt ? { prompt: row.prompt } : {}), @@ -403,6 +523,7 @@ function rowToAutomationAction(row: ActionRowValue): AutomationAction { case "launch-mission": return { type: "launch-mission", + ...rowRuntimeOptions(row), ...(row.missionTitle ? { sessionTitle: row.missionTitle } : {}), }; } @@ -415,28 +536,32 @@ function automationActionToDraftAction( case "create-lane": return { type: "create-lane", + ...rowRuntimeOptions(actionToRow(action)), ...(action.laneNameTemplate ? { laneNameTemplate: action.laneNameTemplate } : {}), ...(action.laneDescriptionTemplate ? { laneDescriptionTemplate: action.laneDescriptionTemplate } : {}), ...(action.parentLaneId ? { parentLaneId: action.parentLaneId } : {}), }; case "run-tests": - return { type: "run-tests", suite: action.suiteId ?? "" }; + return { type: "run-tests", ...rowRuntimeOptions(actionToRow(action)), suite: action.suiteId ?? "" }; case "run-command": return { type: "run-command", + ...rowRuntimeOptions(actionToRow(action)), command: action.command ?? "", ...(action.cwd ? { cwd: action.cwd } : {}), }; case "predict-conflicts": - return { type: "predict-conflicts" }; + return { type: "predict-conflicts", ...rowRuntimeOptions(actionToRow(action)) }; case "ade-action": return { type: "ade-action", + ...rowRuntimeOptions(actionToRow(action)), adeAction: action.adeAction ?? { domain: "", action: "" }, }; case "agent-session": return { type: "agent-session", + ...rowRuntimeOptions(actionToRow(action)), ...(action.modelConfig ? { modelConfig: action.modelConfig } : {}), ...(action.permissionConfig ? { permissionConfig: action.permissionConfig } : {}), ...(action.prompt ? { prompt: action.prompt } : {}), @@ -445,6 +570,7 @@ function automationActionToDraftAction( case "launch-mission": return { type: "launch-mission", + ...rowRuntimeOptions(actionToRow(action)), ...(action.sessionTitle ? { missionTitle: action.sessionTitle } : {}), }; case "lane-setup": @@ -454,6 +580,10 @@ function automationActionToDraftAction( } } +function actionToRow(action: AutomationAction): ActionRowValue { + return { kind: action.type === "lane-setup" ? "predict-conflicts" : action.type, ...actionRuntimeOptions(action) } as ActionRowValue; +} + // --- component --- export function RuleEditorPanel({ @@ -496,6 +626,9 @@ export function RuleEditorPanel({ const actionRows = useMemo(() => draftToActionRows(draft), [draft]); const includeProjectContext = computeIncludeProjectContext(draft); const modelValue = draft.modelConfig?.orchestratorModel ?? { modelId: DEFAULT_MODEL_ID, thinkingLevel: "medium" as const }; + const outputs = draft.outputs ?? { disposition: "comment-only" as const, createArtifact: true }; + const verification = draft.verification ?? { verifyBeforePublish: false, mode: "intervention" as const }; + const toolPalette: AutomationToolFamily[] = draft.toolPalette ?? ["repo", "memory", "mission"]; const permissionMeta = permissionControlsForModel(modelValue.modelId); const currentPermission = permissionMeta ? draft.permissionConfig?.providers?.[permissionMeta.key] ?? "" @@ -503,7 +636,7 @@ export function RuleEditorPanel({ // laneMode resolution: missing → "reuse" (server-side migration handles // legacy create-lane-as-first-action collapse). - const laneMode: AutomationLaneMode = draft.execution?.laneMode ?? "reuse"; + const laneMode = readLaneMode(draft); const lanePreset: AutomationLaneNamePreset = draft.execution?.laneNamePreset ?? "issue-title"; const laneCustomTemplate = draft.execution?.laneNameTemplate ?? ""; const laneTargetLaneId = draft.execution?.targetLaneId ?? null; @@ -530,7 +663,7 @@ export function RuleEditorPanel({ const patchExecution = ( patch: Partial<{ - laneMode: AutomationLaneMode; + laneMode: DraftLaneMode; targetLaneId: string | null; laneNamePreset: AutomationLaneNamePreset; laneNameTemplate: string; @@ -538,14 +671,15 @@ export function RuleEditorPanel({ ) => { const current = draft.execution ?? { kind: "agent-session" as const }; const next = { ...current }; - if (patch.laneMode !== undefined) next.laneMode = patch.laneMode; + if (patch.laneMode !== undefined) (next as { laneMode?: string }).laneMode = patch.laneMode; if (patch.laneNamePreset !== undefined) next.laneNamePreset = patch.laneNamePreset; if (patch.laneNameTemplate !== undefined) next.laneNameTemplate = patch.laneNameTemplate; if (patch.targetLaneId !== undefined) { if (patch.targetLaneId == null) delete next.targetLaneId; else next.targetLaneId = patch.targetLaneId; } - setDraft({ ...draft, execution: next }); + const nextDraft = { ...draft, execution: next }; + setDraft(isRequireLaneAtRunTimeMode(next.laneMode) ? stripActionTargetLaneIdsFromDraft(nextDraft) : nextDraft); }; // Smart defaults: when the trigger event changes and the user hasn't yet @@ -640,6 +774,20 @@ export function RuleEditorPanel({ checked={draft.enabled} onChange={(next) => setDraft({ ...draft, enabled: next })} /> + @@ -766,6 +914,55 @@ export function RuleEditorPanel({ }); }} /> +
+ Review profile + +
+
+ Tool palette +
+ {TOOL_FAMILIES.map((tool) => { + const checked = toolPalette.includes(tool.value); + const wouldEmptyPalette = checked && toolPalette.length === 1; + return ( + + ); + })} +
+
Model +
+ + + setDraft({ + ...draft, + guardrails: { + ...draft.guardrails, + maxFindings: n == null ? undefined : Math.max(1, Math.floor(n)), + }, + }) + } + placeholder="Default" + /> +
patchTrigger({ activeHours: next ?? undefined })} @@ -833,6 +1071,86 @@ export function RuleEditorPanel({
+ + {/* Output */} +
+
+ + setDraft({ ...draft, outputs: { ...outputs, createArtifact: next } })} + /> + +
+ + + setDraft({ + ...draft, + verification: { ...verification, verifyBeforePublish: next }, + }) + } + /> +
+
+
{/* Right column — workflow steps */} @@ -850,6 +1168,7 @@ export function RuleEditorPanel({ lanes={lanes} suites={suites} fallbackModel={modelValue} + executionLaneMode={laneMode} onChange={setActionRows} onOpenAiSettings={openAiSettings} /> @@ -869,13 +1188,23 @@ function LaneModeControl({ lanes, onChange, }: { - laneMode: AutomationLaneMode; + laneMode: DraftLaneMode; targetLaneId: string | null; lanes: Array<{ id: string; name: string }>; - onChange: (patch: { laneMode?: AutomationLaneMode; targetLaneId?: string | null }) => void; + onChange: (patch: { laneMode?: DraftLaneMode; targetLaneId?: string | null }) => void; }) { - // Compose a single value: "create", "reuse:" (primary), or "reuse:". - const selectValue = laneMode === "create" ? "create" : `reuse:${targetLaneId ?? ""}`; + const knownMode = laneMode === "create" + || laneMode === "reuse" + || laneMode === "provided" + || laneMode === "prompt-at-run" + || laneMode === "require-on-trigger"; + const selectValue = laneMode === "create" + ? "create" + : laneMode === "provided" || laneMode === "prompt-at-run" || laneMode === "require-on-trigger" + ? "require-on-trigger" + : laneMode === "reuse" + ? `reuse:${targetLaneId ?? ""}` + : `unknown:${laneMode}`; const sortedLanes = useMemo(() => [...lanes].sort((a, b) => a.name.localeCompare(b.name)), [lanes]); return ( @@ -890,6 +1219,10 @@ function LaneModeControl({ onChange({ laneMode: "create", targetLaneId: null }); return; } + if (v === "require-on-trigger") { + onChange({ laneMode: "require-on-trigger", targetLaneId: null }); + return; + } if (v === "reuse:") { onChange({ laneMode: "reuse", targetLaneId: null }); return; @@ -900,11 +1233,17 @@ function LaneModeControl({ }} > + {sortedLanes.map((lane) => ( ))} + {!knownMode ? ( + + ) : null} ); @@ -1366,4 +1705,3 @@ function ActiveHoursFields({ ); } - diff --git a/apps/desktop/src/renderer/components/automations/shared.ts b/apps/desktop/src/renderer/components/automations/shared.ts index 56cc728b1..38c51f8c7 100644 --- a/apps/desktop/src/renderer/components/automations/shared.ts +++ b/apps/desktop/src/renderer/components/automations/shared.ts @@ -16,3 +16,8 @@ export const CARD_STYLE: React.CSSProperties = {}; export function extractError(err: unknown): string { return err instanceof Error ? err.message : String(err); } + +/** Parse a comma-separated input into a trimmed, non-empty string list. */ +export function parseList(value: string): string[] { + return value.split(",").map((entry) => entry.trim()).filter(Boolean); +} diff --git a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx index 8aa13e3d3..28e38bfe7 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx @@ -626,6 +626,7 @@ export function AgentChatComposer({ hideNativeControls = false, messagePlaceholder, onModelChange, + onModelCatalogOpen, onReasoningEffortChange, onCodexFastModeChange, onDraftChange, @@ -735,6 +736,7 @@ export function AgentChatComposer({ hideNativeControls?: boolean; messagePlaceholder?: string; onModelChange: (modelId: string) => void; + onModelCatalogOpen?: () => void; onReasoningEffortChange: (reasoningEffort: string | null) => void; onCodexFastModeChange?: (enabled: boolean) => void; onDraftChange: (value: string) => void; @@ -2870,6 +2872,7 @@ export function AgentChatComposer({ onParallelSlotModelChange?.(parallelConfiguringIndex, next)} + onOpen={onModelCatalogOpen} availableModelIds={availableModelIds} disabled={parallelLaunchBusy} showReasoning @@ -2890,6 +2893,7 @@ export function AgentChatComposer({ { }); }); + it("does not auto-fetch Cursor inventory on chat boot", async () => { + let resolveProjectConfig: (value: unknown) => void = () => {}; + const projectConfig = new Promise((resolve) => { + resolveProjectConfig = resolve; + }); + installAdeMocks({ + sessions: [], + }); + useAppStore.setState({ + project: { rootPath: "/tmp/project-under-test" } as any, + lanes: [{ + id: "lane-1", + name: "Lane 1", + laneType: "worktree", + branchRef: "refs/heads/lane-1", + worktreePath: "/tmp/project-under-test/lane-1", + } as any], + selectedLaneId: "lane-1", + }); + window.ade.projectConfig.get = vi.fn().mockReturnValue(projectConfig) as any; + window.ade.ai.getStatus = vi.fn().mockResolvedValue({ + mode: "subscription", + availableProviders: { claude: false, codex: true, cursor: true, droid: false }, + models: { claude: [], codex: [], cursor: [], droid: [] }, + features: [], + detectedAuth: [ + { type: "cli-subscription", cli: "codex", authenticated: true }, + { type: "api-key", provider: "cursor" }, + ], + availableModelIds: [], + }) as any; + window.ade.agentChat.models = vi.fn().mockResolvedValue([]) as any; + + render( + + + , + ); + + expect(await screen.findByText("Loading sessions")).toBeTruthy(); + + await act(async () => { + resolveProjectConfig({ + effective: { + ai: { + chat: { + sendOnEnter: true, + }, + }, + }, + }); + }); + + await waitFor(() => { + expect(screen.queryByText("Loading sessions")).toBeNull(); + }); + expect(await screen.findByText("Start a new conversation")).toBeTruthy(); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(window.ade.agentChat.models).not.toHaveBeenCalledWith( + expect.objectContaining({ provider: "cursor" }), + ); + }); + + it("uses Cursor model IDs from AI status without probing Cursor inventory", async () => { + installAdeMocks({ + sessions: [], + }); + useAppStore.setState({ + project: { rootPath: "/tmp/project-under-test" } as any, + lanes: [{ + id: "lane-1", + name: "Lane 1", + laneType: "worktree", + branchRef: "refs/heads/lane-1", + worktreePath: "/tmp/project-under-test/lane-1", + } as any], + selectedLaneId: "lane-1", + }); + window.ade.ai.getStatus = vi.fn().mockResolvedValue({ + mode: "subscription", + availableProviders: { claude: false, codex: true, cursor: true, droid: false }, + models: { claude: [], codex: [], cursor: [], droid: [] }, + features: [], + detectedAuth: [ + { type: "cli-subscription", cli: "codex", authenticated: true }, + { type: "api-key", provider: "cursor" }, + ], + availableModelIds: ["cursor/auto"], + }) as any; + window.ade.agentChat.models = vi.fn().mockResolvedValue([]) as any; + + render( + + + , + ); + + expect(await screen.findByText("Start a new conversation")).toBeTruthy(); + await waitFor(() => { + expect(window.ade.ai.getStatus).toHaveBeenCalled(); + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(window.ade.agentChat.models).not.toHaveBeenCalled(); + }); + it("keeps the committed model visible until the backend confirms the switch", async () => { const session = buildSession("session-1", { status: "idle" }); const sessions = [session]; diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx index 41fab273c..ec45730fd 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx @@ -1505,6 +1505,25 @@ function chatSessionTitle(session: AgentChatSessionSummary): string { return descriptor?.displayName ?? `${session.provider}/${session.model}`; } +function orderAvailableModelIds(ids: Iterable): string[] { + const available = new Set(ids); + const ordered = MODEL_REGISTRY + .filter((model) => !model.deprecated && available.has(model.id)) + .map((model) => model.id); + const extra = [...available].filter((modelId) => !ordered.includes(modelId)); + extra.sort((left, right) => { + const leftLabel = getModelById(left)?.displayName ?? left; + const rightLabel = getModelById(right)?.displayName ?? right; + return leftLabel.localeCompare(rightLabel, undefined, { sensitivity: "base" }); + }); + return [...ordered, ...extra]; +} + +function isCursorModelId(id: string): boolean { + return id.startsWith("cursor/") + || getModelById(id)?.family === "cursor"; +} + function completionBadgeClass(status: NonNullable["status"]): string { switch (status) { case "completed": return "border-emerald-400/20 bg-emerald-400/[0.08] text-emerald-300"; @@ -1689,6 +1708,12 @@ export function AgentChatPane({ const [availableModelIds, setAvailableModelIds] = useState(() => seedAiStatus ? deriveConfiguredModelIds(seedAiStatus, { includeDroid: true }) : [], ); + const availableModelIdsRef = useRef(availableModelIds); + const availableModelsRefreshSeqRef = useRef(0); + const cursorInventoryRefreshSeqRef = useRef(0); + useEffect(() => { + availableModelIdsRef.current = availableModelIds; + }, [availableModelIds]); const [claudePermissionMode, setClaudePermissionMode] = useState(initialNativeControls.claudePermissionMode); const [codexApprovalPolicy, setCodexApprovalPolicy] = useState(initialNativeControls.codexApprovalPolicy); const [codexSandbox, setCodexSandbox] = useState(initialNativeControls.codexSandbox); @@ -2693,24 +2718,7 @@ export function AgentChatPane({ }); const refreshAvailableModels = useCallback(async () => { - const orderModelIds = (ids: Iterable): string[] => { - const available = new Set(ids); - const ordered = MODEL_REGISTRY - .filter((model) => !model.deprecated && available.has(model.id)) - .map((model) => model.id); - const extra = [...available].filter((modelId) => !ordered.includes(modelId)); - extra.sort((left, right) => { - const leftLabel = getModelById(left)?.displayName ?? left; - const rightLabel = getModelById(right)?.displayName ?? right; - return leftLabel.localeCompare(rightLabel, undefined, { sensitivity: "base" }); - }); - return [...ordered, ...extra]; - }; - const isCursorModelId = (id: string): boolean => ( - id.startsWith("cursor/") - || getModelById(id)?.family === "cursor" - ); - + ++availableModelsRefreshSeqRef.current; const selectedModelProvider = modelId.trim() ? resolveChatRuntimeProvider(getModelById(modelId)) : null; @@ -2733,36 +2741,9 @@ export function AgentChatPane({ droid: status.providerConnections?.droid ?? null, }); const available = deriveConfiguredModelIds(status, { includeDroid: true }); - const orderedAvailable = orderModelIds(available); + const orderedAvailable = orderAvailableModelIds(available); setAvailableModelIds(orderedAvailable); - const cursorReady = status.availableProviders?.cursor === true - || status.providerConnections?.cursor?.runtimeAvailable === true; - if (!cursorReady) return orderedAvailable; - - let cursorModels: Awaited>; - try { - cursorModels = await getAgentChatModelsCached({ - projectRoot, - provider: "cursor", - activateRuntime: true, - }); - } catch { - return orderedAvailable; - } - if (!cursorModels.length) { - const withoutCursor = orderedAvailable.filter((id) => !isCursorModelId(id)); - setAvailableModelIds(withoutCursor); - return withoutCursor; - } - - const merged = new Set(available); - for (const model of cursorModels) { - const resolved = resolveCliRegistryModelId("cursor", model.id); - if (resolved) merged.add(resolved); - } - const withCursor = orderModelIds(merged); - setAvailableModelIds(withCursor); - return withCursor; + return orderedAvailable; } catch { setAiStatus(null); setProviderConnections(null); @@ -2808,7 +2789,7 @@ export function AgentChatPane({ } } - const allAvailable = orderModelIds(available); + const allAvailable = orderAvailableModelIds(available); setAvailableModelIds(allAvailable); return allAvailable; } catch { @@ -2817,6 +2798,46 @@ export function AgentChatPane({ } }, [modelId, projectRoot, selectedSession?.provider, sessionProvider]); + const refreshCursorModelInventory = useCallback(async () => { + const cursorRefreshSeq = ++cursorInventoryRefreshSeqRef.current; + const status = aiStatus; + const cursorExplicitlyUnavailable = + status != null + && status.availableProviders?.cursor !== true + && status.providerConnections?.cursor?.runtimeAvailable !== true; + if (cursorExplicitlyUnavailable) return; + if (availableModelIdsRef.current.some(isCursorModelId)) return; + const refreshSeq = availableModelsRefreshSeqRef.current; + let cursorModels: Awaited>; + try { + cursorModels = await getAgentChatModelsCached({ + projectRoot, + provider: "cursor", + activateRuntime: true, + }); + } catch { + return; + } + if ( + availableModelsRefreshSeqRef.current !== refreshSeq + || cursorInventoryRefreshSeqRef.current !== cursorRefreshSeq + ) { + return; + } + if (!cursorModels.length) { + setAvailableModelIds((prev) => prev.filter((id) => !isCursorModelId(id))); + return; + } + setAvailableModelIds((prev) => { + const merged = new Set(prev); + for (const model of cursorModels) { + const resolved = resolveCliRegistryModelId("cursor", model.id); + if (resolved) merged.add(resolved); + } + return orderAvailableModelIds(merged); + }); + }, [aiStatus, projectRoot]); + const touchSession = useCallback((sessionId: string | null | undefined, touchedAt = new Date().toISOString()) => { if (!sessionId) return; const previousTouch = localTouchBySessionRef.current.get(sessionId); @@ -5270,6 +5291,7 @@ export function AgentChatPane({ { void updateNativeControls({ interactionMode: value }); }} onClaudeModeChange={handleClaudeModeChange} onClaudePermissionModeChange={(value) => { void updateNativeControls({ claudePermissionMode: value }); }} diff --git a/apps/desktop/src/renderer/components/graph/WorkspaceGraphPage.tsx b/apps/desktop/src/renderer/components/graph/WorkspaceGraphPage.tsx index 2e5b1a7e9..b94af7904 100644 --- a/apps/desktop/src/renderer/components/graph/WorkspaceGraphPage.tsx +++ b/apps/desktop/src/renderer/components/graph/WorkspaceGraphPage.tsx @@ -259,6 +259,7 @@ function GraphInner() { const [viewMode, setViewMode] = React.useState("all"); const [sessionState, setSessionState] = React.useState(createSessionState); const [loadedGraphPreferences, setLoadedGraphPreferences] = React.useState(false); + const skipNextGraphPreferencePersistRootRef = React.useRef(null); const [nodes, setNodes] = React.useState>>([]); const [edges, setEdges] = React.useState>>([]); @@ -871,14 +872,20 @@ function GraphInner() { }, [refreshIntegrationProposals, reportGraphIssue]); React.useEffect(() => { - if (!project?.rootPath) return; + if (!project?.rootPath) { + setLoadedGraphPreferences(false); + skipNextGraphPreferencePersistRootRef.current = null; + return; + } const rootPath = project.rootPath; let cancelled = false; + setLoadedGraphPreferences(false); void window.ade.graphState .get(rootPath) .then((state) => { if (cancelled) return; const normalized = normalizeGraphPreferences(state); + skipNextGraphPreferencePersistRootRef.current = rootPath; setViewMode(normalized.preferences.lastViewMode); if (normalized.migrated) { void window.ade.graphState.set(rootPath, normalized.preferences).catch(() => {}); @@ -887,6 +894,7 @@ function GraphInner() { .catch((err) => { console.warn("[Graph] Failed to load graph state:", err); if (cancelled) return; + skipNextGraphPreferencePersistRootRef.current = rootPath; setViewMode(createGraphPreferences().lastViewMode); }) .finally(() => { @@ -899,6 +907,10 @@ function GraphInner() { React.useEffect(() => { if (!project?.rootPath || !loadedGraphPreferences) return; + if (skipNextGraphPreferencePersistRootRef.current === project.rootPath) { + skipNextGraphPreferencePersistRootRef.current = null; + return; + } void window.ade.graphState.set(project.rootPath, createGraphPreferences(viewMode)).catch(() => {}); }, [loadedGraphPreferences, project?.rootPath, viewMode]); diff --git a/apps/desktop/src/renderer/components/lanes/LaneGitActionsPane.test.tsx b/apps/desktop/src/renderer/components/lanes/LaneGitActionsPane.test.tsx index bf33c6e68..f4dd90dab 100644 --- a/apps/desktop/src/renderer/components/lanes/LaneGitActionsPane.test.tsx +++ b/apps/desktop/src/renderer/components/lanes/LaneGitActionsPane.test.tsx @@ -226,6 +226,7 @@ describe("LaneGitActionsPane rescue action", () => { expect(window.ade.diff.getChanges).toHaveBeenCalledWith({ laneId: "lane-1" }); expect(window.ade.git.getSyncStatus).toHaveBeenCalledWith({ laneId: "lane-1" }); expect(window.ade.git.getSyncStatus).toHaveBeenCalledTimes(1); + expect(window.ade.lanes.listAutoRebaseStatuses).not.toHaveBeenCalled(); }); it("blocks pull and surfaces merge recovery actions when a merge is in progress", async () => { @@ -399,7 +400,10 @@ describe("LaneGitActionsPane rescue action", () => { }, ]; - renderPane({ onResolveRebaseConflict: resolveRebaseConflict }); + renderPane({ + autoRebaseStatusSnapshot: mockAutoRebaseStatuses[0] ?? null, + onResolveRebaseConflict: resolveRebaseConflict, + }); const rebaseTabButton = await screen.findByRole("button", { name: /open rebase\/merge tab/i }); screen.getByText("AUTO-REBASE FAILED"); diff --git a/apps/desktop/src/renderer/components/lanes/LaneGitActionsPane.tsx b/apps/desktop/src/renderer/components/lanes/LaneGitActionsPane.tsx index 2425e7e86..9a72e56e1 100644 --- a/apps/desktop/src/renderer/components/lanes/LaneGitActionsPane.tsx +++ b/apps/desktop/src/renderer/components/lanes/LaneGitActionsPane.tsx @@ -452,6 +452,7 @@ function ActionButton({ export function LaneGitActionsPane({ laneId, autoRebaseEnabled, + autoRebaseStatusSnapshot, onOpenSettings, onRebaseNowLocal, onRebaseAndPush, @@ -467,6 +468,7 @@ export function LaneGitActionsPane({ }: { laneId: string | null; autoRebaseEnabled: boolean; + autoRebaseStatusSnapshot?: AutoRebaseLaneStatus | null; onOpenSettings: () => void; onRebaseNowLocal?: (laneId: string) => Promise | void; onRebaseAndPush?: (laneId: string) => Promise | void; @@ -517,7 +519,8 @@ export function LaneGitActionsPane({ const [commitTimelineKey, setCommitTimelineKey] = useState(0); const [amendCommit, setAmendCommit] = useState(false); const [showAdvanced, setShowAdvanced] = useState(false); - const [autoRebaseStatus, setAutoRebaseStatus] = useState(null); + const [autoRebaseStatus, setAutoRebaseStatus] = useState(autoRebaseStatusSnapshot ?? null); + const autoRebaseStatusSnapshotRef = useRef(autoRebaseStatusSnapshot); const [conflictState, setConflictState] = useState(null); const [stuckRebase, setStuckRebase] = useState(null); const laneGitActionRuntime = useLaneGitActionRuntimeState(laneId); @@ -709,6 +712,13 @@ export function LaneGitActionsPane({ } }, [projectRoot]); + useEffect(() => { + autoRebaseStatusSnapshotRef.current = autoRebaseStatusSnapshot; + if (autoRebaseStatusSnapshot !== undefined) { + setAutoRebaseStatus(autoRebaseStatusSnapshot); + } + }, [autoRebaseStatusSnapshot]); + const isNonFastForwardError = useCallback((rawMessage: string): boolean => { const lower = rawMessage.toLowerCase(); return lower.includes("non-fast-forward") || lower.includes("failed to push some refs"); @@ -838,7 +848,7 @@ export function LaneGitActionsPane({ setForcePushSuggested(false); setAmendCommit(false); setCommitMessageAi({ enabled: false, modelId: null }); - setAutoRebaseStatus(null); + setAutoRebaseStatus(autoRebaseStatusSnapshotRef.current ?? null); setConflictState(null); setStuckRebase(null); if (!laneId) return; @@ -848,9 +858,20 @@ export function LaneGitActionsPane({ error: err instanceof Error ? err.message : String(err), }); }); - void refreshAutoRebaseStatus(laneId); void refreshCommitMessageAiState(); - }, [laneId, lane?.branchRef, refreshAutoRebaseStatus, refreshCommitMessageAiState]); + }, [laneId, lane?.branchRef, refreshCommitMessageAiState]); + + useEffect(() => { + if (!laneId) return; + if (autoRebaseStatusSnapshotRef.current !== undefined) return; + const targetLaneId = laneId; + const timer = window.setTimeout(() => { + if (document.visibilityState !== "visible") return; + if (autoRebaseStatusSnapshotRef.current !== undefined) return; + void refreshAutoRebaseStatus(targetLaneId); + }, 3_500); + return () => window.clearTimeout(timer); + }, [laneId, lane?.branchRef, refreshAutoRebaseStatus]); useEffect(() => { if (!laneId) return; diff --git a/apps/desktop/src/renderer/components/lanes/LanesPage.tsx b/apps/desktop/src/renderer/components/lanes/LanesPage.tsx index 2c71e8ff3..2a7cafe24 100644 --- a/apps/desktop/src/renderer/components/lanes/LanesPage.tsx +++ b/apps/desktop/src/renderer/components/lanes/LanesPage.tsx @@ -2147,6 +2147,7 @@ export function LanesPage() { const getPaneConfigs = useCallback((laneId: string | null) => { const laneDetail = laneId ? lanePaneDetails[laneId] ?? EMPTY_LANE_PANE_DETAIL : EMPTY_LANE_PANE_DETAIL; + const laneSnapshot = laneId ? laneSnapshotByLaneId.get(laneId) ?? null : null; return { "git-actions": { title: "Git Actions", @@ -2180,6 +2181,7 @@ export function LanesPage() { runRebaseFlow(targetLaneId, "local_only")} onRebaseAndPush={(targetLaneId) => runRebaseFlow(targetLaneId, "local_and_remote")} @@ -2211,6 +2213,7 @@ export function LanesPage() { }; }, [ lanePaneDetails, + laneSnapshotByLaneId, expandedGitActionsLaneId, autoRebaseEnabled, openAutoRebaseSettings, diff --git a/apps/desktop/src/renderer/components/prs/state/PrsContext.test.tsx b/apps/desktop/src/renderer/components/prs/state/PrsContext.test.tsx index f5369e83d..dca378cff 100644 --- a/apps/desktop/src/renderer/components/prs/state/PrsContext.test.tsx +++ b/apps/desktop/src/renderer/components/prs/state/PrsContext.test.tsx @@ -50,6 +50,8 @@ function TabSwitchHarness() { describe("PrsContext refresh", () => { beforeEach(() => { + window.history.replaceState(null, "", "/"); + window.location.hash = ""; const refreshedNeed: RebaseNeed = { laneId: "lane-1", laneName: "Lane 1", @@ -82,19 +84,11 @@ describe("PrsContext refresh", () => { }, lanes: { list: vi.fn().mockResolvedValue([]), - listAutoRebaseStatuses: vi - .fn() - .mockResolvedValueOnce([]) - .mockResolvedValueOnce([]) - .mockResolvedValue([refreshedAutoStatus]), + listAutoRebaseStatuses: vi.fn().mockResolvedValue([refreshedAutoStatus]), onAutoRebaseEvent: vi.fn(() => () => {}), }, rebase: { - scanNeeds: vi - .fn() - .mockResolvedValueOnce([]) - .mockResolvedValueOnce([]) - .mockResolvedValue([refreshedNeed]), + scanNeeds: vi.fn().mockResolvedValue([refreshedNeed]), onEvent: vi.fn(() => () => {}), }, } as any; @@ -104,9 +98,47 @@ describe("PrsContext refresh", () => { cleanup(); globalThis.window.ade = originalAde; window.location.hash = ""; + window.history.replaceState(null, "", "/"); }); - it("refreshes rebase needs and auto-rebase statuses without waiting for events", async () => { + it("skips rebase scans for the plain GitHub PR list", async () => { + render( + + + , + ); + + await waitFor(() => { + expect(screen.getByTestId("loading").textContent).toBe("idle"); + }); + expect(window.ade.rebase.scanNeeds).not.toHaveBeenCalled(); + expect(window.ade.lanes.listAutoRebaseStatuses).not.toHaveBeenCalled(); + expect(window.ade.lanes.list).toHaveBeenCalledWith({ includeStatus: false }); + expect(window.ade.prs.refresh).not.toHaveBeenCalled(); + }); + + it("refreshes rebase needs and auto-rebase statuses for workflow routes without waiting for events", async () => { + window.location.hash = "#/prs?tab=workflows&workflow=rebase&laneId=lane-1"; + + render( + + + , + ); + + await waitFor(() => { + expect(screen.getByTestId("loading").textContent).toBe("idle"); + }); + + await waitFor(() => { + expect(screen.getByTestId("needs-count").textContent).toBe("1"); + expect(screen.getByTestId("auto-count").textContent).toBe("1"); + }); + expect(window.ade.lanes.list).toHaveBeenCalledWith({ includeStatus: true }); + expect(window.ade.prs.refresh).toHaveBeenCalledTimes(1); + }); + + it("runs a GitHub PR refresh for explicit refresh actions", async () => { const user = userEvent.setup(); render( @@ -118,12 +150,12 @@ describe("PrsContext refresh", () => { await waitFor(() => { expect(screen.getByTestId("loading").textContent).toBe("idle"); }); + expect(window.ade.prs.refresh).not.toHaveBeenCalled(); await user.click(screen.getByRole("button", { name: "refresh" })); await waitFor(() => { - expect(screen.getByTestId("needs-count").textContent).toBe("1"); - expect(screen.getByTestId("auto-count").textContent).toBe("1"); + expect(window.ade.prs.refresh).toHaveBeenCalledTimes(1); }); }); @@ -139,14 +171,14 @@ describe("PrsContext refresh", () => { await waitFor(() => { expect(screen.getByTestId("loading").textContent).toBe("idle"); }); - expect(window.ade.prs.refresh).toHaveBeenCalledTimes(1); + expect(window.ade.prs.refresh).not.toHaveBeenCalled(); await user.click(screen.getByRole("button", { name: "queue" })); await waitFor(() => { expect(screen.getByTestId("active-tab").textContent).toBe("queue"); }); - expect(window.ade.prs.refresh).toHaveBeenCalledTimes(1); + expect(window.ade.prs.refresh).not.toHaveBeenCalled(); }); it("hydrates the Rebase/Merge workflow selection from the initial hash route", async () => { diff --git a/apps/desktop/src/renderer/components/prs/state/PrsContext.tsx b/apps/desktop/src/renderer/components/prs/state/PrsContext.tsx index 8a63f8234..83710bf6e 100644 --- a/apps/desktop/src/renderer/components/prs/state/PrsContext.tsx +++ b/apps/desktop/src/renderer/components/prs/state/PrsContext.tsx @@ -674,20 +674,25 @@ export function PrsProvider({ children }: { children: React.ReactNode }) { // kicks off another refresh instead of silently dropping the request. const applyLocalPrState = useCallback(async () => { const shouldLoadWorkflowState = activeTabRef.current !== "normal"; + const shouldLoadRebaseState = shouldLoadWorkflowState || selectedPrIdRef.current !== null; const [prList, laneList, queueStateList, refreshedRebaseNeeds, refreshedAutoRebaseStatuses] = await Promise.all([ window.ade.prs.listWithConflicts(), - window.ade.lanes.list({ includeStatus: true }), + window.ade.lanes.list({ includeStatus: shouldLoadRebaseState }), shouldLoadWorkflowState ? window.ade.prs.listQueueStates({ includeCompleted: true, limit: 50 }) : Promise.resolve([] as QueueLandingState[]), - window.ade.rebase.scanNeeds().catch((err) => { - console.warn("[PrsContext] Failed to refresh rebase needs:", err); - return rebaseNeedsRef.current; - }), - window.ade.lanes.listAutoRebaseStatuses().catch((err) => { - console.warn("[PrsContext] Failed to refresh auto-rebase statuses:", err); - return autoRebaseStatusesRef.current; - }), + shouldLoadRebaseState + ? window.ade.rebase.scanNeeds().catch((err) => { + console.warn("[PrsContext] Failed to refresh rebase needs:", err); + return rebaseNeedsRef.current; + }) + : Promise.resolve(rebaseNeedsRef.current), + shouldLoadRebaseState + ? window.ade.lanes.listAutoRebaseStatuses().catch((err) => { + console.warn("[PrsContext] Failed to refresh auto-rebase statuses:", err); + return autoRebaseStatusesRef.current; + }) + : Promise.resolve(autoRebaseStatusesRef.current), ]); const changedPrIds = diffPrIds(prsRef.current, prList); @@ -790,7 +795,12 @@ export function PrsProvider({ children }: { children: React.ReactNode }) { // Initial load useEffect(() => { - void refreshCore({ skipFreshWarmCache: true, githubRefreshMode: "background" }); + const shouldRefreshFromGithub = + activeTabRef.current !== "normal" || selectedPrIdRef.current !== null; + void refreshCore({ + skipFreshWarmCache: true, + githubRefreshMode: shouldRefreshFromGithub ? "background" : undefined, + }); }, [refreshCore]); // Silently refresh detail data for the given PR (no loading state). @@ -1127,9 +1137,16 @@ export function PrsProvider({ children }: { children: React.ReactNode }) { return unsub; }, []); - // Periodic rebase needs scan (cancelled flag guards against setState after unmount) + // Periodic rebase needs scan (cancelled flag guards against setState after unmount). + // The plain GitHub PR list does not render rebase workflow state, so avoid + // doing that git work until a workflow tab or selected PR detail can use it. useEffect(() => { let cancelled = false; + if (activeTab === "normal" && selectedPrId == null) { + return () => { + cancelled = true; + }; + } const scan = () => { window.ade.rebase.scanNeeds().then((needs) => { if (!cancelled) setRebaseNeeds(needs); @@ -1143,13 +1160,10 @@ export function PrsProvider({ children }: { children: React.ReactNode }) { cancelled = true; clearInterval(timer); }; - }, []); + }, [activeTab, selectedPrId]); // Subscribe to auto-rebase events useEffect(() => { - window.ade.lanes.listAutoRebaseStatuses().then(setAutoRebaseStatuses).catch((err) => { - console.warn("[PrsContext] Failed to list auto-rebase statuses:", err); - }); const unsub = window.ade.lanes.onAutoRebaseEvent((event: AutoRebaseEventPayload) => { if (event.type === "auto-rebase-updated") { setAutoRebaseStatuses(event.statuses); @@ -1158,6 +1172,19 @@ export function PrsProvider({ children }: { children: React.ReactNode }) { return unsub; }, []); + useEffect(() => { + if (activeTab === "normal" && selectedPrId == null) return; + let cancelled = false; + window.ade.lanes.listAutoRebaseStatuses().then((statuses) => { + if (!cancelled) setAutoRebaseStatuses(statuses); + }).catch((err) => { + console.warn("[PrsContext] Failed to list auto-rebase statuses:", err); + }); + return () => { + cancelled = true; + }; + }, [activeTab, selectedPrId]); + useEffect(() => { if (PRS_CONTEXT_CACHE_DISABLED) return; if (!initialLoadDone.current && prs.length === 0 && lanes.length === 0) return; diff --git a/apps/desktop/src/renderer/components/settings/UsageGuardrailsSection.test.tsx b/apps/desktop/src/renderer/components/settings/UsageGuardrailsSection.test.tsx new file mode 100644 index 000000000..d782cd487 --- /dev/null +++ b/apps/desktop/src/renderer/components/settings/UsageGuardrailsSection.test.tsx @@ -0,0 +1,83 @@ +/* @vitest-environment jsdom */ + +import React from "react"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { UsageSnapshot } from "../../../shared/types"; +import { UsageGuardrailsSection } from "./UsageGuardrailsSection"; + +function makeSnapshot(): UsageSnapshot { + return { + windows: [], + pacing: { + status: "on-track", + projectedWeeklyPercent: 0, + weekElapsedPercent: 0, + expectedPercent: 0, + deltaPercent: 0, + etaHours: null, + willLastToReset: true, + resetsInHours: 0, + }, + costs: [], + extraUsage: [], + lastPolledAt: "2026-05-08T07:00:00.000Z", + errors: [], + }; +} + +describe("UsageGuardrailsSection", () => { + const originalAde = globalThis.window.ade; + + beforeEach(() => { + globalThis.window.ade = { + usage: { + getSnapshot: vi.fn().mockResolvedValue(makeSnapshot()), + refresh: vi.fn().mockResolvedValue(makeSnapshot()), + getBudgetConfig: vi.fn().mockResolvedValue({}), + saveBudgetConfig: vi.fn().mockResolvedValue({}), + onUpdate: vi.fn(() => () => {}), + }, + ai: { + getStatus: vi.fn().mockResolvedValue({ + providerConnections: { + claude: null, + codex: null, + cursor: null, + droid: null, + }, + }), + }, + } as any; + }); + + afterEach(() => { + cleanup(); + globalThis.window.ade = originalAde; + }); + + it("hydrates from the cached snapshot on mount instead of forcing a live usage poll", async () => { + render(); + + await waitFor(() => { + expect(window.ade.usage.getSnapshot).toHaveBeenCalledTimes(1); + expect(window.ade.usage.getBudgetConfig).toHaveBeenCalledTimes(1); + }); + expect(window.ade.usage.refresh).not.toHaveBeenCalled(); + }); + + it("keeps live provider polling available through the manual refresh button", async () => { + render(); + + await waitFor(() => { + const refreshButton = screen.getByRole("button", { name: /refresh/i }) as HTMLButtonElement; + expect(refreshButton.disabled).toBe(false); + }); + + fireEvent.click(screen.getByRole("button", { name: /refresh/i })); + + await waitFor(() => { + expect(window.ade.usage.refresh).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/apps/desktop/src/renderer/components/settings/UsageGuardrailsSection.tsx b/apps/desktop/src/renderer/components/settings/UsageGuardrailsSection.tsx index 8bb04ebee..8011eeb57 100644 --- a/apps/desktop/src/renderer/components/settings/UsageGuardrailsSection.tsx +++ b/apps/desktop/src/renderer/components/settings/UsageGuardrailsSection.tsx @@ -74,7 +74,7 @@ export function UsageGuardrailsSection({ setError(null); try { const [nextSnapshot, nextBudgetConfig] = await Promise.all([ - window.ade.usage.refresh(), + window.ade.usage.getSnapshot(), window.ade.usage.getBudgetConfig(), ]); setSnapshot(nextSnapshot); diff --git a/apps/desktop/src/renderer/components/shared/ProviderModelSelector.tsx b/apps/desktop/src/renderer/components/shared/ProviderModelSelector.tsx index e3ef2181a..f3ba75553 100644 --- a/apps/desktop/src/renderer/components/shared/ProviderModelSelector.tsx +++ b/apps/desktop/src/renderer/components/shared/ProviderModelSelector.tsx @@ -12,6 +12,7 @@ import { SmartTooltip } from "../ui/SmartTooltip"; type ProviderModelSelectorProps = { value: string; onChange: (modelId: string) => void; + onOpen?: () => void; filter?: (model: ModelDescriptor) => boolean; availableModelIds?: string[]; catalogMode?: "all" | "available-only"; @@ -48,6 +49,7 @@ function tierLabel(tier: string): string { export function ProviderModelSelector({ value, onChange, + onOpen, filter, availableModelIds, catalogMode = "all", @@ -210,6 +212,7 @@ export function ProviderModelSelector({ disabled={disabled} onClick={() => { if (disabled) return; + if (!open) onOpen?.(); setOpen((current) => !current); }} className={cn( diff --git a/apps/desktop/src/shared/types/automations.ts b/apps/desktop/src/shared/types/automations.ts index 10694eae2..1e441da0d 100644 --- a/apps/desktop/src/shared/types/automations.ts +++ b/apps/desktop/src/shared/types/automations.ts @@ -215,6 +215,7 @@ export type AutomationPlannerConfig = export type AutomationDraftActionBase = { type: AutomationActionType; + targetLaneId?: string | null; condition?: string; continueOnFailure?: boolean; timeoutMs?: number; diff --git a/apps/desktop/src/shared/types/config.ts b/apps/desktop/src/shared/types/config.ts index 4562db567..69eadad12 100644 --- a/apps/desktop/src/shared/types/config.ts +++ b/apps/desktop/src/shared/types/config.ts @@ -746,7 +746,7 @@ export type AutomationAction = { export type AutomationExecutionKind = "agent-session" | "mission" | "built-in"; -export type AutomationLaneMode = "create" | "reuse"; +export type AutomationLaneMode = "create" | "reuse" | "require-on-trigger"; export type AutomationLaneNamePreset = | "issue-title" @@ -758,7 +758,8 @@ export type AutomationExecution = { kind: AutomationExecutionKind; /** * Whether each run should spawn a fresh lane (`"create"`) or reuse the - * configured / trigger / primary lane (`"reuse"`). Defaults to `"reuse"`. + * configured / trigger / primary lane (`"reuse"`), or require the trigger + * caller/event to supply a lane (`"require-on-trigger"`). Defaults to `"reuse"`. */ laneMode?: AutomationLaneMode; /** @@ -837,8 +838,6 @@ export type AutomationOutputs = { notificationChannel?: string | null; }; -/** @deprecated Review/verification gate is no longer surfaced in the UI; kept - * for YAML compatibility so existing rules still load. */ export type AutomationVerification = { verifyBeforePublish: boolean; mode?: "intervention" | "dry-run"; @@ -858,9 +857,7 @@ export type AutomationRule = { permissionConfig?: MissionPermissionConfig; templateId?: string; prompt?: string; - /** @deprecated Review profile is no longer surfaced in the UI. */ reviewProfile: AutomationReviewProfile; - /** @deprecated Tool palette is no longer surfaced in the UI. */ toolPalette: AutomationToolFamily[]; /** @deprecated Replaced by `includeProjectContext` in the UI. */ contextSources: AutomationContextSource[]; diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 8df62800f..e33f59770 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -571,10 +571,14 @@ Enforced rules (from the stability overhaul): 2. New integrations are dormant-until-configured. 3. Feature pages stage data: cheapest (list/summary/topology) first, heavy (dashboard/settings/model metadata/overlays) on delay. 4. Never mount expensive trees eagerly — settings dialogs, advanced launcher sections unmount when closed. -5. Renderer polling is route-scoped; terminal attention only polls on terminal routes; lane panels only poll while live sessions exist. +5. Renderer polling is route-scoped; terminal attention only polls on terminal routes; lane panels only poll while live sessions exist. The plain PR list does not fire a GitHub refresh on mount and skips rebase-needs / auto-rebase polling until the user opens a workflow tab or selects a PR. The Lanes page reuses the `LaneSummary.autoRebaseStatus` snapshot already in the lane list instead of probing per-lane on `LaneGitActionsPane` mount; a fallback probe runs only when the snapshot is missing and after a visibility-gated 3.5 s delay. The Work top-bar sync chip refreshes on focus and on `sync-status` events instead of a 5 s interval. The chat composer's Cursor model inventory is fetched lazily — `ProviderModelSelector` calls `onOpen` on first open of the model catalog, and `AgentChatPane.refreshCursorModelInventory` is the only entry point that hits `cursor` with `activateRuntime: true`. 6. Shared caches for high-frequency calls (`sessionListCache`, GitHub fingerprint-based snapshots). 7. Memoize expensive renderer computations (`useMemo`, `React.memo`); isolate frequently-refreshing subtrees (e.g., budget footers). 8. `Promise.allSettled` over `Promise.all` for parallel startup — one failing service must not block others. +9. Settings sections that surface a snapshot read the cached snapshot on mount (`ade.usage.getSnapshot`) instead of forcing a refresh; an explicit Refresh button drives recompute. +10. Persistence callbacks dedupe against the last-saved value: the workspace-graph view-mode persister tracks the last-loaded preference root and skips the immediate write that the load handler's `setViewMode` would otherwise fire. + +CLI-launcher and shell-quoting helpers (`cliLaunch.ts`, `shell.ts`) live under `apps/desktop/src/renderer/` only — the prior `apps/desktop/src/shared/` copies were renderer-only in practice and have been removed. The mobile-launcher path (`work.startCliSession`) was retired with them; iOS launches CLI sessions through host-side actions that don't share renderer modules. Themes: six shipped themes (`e-paper`, `bloomberg`, `github`, `rainbow`, `sky`, `pats`), persisted in `localStorage.ade.theme`, applied via `data-theme` on root. Token-based palettes in `apps/desktop/src/renderer/index.css`. diff --git a/docs/features/chat/composer-and-ui.md b/docs/features/chat/composer-and-ui.md index b392aa685..3e4b9d1de 100644 --- a/docs/features/chat/composer-and-ui.md +++ b/docs/features/chat/composer-and-ui.md @@ -98,7 +98,14 @@ and a footer that contains the composer. - **Model selection.** `ProviderModelSelector` is embedded and filters the registry via `filterChatModelIdsForSession`. Switching within the allowed family is a normal update; crossing families triggers a - handoff. + handoff. The Cursor model inventory (`getAgentChatModelsCached` with + `provider: "cursor"`, `activateRuntime: true`) is no longer fetched + on chat boot — the selector exposes an `onOpen` callback that fires + the first time the user actually opens the model catalog, and + `AgentChatPane.refreshCursorModelInventory` is the only path that + performs the active probe. It also no-ops when the latest + `availableModelIds` already contains a Cursor entry, so re-opening + the catalog after a successful inventory does not refire the probe. - **Reasoning effort.** Dropdown for models that support reasoning tiers. - **Fast mode (Codex).** A yellow Lightning chip next to the model diff --git a/docs/features/lanes/README.md b/docs/features/lanes/README.md index 598d8ef67..66860f30a 100644 --- a/docs/features/lanes/README.md +++ b/docs/features/lanes/README.md @@ -40,7 +40,7 @@ Renderer components: | `renderer/components/lanes/LaneContextMenu.tsx` | Right-click menu on the lane list. Hosts the inline color swatch row that calls `lanes.updateAppearance` directly, "Reveal/Copy path", manage/adopt/open-in-Run actions, split-tab actions, and batch manage. | | `renderer/components/lanes/LaneStackPane.tsx` | Stack graph sidebar, integration source chips, canvas jump | | `renderer/components/lanes/LaneDiffPane.tsx` | Diff viewer, per-file stage/unstage/discard | -| `renderer/components/lanes/LaneGitActionsPane.tsx` | Commit, stash, fetch, sync, push, recent commits | +| `renderer/components/lanes/LaneGitActionsPane.tsx` | Commit, stash, fetch, sync, push, recent commits. Seeds its `autoRebaseStatus` from the `autoRebaseStatusSnapshot` prop that `LanesPage` passes from the lane list (`laneSnapshot.autoRebaseStatus`), so opening a lane does not trigger a per-lane probe. A fallback `refreshAutoRebaseStatus` runs only when the snapshot is `undefined`, after a 3.5 s delay, and only while the document is visible. | | `renderer/components/lanes/LaneWorkPane.tsx` | Terminal/chat toggle work surface | | `renderer/components/lanes/LaneRebaseBanner.tsx` | Inline banner driven by `rebaseSuggestionService` | | `renderer/components/lanes/LaneEnvInitProgress.tsx` | Env init step progress inside create dialog | diff --git a/docs/features/onboarding-and-settings/README.md b/docs/features/onboarding-and-settings/README.md index a2984b342..ccadb3a24 100644 --- a/docs/features/onboarding-and-settings/README.md +++ b/docs/features/onboarding-and-settings/README.md @@ -181,7 +181,11 @@ Renderer — settings: via `tailscale serve`), and the per-device connection panel used to forget paired phones. - `apps/desktop/src/renderer/components/settings/SettingsUsageSection.tsx` - and `UsageGuardrailsSection.tsx` — cost and usage. + and `UsageGuardrailsSection.tsx` — cost and usage. The guardrails + section's mount-time hydrate calls `ade.usage.getSnapshot` (cached + read), not `ade.usage.refresh` (which forces a recompute); the user + still gets the live numbers via the section's explicit Refresh + control. - `apps/desktop/src/renderer/components/settings/ProxyAndPreviewSection.tsx` — proxy/preview configuration UI. - `apps/desktop/src/renderer/components/settings/DiagnosticsDashboardSection.tsx` diff --git a/docs/features/pull-requests/README.md b/docs/features/pull-requests/README.md index 853f53e57..73f44e7ff 100644 --- a/docs/features/pull-requests/README.md +++ b/docs/features/pull-requests/README.md @@ -531,6 +531,7 @@ best-effort — failures log a warning and do not abort the tick. - `PRsPage` parses URL state via `parsePrsRouteState` and writes it back with `buildPrsRouteSearch`. Active tab, workflow sub-tab, selected PR, queue group, lane, and rebase item are all encoded. +- `PrsContext` mounts cheaply on the plain GitHub PR list. The initial `refreshCore` only kicks a background GitHub refresh when the active tab is a workflow tab (`queue` / `integration` / `rebase`) or a PR is selected; otherwise `githubRefreshMode` is left undefined so the renderer paints from the existing snapshot. `applyLocalPrState` calls `lanes.list({ includeStatus: false })` and skips `rebase.scanNeeds` / `lanes.listAutoRebaseStatuses` on the plain list — those legs hydrate the moment a workflow tab opens or a PR is selected, and the periodic 60 s rebase scan + auto-rebase listener also no-op while the user is on the plain list. - `PrsContext` owns PR list, queue states, rebase needs, proposals, convergence runtime state, and the Timeline+Rails UI state (`prsTimelineRailsEnabled`, `timelineFiltersByPrId`, diff --git a/docs/features/terminals-and-sessions/README.md b/docs/features/terminals-and-sessions/README.md index 266456563..519663824 100644 --- a/docs/features/terminals-and-sessions/README.md +++ b/docs/features/terminals-and-sessions/README.md @@ -258,10 +258,13 @@ Renderer surfaces: iOS Work surfaces: -- `apps/ios/ADE/Views/Work/WorkRootScreen.swift` and - `WorkRootScreen+Actions.swift` — mobile Work list, filters, - grouped session rows, live-count/status pills, and the resume - flow that re-uses `work.startCliSession` for ended PTY rows. +- `apps/ios/ADE/Views/Work/WorkRootScreen.swift`, + `WorkRootScreen+Actions.swift`, `WorkRootScreen+Selection.swift`, and + `WorkRootComponents.swift` — mobile Work list, filters, grouped + session rows, and live-count/status pills, plus the resume flow that + re-uses `work.startCliSession` for ended PTY rows. The earlier + in-list activity feed is gone — running chats surface through the + session list and the live-count chip. - `apps/ios/ADE/Views/Work/WorkArtifactTerminalViews.swift` — terminal artifact/output views and the compact input bar that sends `terminal_input` bytes and Ctrl-C to the subscribed host PTY. Hosts diff --git a/docs/features/workspace-graph/README.md b/docs/features/workspace-graph/README.md index 91881a6f3..f98fa6d26 100644 --- a/docs/features/workspace-graph/README.md +++ b/docs/features/workspace-graph/README.md @@ -105,6 +105,13 @@ returns to their preferred view across sessions. `normalizeGraphPreferences(state)` migrates legacy schemas (including the older `presets: […]` shape) to the current format. +The persistence callback dedupes against the value just loaded: +`GraphInner` keeps a `skipNextGraphPreferencePersistRootRef` set to the +project root that was just hydrated. The next `viewMode`-watcher run +skips its `graphState.set` because the load handler's `setViewMode` +would otherwise echo the loaded preference straight back to disk on +every project switch. + ## Node data (`GraphNodeData`) Every lane node carries enough derived state to render without