From 8f653cae9a55bf96850de2e122e685a3cee731bd Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Thu, 23 Apr 2026 15:01:08 -0400 Subject: [PATCH 01/10] Overhaul automations: ADE actions, settings move, polling - Add ADE Actions registry and editor (new action type alongside ADE agents) - Move budget/usage/cost UI from Automations page to Settings - Replace HistoryTab with per-rule RuleHistoryPanel - Add github polling service and Linear/GitHub trigger filter editors - Rewrite automationService with helpers + tests; refactor adeRpcServer --- apps/ade-cli/src/adeRpcServer.ts | 220 +-- apps/ade-cli/src/bootstrap.ts | 6 + apps/ade-cli/src/cli.ts | 132 ++ apps/desktop/src/main/main.ts | 225 +++ .../src/main/services/adeActions/registry.ts | 329 ++++ .../automations/automationHelpers.test.ts | 285 ++++ .../automations/automationService.test.ts | 238 --- .../services/automations/automationService.ts | 481 +++++- .../automations/githubPollingService.ts | 464 ++++++ .../src/main/services/github/githubService.ts | 233 ++- .../src/main/services/ipc/registerIpc.ts | 58 + apps/desktop/src/preload/global.d.ts | 4 + apps/desktop/src/preload/preload.ts | 5 + apps/desktop/src/renderer/browserMock.ts | 3 + .../src/renderer/components/app/App.tsx | 4 + .../components/automations/ActionList.tsx | 165 ++ .../components/automations/ActionRow.tsx | 205 +++ .../automations/AdeActionEditor.tsx | 187 +++ .../automations/AutomationsPage.tsx | 96 +- .../automations/AutomationsTemplatesPage.tsx | 50 + .../components/automations/EmptyStateHint.tsx | 51 + .../automations/GitHubTriggerFilters.tsx | 326 ++++ .../components/automations/HistoryTab.tsx | 175 -- .../automations/LinearTriggerFilters.tsx | 81 + .../automations/RuleHistoryPanel.tsx | 127 ++ .../components/automations/RulesTab.tsx | 234 ++- .../components/automations/TemplatesTab.tsx | 353 ++-- .../components/RuleEditorPanel.tsx | 1471 ++++++++--------- .../automations/components/RunDetailPanel.tsx | 1 - .../BudgetCapEditor.tsx | 15 +- .../CostSummaryCard.tsx | 11 +- .../settings/UsageGuardrailsSection.tsx | 19 +- .../components => settings}/UsageMeter.tsx | 2 +- .../UsagePacingBadge.tsx | 4 +- apps/desktop/src/shared/ipc.ts | 4 + apps/desktop/src/shared/types/automations.ts | 48 +- apps/desktop/src/shared/types/config.ts | 70 +- 37 files changed, 4622 insertions(+), 1760 deletions(-) create mode 100644 apps/desktop/src/main/services/adeActions/registry.ts create mode 100644 apps/desktop/src/main/services/automations/automationHelpers.test.ts create mode 100644 apps/desktop/src/main/services/automations/githubPollingService.ts create mode 100644 apps/desktop/src/renderer/components/automations/ActionList.tsx create mode 100644 apps/desktop/src/renderer/components/automations/ActionRow.tsx create mode 100644 apps/desktop/src/renderer/components/automations/AdeActionEditor.tsx create mode 100644 apps/desktop/src/renderer/components/automations/AutomationsTemplatesPage.tsx create mode 100644 apps/desktop/src/renderer/components/automations/EmptyStateHint.tsx create mode 100644 apps/desktop/src/renderer/components/automations/GitHubTriggerFilters.tsx delete mode 100644 apps/desktop/src/renderer/components/automations/HistoryTab.tsx create mode 100644 apps/desktop/src/renderer/components/automations/LinearTriggerFilters.tsx create mode 100644 apps/desktop/src/renderer/components/automations/RuleHistoryPanel.tsx rename apps/desktop/src/renderer/components/{automations/components => settings}/BudgetCapEditor.tsx (96%) rename apps/desktop/src/renderer/components/{automations/components => settings}/CostSummaryCard.tsx (84%) rename apps/desktop/src/renderer/components/{automations/components => settings}/UsageMeter.tsx (98%) rename apps/desktop/src/renderer/components/{automations/components => settings}/UsagePacingBadge.tsx (96%) diff --git a/apps/ade-cli/src/adeRpcServer.ts b/apps/ade-cli/src/adeRpcServer.ts index 9e2b23cfd..af22482dc 100644 --- a/apps/ade-cli/src/adeRpcServer.ts +++ b/apps/ade-cli/src/adeRpcServer.ts @@ -14,6 +14,13 @@ import { } from "../../desktop/src/main/services/computerUse/localComputerUse"; import { loadAgentBrowserArtifactPayloadFromFile, parseAgentBrowserArtifactPayload } from "../../desktop/src/main/services/proof/agentBrowserArtifactAdapter"; import { resolveAgentMemoryWritePolicy } from "../../desktop/src/main/services/memory/memoryService"; +import { + ADE_ACTION_ALLOWLIST, + type AdeActionDomain, + getAdeActionDomainServices, + isAllowedAdeAction, + listAllowedAdeActionNames, +} from "../../desktop/src/main/services/adeActions/registry"; import { ReflectionValidationError } from "../../desktop/src/main/services/orchestrator/orchestratorService"; import { getTeamMembersForRun, registerTeamMember, updateTeamMemberStatus } from "../../desktop/src/main/services/orchestrator/teamRuntimeState"; import { launchPrIssueResolutionChat, previewPrIssueResolutionPrompt } from "../../desktop/src/main/services/prs/prIssueResolver"; @@ -232,6 +239,8 @@ const TOOL_SPECS: ToolSpec[] = [ "process", "pty", "computer_use_artifacts", + "automations", + "issue", ], }, action: { type: "string", minLength: 1 }, @@ -2992,217 +3001,6 @@ async function waitForTestRunCompletion(args: { }; } -type AdeActionDomain = - | "lane" - | "git" - | "diff" - | "conflicts" - | "pr" - | "tests" - | "chat" - | "mission" - | "orchestrator" - | "orchestrator_core" - | "memory" - | "cto_state" - | "worker_agent" - | "session" - | "operation" - | "project_config" - | "issue_inventory" - | "flow_policy" - | "linear_dispatcher" - | "linear_issue_tracker" - | "linear_sync" - | "linear_ingress" - | "linear_routing" - | "file" - | "process" - | "pty" - | "computer_use_artifacts"; - -const ADE_ACTION_ALLOWLIST: Partial> = { - lane: [ - "adoptAttached", - "attach", - "create", - "createFromUnstaged", - "delete", - "getChildren", - "getStackChain", - "importBranch", - "list", - "listUnregisteredWorktrees", - "refreshSnapshots", - "rename", - "reparent", - "updateAppearance", - ], - git: [ - "abortRebase", - "cherryPickCommit", - "commit", - "continueRebase", - "fetch", - "getCommitMessage", - "getConflictState", - "getFileHistory", - "getSyncStatus", - "listCommitFiles", - "mergeAbort", - "mergeContinue", - "pull", - "push", - "rebaseAbort", - "rebaseContinue", - "revertCommit", - "stash", - "stagePaths", - "unstagePaths", - ], - diff: ["getChanges", "getFileDiff"], - conflicts: ["getLaneStatus", "listOverlaps", "rebaseLane", "runPrediction"], - pr: [ - "addComment", - "aiReviewSummary", - "cleanupIntegrationWorkflow", - "createFromLane", - "createIntegrationLane", - "createIntegrationPr", - "createQueuePrs", - "dismissIntegrationCleanup", - "draftDescription", - "getActionRuns", - "getChecks", - "getComments", - "getDetail", - "getGithubSnapshot", - "getIntegrationResolutionState", - "getMobileSnapshot", - "getPrHealth", - "getQueueState", - "getReviewThreads", - "getReviews", - "landQueueNext", - "landStack", - "landStackEnhanced", - "linkToLane", - "listAll", - "listGroupPrs", - "listIntegrationProposals", - "listIntegrationWorkflows", - "listWithConflicts", - "postReviewComment", - "reactToComment", - "recheckIntegrationStep", - "refresh", - "reorderQueuePrs", - "requestReviewers", - "setLabels", - "setReviewThreadResolved", - "simulateIntegration", - "startIntegrationResolution", - "submitReview", - "updateDescription", - "updateIntegrationProposal", - "updateTitle", - ], - tests: ["getLogTail", "listRuns", "listSuites", "run", "stop"], - chat: [ - "createSession", - "deleteSession", - "getAvailableModels", - "getSessionSummary", - "getSlashCommands", - "interrupt", - "listSessions", - "resumeSession", - "sendMessage", - ], - memory: ["addSharedFact", "pinMemory", "searchMemories", "writeMemory"], - session: ["get", "readTranscriptTail"], - operation: ["finish", "list", "start"], - project_config: ["get", "save"], - issue_inventory: [ - "getConvergenceRuntime", - "getConvergenceStatus", - "getInventory", - "getNewItems", - "getPipelineSettings", - "markDismissed", - "markEscalated", - "markFixed", - "markSentToAgent", - "reconcileConvergenceSessionExit", - "savePipelineSettings", - "syncFromPrData", - ], - flow_policy: ["getPolicy", "savePolicy"], - linear_dispatcher: ["dispatchIssue", "getDashboard", "listEmployees", "listQueue"], - linear_issue_tracker: ["getStatus", "listIssues"], - linear_sync: ["getDashboard", "getRunDetail", "listQueue", "resolveQueueItem", "runSyncNow"], - linear_ingress: ["ensureRelayWebhook", "getStatus", "listRecentEvents"], - linear_routing: ["simulateRoute"], - file: [ - "createDirectory", - "createFile", - "deletePath", - "listTree", - "listWorkspaces", - "quickOpen", - "readFile", - "rename", - "searchText", - "writeWorkspaceText", - ], - process: ["getLogTail", "listDefinitions", "listRuntime", "startAll", "stopAll"], - pty: ["create", "dispose", "resize", "write"], - computer_use_artifacts: ["ingest", "listArtifacts"], -}; - -function getAdeActionDomainServices(runtime: AdeRuntime): Partial | null | undefined>> { - return { - lane: runtime.laneService as unknown as Record, - git: runtime.gitService as unknown as Record, - diff: runtime.diffService as unknown as Record, - conflicts: runtime.conflictService as unknown as Record, - pr: (runtime.prService ?? null) as unknown as Record | null, - tests: runtime.testService as unknown as Record, - chat: (runtime.agentChatService ?? null) as unknown as Record | null, - mission: runtime.missionService as unknown as Record, - orchestrator: runtime.aiOrchestratorService as unknown as Record, - orchestrator_core: runtime.orchestratorService as unknown as Record, - memory: runtime.memoryService as unknown as Record, - cto_state: runtime.ctoStateService as unknown as Record, - worker_agent: runtime.workerAgentService as unknown as Record, - session: runtime.sessionService as unknown as Record, - operation: runtime.operationService as unknown as Record, - project_config: runtime.projectConfigService as unknown as Record, - issue_inventory: runtime.issueInventoryService as unknown as Record, - flow_policy: (runtime.flowPolicyService ?? null) as unknown as Record | null, - linear_dispatcher: (runtime.linearDispatcherService ?? null) as unknown as Record | null, - linear_issue_tracker: (runtime.linearIssueTracker ?? null) as unknown as Record | null, - linear_sync: (runtime.linearSyncService ?? null) as unknown as Record | null, - linear_ingress: (runtime.linearIngressService ?? null) as unknown as Record | null, - linear_routing: (runtime.linearRoutingService ?? null) as unknown as Record | null, - file: (runtime.fileService ?? null) as unknown as Record | null, - process: (runtime.processService ?? null) as unknown as Record | null, - pty: runtime.ptyService as unknown as Record, - computer_use_artifacts: runtime.computerUseArtifactBrokerService as unknown as Record, - }; -} - -function listAllowedAdeActionNames(domain: AdeActionDomain, service: Record): string[] { - const allowed = ADE_ACTION_ALLOWLIST[domain] ?? []; - return allowed - .filter((key) => typeof service[key] === "function") - .sort((a, b) => a.localeCompare(b)); -} - -function isAllowedAdeAction(domain: AdeActionDomain, action: string): boolean { - return (ADE_ACTION_ALLOWLIST[domain] ?? []).includes(action); -} - async function waitForSessionCompletion(args: { runtime: AdeRuntime; ptyId: string; diff --git a/apps/ade-cli/src/bootstrap.ts b/apps/ade-cli/src/bootstrap.ts index 65b1a7341..fe8b1e8db 100644 --- a/apps/ade-cli/src/bootstrap.ts +++ b/apps/ade-cli/src/bootstrap.ts @@ -38,6 +38,9 @@ import { } from "../../desktop/src/main/services/computerUse/computerUseArtifactBrokerService"; import type { createFileService } from "../../desktop/src/main/services/files/fileService"; import type { createProcessService } from "../../desktop/src/main/services/processes/processService"; +import type { createGithubService } from "../../desktop/src/main/services/github/githubService"; +import type { createAutomationService } from "../../desktop/src/main/services/automations/automationService"; +import type { createAutomationPlannerService } from "../../desktop/src/main/services/automations/automationPlannerService"; import { createHeadlessLinearServices } from "./headlessLinearServices"; import { createEventBuffer, type BufferedEvent, type EventBuffer } from "./eventBuffer"; @@ -93,6 +96,9 @@ export type AdeRuntime = { linearIngressService?: ReturnType | null; linearRoutingService?: ReturnType | null; processService?: ReturnType | null; + githubService?: ReturnType | null; + automationService?: ReturnType | null; + automationPlannerService?: ReturnType | null; computerUseArtifactBrokerService: ComputerUseArtifactBrokerService; orchestratorService: ReturnType; aiOrchestratorService: ReturnType; diff --git a/apps/ade-cli/src/cli.ts b/apps/ade-cli/src/cli.ts index 6aab71899..aa52a4707 100644 --- a/apps/ade-cli/src/cli.ts +++ b/apps/ade-cli/src/cli.ts @@ -4,6 +4,7 @@ import { spawnSync } from "node:child_process"; import fs from "node:fs"; import net from "node:net"; import path from "node:path"; +import YAML from "yaml"; import { type JsonRpcHandler, type JsonRpcId, type JsonRpcRequest } from "./jsonrpc"; type JsonObject = Record; @@ -255,6 +256,7 @@ const TOP_LEVEL_HELP = `${ADE_BANNER} $ ade agent spawn --lane --prompt Launch an agent session in ADE $ ade cto state | chats Operate CTO state and Work chats $ ade linear workflows | run | sync Operate Linear routing and sync workflows + $ ade automations list | create | run | runs Manage automation rules $ ade coordinator Call coordinator runtime tools $ ade tests list | run | stop | runs | logs Run configured test suites $ ade proof status | list | screenshot | record Manage proof and computer-use artifacts @@ -346,6 +348,19 @@ const HELP_BY_COMMAND: Record = { $ ade actions run git.stageFile --arg laneId= --arg path=src/index.ts $ ade actions run --input-json '{"key":"value"}' $ ade actions status --text Runtime action availability +`, + automations: `${ADE_BANNER} + Automations + + $ ade automations list [--json] List automation rules + $ ade automations show [--json] Inspect a rule + $ ade automations create --from-file Create from YAML (also accepts --stdin) + $ 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 runs [--rule ] [--limit 50] [--json] + $ ade automations run-show [--json] Inspect a run `, }; @@ -1463,6 +1478,121 @@ function buildCtoPlan(args: string[]): CliPlan { return { kind: "execute", label: `CTO ${sub}`, steps: [actionCallStep("result", sub.replace(/-/g, "_"), collectGenericObjectArgs(args))] }; } +function parseDraftInput(args: string[]): JsonObject { + const text = readFileTextInput(args); + if (text == null) { + throw new CliUsageError("Provide a rule body via --from-file, --stdin, or --text."); + } + const trimmed = text.trim(); + if (!trimmed.length) { + throw new CliUsageError("Rule body is empty."); + } + let parsed: unknown; + try { + parsed = trimmed.startsWith("{") || trimmed.startsWith("[") + ? JSON.parse(trimmed) + : YAML.parse(trimmed); + } catch (error) { + throw new CliUsageError(`Failed to parse rule body: ${error instanceof Error ? error.message : String(error)}`); + } + if (!isRecord(parsed)) { + throw new CliUsageError("Rule body must be an object."); + } + return parsed; +} + +function buildAutomationsPlan(args: string[]): CliPlan { + const sub = firstPositional(args) ?? "list"; + + if (sub === "list") { + return { kind: "execute", label: "automations list", steps: [actionStep("result", "automations", "list")] }; + } + + if (sub === "show" || sub === "get") { + const id = requireValue(readValue(args, ["--id"]) ?? firstPositional(args), "rule id"); + return { kind: "execute", label: `automations show ${id}`, steps: [actionStep("result", "automations", "get", { id })] }; + } + + if (sub === "create") { + const draft = parseDraftInput(args); + return { + kind: "execute", + label: "automations create", + steps: [actionStep("result", "automations", "saveRule", { draft })], + }; + } + + if (sub === "update") { + const id = requireValue(readValue(args, ["--id"]) ?? firstPositional(args), "rule id"); + const draft = parseDraftInput(args); + return { + kind: "execute", + label: `automations update ${id}`, + steps: [actionStep("result", "automations", "saveRule", { draft: { ...draft, id } })], + }; + } + + if (sub === "delete") { + const id = requireValue(readValue(args, ["--id"]) ?? firstPositional(args), "rule id"); + return { kind: "execute", label: `automations delete ${id}`, steps: [actionStep("result", "automations", "deleteRule", { id })] }; + } + + if (sub === "toggle") { + const id = requireValue(readValue(args, ["--id"]) ?? firstPositional(args), "rule id"); + const enabledRaw = readValue(args, ["--enabled"]); + if (enabledRaw == null) { + throw new CliUsageError("automations toggle requires --enabled ."); + } + const enabled = enabledRaw === "true" || enabledRaw === "1"; + return { + kind: "execute", + label: `automations toggle ${id}`, + steps: [actionStep("result", "automations", "toggleRule", { id, enabled })], + }; + } + + if (sub === "run") { + const id = requireValue(readValue(args, ["--id"]) ?? firstPositional(args), "rule id"); + const dryRun = readFlag(args, ["--dry-run"]); + const laneId = readLaneId(args); + return { + kind: "execute", + label: `automations run ${id}`, + steps: [actionStep("result", "automations", "triggerManually", { + id, + ...(dryRun ? { dryRun: true } : {}), + ...(laneId ? { laneId } : {}), + })], + }; + } + + if (sub === "runs") { + const automationId = readValue(args, ["--rule", "--automation", "--id"]); + const limit = readIntOption(args, ["--limit"]); + return { + kind: "execute", + label: "automations runs", + steps: [actionStep("result", "automations", "listRuns", { + ...(automationId ? { automationId } : {}), + ...(typeof limit === "number" ? { limit } : {}), + })], + }; + } + + if (sub === "run-show" || sub === "run-detail") { + const runId = requireValue(readValue(args, ["--run", "--run-id"]) ?? firstPositional(args), "run id"); + return { + kind: "execute", + label: `automations run-show ${runId}`, + steps: [actionStep("result", "automations", "getRunDetail", { runId })], + }; + } + + throw new CliUsageError( + "automations supports list, show, create, update, delete, toggle, run, runs, or run-show.", + ); +} + function buildLinearPlan(args: string[]): CliPlan { const sub = firstPositional(args) ?? "workflows"; if (sub === "workflows") return { kind: "execute", label: "Linear workflows", steps: [actionCallStep("result", "listLinearWorkflows", collectGenericObjectArgs(args))] }; @@ -1556,6 +1686,7 @@ function buildCliPlan(command: string[]): CliPlan { computer: "proof", "computer-use": "proof", action: "actions", + automation: "automations", }; const key = aliases[topic] ?? topic; return { kind: "help", text: key && HELP_BY_COMMAND[key] ? HELP_BY_COMMAND[key] : TOP_LEVEL_HELP }; @@ -1603,6 +1734,7 @@ function buildCliPlan(command: string[]): CliPlan { if (primary === "agent" || primary === "agents") return buildAgentPlan(args); if (primary === "cto") return buildCtoPlan(args); if (primary === "linear") return buildLinearPlan(args); + if (primary === "automations" || primary === "automation") return buildAutomationsPlan(args); if (primary === "flow") return buildFlowPlan(args); if (primary === "coordinator" || primary === "coord") return buildCoordinatorPlan(args); if (primary === "ask") return { kind: "execute", label: "ask user", steps: [actionCallStep("result", "ask_user", collectGenericObjectArgs(args, { title: readValue(args, ["--title"]) ?? "ADE question", body: readValue(args, ["--body", "--question"]) ?? args.join(" ") }))] }; diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index 81dfdf5f3..8abbc9eba 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -54,6 +54,9 @@ import { createConfigReloadService } from "./services/projects/configReloadServi import { IPC } from "../shared/ipc"; import { resolveAdeLayout } from "../shared/adeLayout"; import type { PortLease, ProjectInfo, RecentProjectSummary, SyncMobileProjectSummary, SyncProjectSwitchRequestPayload, SyncProjectSwitchResultPayload } from "../shared/types"; +import type { AutomationTriggerType } from "../shared/types/config"; +import type { AutomationTriggerLinearIssueContext } from "../shared/types/automations"; +import type { LinearIngressEventRecord } from "../shared/types/linearSync"; import type { AppContext } from "./services/ipc/registerIpc"; import fs from "node:fs"; import net from "node:net"; @@ -73,6 +76,14 @@ import { createAutomationService } from "./services/automations/automationServic import { createAutomationPlannerService } from "./services/automations/automationPlannerService"; import { createAutomationSecretService } from "./services/automations/automationSecretService"; import { createAutomationIngressService } from "./services/automations/automationIngressService"; +import { createGithubPollingService } from "./services/automations/githubPollingService"; +import type { AutomationAdeActionRegistry } from "./services/automations/automationService"; +import { + ADE_ACTION_ALLOWLIST, + type AdeActionDomain, + getAdeActionDomainServices, + isAllowedAdeAction, +} from "./services/adeActions/registry"; import { createUsageTrackingService } from "./services/usage/usageTrackingService"; import { createBudgetCapService } from "./services/usage/budgetCapService"; import { createRebaseSuggestionService } from "./services/lanes/rebaseSuggestionService"; @@ -196,6 +207,115 @@ const episodicSummaryEnabled = isBackgroundTaskEnabled( "ADE_ENABLE_EPISODIC_SUMMARY", ); +function readString(source: Record | null | undefined, key: string): string | undefined { + const value = source?.[key]; + return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; +} + +function readStringArray(source: Record | null | undefined, key: string): string[] | undefined { + const value = source?.[key]; + if (!Array.isArray(value)) return undefined; + const out = value.map((entry) => { + if (typeof entry === "string") return entry.trim(); + if (entry && typeof entry === "object") { + const rec = entry as Record; + const name = typeof rec.name === "string" ? rec.name.trim() : null; + if (name) return name; + } + return ""; + }).filter((entry) => entry.length > 0); + return out.length > 0 ? out : undefined; +} + +function readNested(source: Record | null | undefined, key: string): Record | null { + const value = source?.[key]; + return value && typeof value === "object" && !Array.isArray(value) ? (value as Record) : null; +} + +function mapLinearActionToTriggerType( + action: string | null, + data: Record | null, + prevData: Record | null, +): { triggerType: AutomationTriggerType; stateTransition: string | null; previousState: string | undefined } { + const currentState = readString(readNested(data, "state"), "name") ?? readString(data, "stateName"); + const previousState = readString(readNested(prevData, "state"), "name") ?? readString(prevData, "stateName"); + if (action === "create") { + return { triggerType: "linear.issue_created", stateTransition: null, previousState: undefined }; + } + const prevAssignee = readString(prevData, "assigneeId") ?? readString(readNested(prevData, "assignee"), "id"); + const curAssignee = readString(data, "assigneeId") ?? readString(readNested(data, "assignee"), "id"); + if (curAssignee && curAssignee !== prevAssignee) { + return { triggerType: "linear.issue_assigned", stateTransition: null, previousState }; + } + if (currentState && previousState && currentState !== previousState) { + return { + triggerType: "linear.issue_status_changed", + stateTransition: `${previousState}->${currentState}`, + previousState, + }; + } + return { triggerType: "linear.issue_updated", stateTransition: null, previousState }; +} + +function buildLinearAutomationDispatch(event: LinearIngressEventRecord): { + source: "linear-relay"; + eventKey: string; + triggerType: AutomationTriggerType; + eventName?: string | null; + summary?: string | null; + author?: string | null; + labels?: string[]; + rawPayload?: Record | null; + linear?: { issue: AutomationTriggerLinearIssueContext } | null; + project?: string | null; + team?: string | null; + assignee?: string | null; + stateTransition?: string | null; + changedFields?: string[]; +} | null { + if (!event.issueId) return null; + const payload = event.payload ?? null; + const data = readNested(payload, "data"); + const prevData = readNested(payload, "updatedFrom"); + const mapping = mapLinearActionToTriggerType(event.action, data, prevData); + + const teamName = readString(readNested(data, "team"), "name") ?? readString(data, "teamName"); + const projectName = readString(readNested(data, "project"), "name") ?? readString(data, "projectName"); + const assigneeName = readString(readNested(data, "assignee"), "name") ?? readString(data, "assigneeName"); + const stateName = readString(readNested(data, "state"), "name") ?? readString(data, "stateName"); + const labels = readStringArray(data, "labels") ?? readStringArray(readNested(data, "labels"), "nodes"); + const title = readString(data, "title") ?? undefined; + + const changedFields = prevData ? Object.keys(prevData) : undefined; + + const linearContext: AutomationTriggerLinearIssueContext = { + id: event.issueId, + title, + team: teamName, + project: projectName, + assignee: assigneeName, + state: stateName, + previousState: mapping.previousState, + labels, + }; + + return { + source: "linear-relay", + eventKey: event.eventId, + triggerType: mapping.triggerType, + eventName: event.action, + summary: event.summary, + labels, + rawPayload: payload, + linear: { issue: linearContext }, + project: projectName ?? null, + team: teamName ?? null, + assignee: assigneeName ?? null, + stateTransition: mapping.stateTransition, + changedFields, + }; +} + // The Claude CLI refuses to start if it detects it is inside another Claude Code // session (nested session guard). ADE is a host app, not a nested session, so // strip the marker env var so the SDK can spawn the CLI cleanly. @@ -2187,6 +2307,12 @@ app.whenReady().then(async () => { listRules: () => projectConfigService.get().effective.automations ?? [], }); + const githubPollingService = createGithubPollingService({ + logger, + githubService, + automationService, + }); + let missionServiceRef: ReturnType | null = null; const missionService = createMissionService({ @@ -2668,6 +2794,18 @@ app.whenReady().then(async () => { if (event.issueId) { await linearSyncService.processIssueUpdate(event.issueId); } + try { + const dispatched = buildLinearAutomationDispatch(event); + if (dispatched) { + await automationService.dispatchIngressTrigger(dispatched); + } + } catch (error) { + logger.warn("linear.automation_dispatch_failed", { + issueId: event.issueId, + eventId: event.eventId, + error: error instanceof Error ? error.message : String(error), + }); + } }, }); linearIngressServiceRef = linearIngressService; @@ -2760,6 +2898,18 @@ app.whenReady().then(async () => { "ADE_ENABLE_AUTOMATION_INGRESS", ); + scheduleBackgroundProjectTask( + "automations.github_polling_start", + () => githubPollingService.start(), + (error) => { + logger.warn("automations.github_polling_start_failed", { + error: error instanceof Error ? error.message : String(error), + }); + }, + 0, + "ADE_ENABLE_AUTOMATION_INGRESS", + ); + const configReloadService = createConfigReloadService({ paths: { sharedPath: adeProjectService.paths.sharedConfigPath, @@ -3038,6 +3188,9 @@ app.whenReady().then(async () => { linearIngressService, linearRoutingService, processService, + githubService, + automationService, + automationPlannerService, computerUseArtifactBrokerService, orchestratorService, aiOrchestratorService, @@ -3133,6 +3286,71 @@ app.whenReady().then(async () => { ); logger.info("rpc.socket_server_started", { socketPath: rpcSocketPath }); + // Wire the automation runtime into the shared ADE-action registry so + // that `ade-action` automation steps can invoke the same domain services + // the RPC server exposes. We do this lazily — the registry re-resolves + // services on every call so that runtime bindings (bindMissionRuntime, + // ctoStateService) that settle later are still visible. + { + const adeActionLookup: AutomationAdeActionRegistry = { + isAllowed(domain: string, action: string): boolean { + return isAllowedAdeAction(domain as AdeActionDomain, action); + }, + getService(domain: string): Record | null { + const pseudoRuntime = buildAdeActionRuntimeForAutomations(); + const services = getAdeActionDomainServices(pseudoRuntime); + const service = services[domain as AdeActionDomain] ?? null; + return (service ?? null) as Record | null; + }, + listDomains(): string[] { + return Object.keys(ADE_ACTION_ALLOWLIST); + }, + listActions(domain: string): string[] { + return [...(ADE_ACTION_ALLOWLIST[domain as AdeActionDomain] ?? [])]; + }, + }; + automationService?.bindAdeActionRegistry(adeActionLookup); + } + + // Helper: materialize an AdeRuntime-shaped bag from the current set of + // locally-created services so that the registry's service map resolves. + // Using a function closure means this stays reactive to late-bound refs + // like `ctoStateServiceRef`. + function buildAdeActionRuntimeForAutomations(): AdeRuntime { + return { + laneService, + gitService, + diffService, + conflictService, + prService, + testService, + agentChatService, + missionService, + aiOrchestratorService, + orchestratorService, + memoryService, + ctoStateService, + workerAgentService, + sessionService, + operationService, + projectConfigService, + issueInventoryService, + flowPolicyService, + linearDispatcherService, + linearIssueTracker, + linearSyncService, + linearIngressService, + linearRoutingService, + fileService, + processService, + ptyService, + computerUseArtifactBrokerService, + automationService, + automationPlannerService, + githubService, + } as unknown as AdeRuntime; + } + return { db, logger, @@ -3176,6 +3394,7 @@ app.whenReady().then(async () => { automationService, automationPlannerService, automationIngressService, + githubPollingService, usageTrackingService, budgetCapService, syncHostService: syncService.getHostService(), @@ -3283,6 +3502,7 @@ app.whenReady().then(async () => { automationService: null, automationPlannerService: null, automationIngressService: null, + githubPollingService: null, usageTrackingService: null, budgetCapService: null, syncHostService: null, @@ -3378,6 +3598,11 @@ app.whenReady().then(async () => { } catch { // ignore } + try { + ctx.githubPollingService?.dispose(); + } catch { + // ignore + } try { ctx.automationService.dispose(); } catch { diff --git a/apps/desktop/src/main/services/adeActions/registry.ts b/apps/desktop/src/main/services/adeActions/registry.ts new file mode 100644 index 000000000..9d527f08b --- /dev/null +++ b/apps/desktop/src/main/services/adeActions/registry.ts @@ -0,0 +1,329 @@ +import type { AdeRuntime } from "../../../../../ade-cli/src/bootstrap"; +import type { + AutomationManualTriggerRequest, + AutomationRun, + AutomationRunDetail, + AutomationRunListArgs, + AutomationRuleSummary, + AutomationSaveDraftRequest, + AutomationSaveDraftResult, +} from "../../../shared/types/automations"; +import type { AutomationRule } from "../../../shared/types/config"; + +export type AdeActionDomain = + | "lane" + | "git" + | "diff" + | "conflicts" + | "pr" + | "tests" + | "chat" + | "mission" + | "orchestrator" + | "orchestrator_core" + | "memory" + | "cto_state" + | "worker_agent" + | "session" + | "operation" + | "project_config" + | "issue_inventory" + | "flow_policy" + | "linear_dispatcher" + | "linear_issue_tracker" + | "linear_sync" + | "linear_ingress" + | "linear_routing" + | "file" + | "process" + | "pty" + | "computer_use_artifacts" + | "automations" + | "issue"; + +export const ADE_ACTION_ALLOWLIST: Partial> = { + lane: [ + "adoptAttached", + "attach", + "create", + "createFromUnstaged", + "delete", + "getChildren", + "getStackChain", + "importBranch", + "list", + "listUnregisteredWorktrees", + "refreshSnapshots", + "rename", + "reparent", + "updateAppearance", + ], + git: [ + "abortRebase", + "cherryPickCommit", + "commit", + "continueRebase", + "fetch", + "getCommitMessage", + "getConflictState", + "getFileHistory", + "getSyncStatus", + "listCommitFiles", + "mergeAbort", + "mergeContinue", + "pull", + "push", + "rebaseAbort", + "rebaseContinue", + "revertCommit", + "stash", + "stagePaths", + "unstagePaths", + ], + diff: ["getChanges", "getFileDiff"], + conflicts: ["getLaneStatus", "listOverlaps", "rebaseLane", "runPrediction"], + pr: [ + "addComment", + "aiReviewSummary", + "cleanupIntegrationWorkflow", + "createFromLane", + "createIntegrationLane", + "createIntegrationPr", + "createQueuePrs", + "dismissIntegrationCleanup", + "draftDescription", + "getActionRuns", + "getChecks", + "getComments", + "getDetail", + "getGithubSnapshot", + "getIntegrationResolutionState", + "getMobileSnapshot", + "getPrHealth", + "getQueueState", + "getReviewThreads", + "getReviews", + "landQueueNext", + "landStack", + "landStackEnhanced", + "linkToLane", + "listAll", + "listGroupPrs", + "listIntegrationProposals", + "listIntegrationWorkflows", + "listWithConflicts", + "postReviewComment", + "reactToComment", + "recheckIntegrationStep", + "refresh", + "reorderQueuePrs", + "requestReviewers", + "setLabels", + "setReviewThreadResolved", + "simulateIntegration", + "startIntegrationResolution", + "submitReview", + "updateDescription", + "updateIntegrationProposal", + "updateTitle", + ], + tests: ["getLogTail", "listRuns", "listSuites", "run", "stop"], + chat: [ + "createSession", + "deleteSession", + "getAvailableModels", + "getSessionSummary", + "getSlashCommands", + "interrupt", + "listSessions", + "resumeSession", + "sendMessage", + ], + memory: ["addSharedFact", "pinMemory", "searchMemories", "writeMemory"], + session: ["get", "readTranscriptTail"], + operation: ["finish", "list", "start"], + project_config: ["get", "save"], + issue_inventory: [ + "getConvergenceRuntime", + "getConvergenceStatus", + "getInventory", + "getNewItems", + "getPipelineSettings", + "markDismissed", + "markEscalated", + "markFixed", + "markSentToAgent", + "reconcileConvergenceSessionExit", + "savePipelineSettings", + "syncFromPrData", + ], + flow_policy: ["getPolicy", "savePolicy"], + linear_dispatcher: ["dispatchIssue", "getDashboard", "listEmployees", "listQueue"], + linear_issue_tracker: ["getStatus", "listIssues"], + linear_sync: ["getDashboard", "getRunDetail", "listQueue", "resolveQueueItem", "runSyncNow"], + linear_ingress: ["ensureRelayWebhook", "getStatus", "listRecentEvents"], + linear_routing: ["simulateRoute"], + file: [ + "createDirectory", + "createFile", + "deletePath", + "listTree", + "listWorkspaces", + "quickOpen", + "readFile", + "rename", + "searchText", + "writeWorkspaceText", + ], + process: ["getLogTail", "listDefinitions", "listRuntime", "startAll", "stopAll"], + pty: ["create", "dispose", "resize", "write"], + computer_use_artifacts: ["ingest", "listArtifacts"], + automations: [ + "list", + "get", + "saveRule", + "deleteRule", + "toggleRule", + "triggerManually", + "listRuns", + "getRunDetail", + ], + issue: [ + "addComment", + "setLabels", + "close", + "reopen", + "assign", + "setTitle", + ], +}; + +type AutomationsDomainService = { + list(): AutomationRuleSummary[]; + get(args: { id: string }): AutomationRule | null; + saveRule(args: AutomationSaveDraftRequest): AutomationSaveDraftResult; + deleteRule(args: { id: string }): AutomationRuleSummary[]; + toggleRule(args: { id: string; enabled: boolean }): AutomationRuleSummary[]; + triggerManually(args: AutomationManualTriggerRequest): Promise; + listRuns(args?: AutomationRunListArgs): AutomationRun[]; + getRunDetail(args: { runId: string }): Promise; +}; + +function buildAutomationsDomainService(runtime: AdeRuntime): AutomationsDomainService | null { + const automationService = runtime.automationService; + const plannerService = runtime.automationPlannerService; + const projectConfigService = runtime.projectConfigService; + if (!automationService || !plannerService || !projectConfigService) return null; + return { + list: () => automationService.list(), + get: ({ id }) => { + const trimmed = id?.trim(); + if (!trimmed) return null; + return projectConfigService.get().effective.automations.find((r) => r.id === trimmed) ?? null; + }, + saveRule: (args) => plannerService.saveDraft(args), + deleteRule: ({ id }) => automationService.deleteRule({ id }), + toggleRule: ({ id, enabled }) => automationService.toggle({ id, enabled }), + triggerManually: (args) => automationService.triggerManually(args), + listRuns: (args = {}) => automationService.listRuns(args), + getRunDetail: ({ runId }) => automationService.getRunDetail({ runId }), + }; +} + +type IssueDomainService = { + addComment(args: { owner?: string; name?: string; number: number; body: string }): Promise; + setLabels(args: { owner?: string; name?: string; number: number; labels: string[] }): Promise; + close(args: { owner?: string; name?: string; number: number; reason?: "completed" | "not_planned" }): Promise; + reopen(args: { owner?: string; name?: string; number: number }): Promise; + assign(args: { owner?: string; name?: string; number: number; assignees: string[] }): Promise; + setTitle(args: { owner?: string; name?: string; number: number; title: string }): Promise; +}; + +function buildIssueDomainService(runtime: AdeRuntime): IssueDomainService | null { + const githubService = runtime.githubService; + if (!githubService) return null; + + const resolveRepo = async (owner?: string, name?: string): Promise<{ owner: string; name: string }> => { + if (owner && name) return { owner, name }; + const repo = await githubService.detectRepo(); + if (!repo) throw new Error("Unable to detect GitHub repo; pass owner/name explicitly."); + return { owner: repo.owner, name: repo.name }; + }; + + return { + addComment: async ({ owner, name, number, body }) => { + const repo = await resolveRepo(owner, name); + return githubService.addIssueComment(repo.owner, repo.name, number, body); + }, + setLabels: async ({ owner, name, number, labels }) => { + const repo = await resolveRepo(owner, name); + return githubService.setIssueLabels(repo.owner, repo.name, number, labels); + }, + close: async ({ owner, name, number, reason }) => { + const repo = await resolveRepo(owner, name); + return githubService.closeIssue(repo.owner, repo.name, number, reason); + }, + reopen: async ({ owner, name, number }) => { + const repo = await resolveRepo(owner, name); + return githubService.reopenIssue(repo.owner, repo.name, number); + }, + assign: async ({ owner, name, number, assignees }) => { + const repo = await resolveRepo(owner, name); + return githubService.assignIssue(repo.owner, repo.name, number, assignees); + }, + setTitle: async ({ owner, name, number, title }) => { + const repo = await resolveRepo(owner, name); + return githubService.setIssueTitle(repo.owner, repo.name, number, title); + }, + }; +} + +export function getAdeActionDomainServices( + runtime: AdeRuntime, +): Partial | null | undefined>> { + return { + lane: runtime.laneService as unknown as Record, + git: runtime.gitService as unknown as Record, + diff: runtime.diffService as unknown as Record, + conflicts: runtime.conflictService as unknown as Record, + pr: (runtime.prService ?? null) as unknown as Record | null, + tests: runtime.testService as unknown as Record, + chat: (runtime.agentChatService ?? null) as unknown as Record | null, + mission: runtime.missionService as unknown as Record, + orchestrator: runtime.aiOrchestratorService as unknown as Record, + orchestrator_core: runtime.orchestratorService as unknown as Record, + memory: runtime.memoryService as unknown as Record, + cto_state: runtime.ctoStateService as unknown as Record, + worker_agent: runtime.workerAgentService as unknown as Record, + session: runtime.sessionService as unknown as Record, + operation: runtime.operationService as unknown as Record, + project_config: runtime.projectConfigService as unknown as Record, + issue_inventory: runtime.issueInventoryService as unknown as Record, + flow_policy: (runtime.flowPolicyService ?? null) as unknown as Record | null, + linear_dispatcher: (runtime.linearDispatcherService ?? null) as unknown as Record | null, + linear_issue_tracker: (runtime.linearIssueTracker ?? null) as unknown as Record | null, + linear_sync: (runtime.linearSyncService ?? null) as unknown as Record | null, + linear_ingress: (runtime.linearIngressService ?? null) as unknown as Record | null, + linear_routing: (runtime.linearRoutingService ?? null) as unknown as Record | null, + file: (runtime.fileService ?? null) as unknown as Record | null, + process: (runtime.processService ?? null) as unknown as Record | null, + pty: runtime.ptyService as unknown as Record, + computer_use_artifacts: runtime.computerUseArtifactBrokerService as unknown as Record, + automations: buildAutomationsDomainService(runtime) as unknown as Record | null, + issue: buildIssueDomainService(runtime) as unknown as Record | null, + }; +} + +export function listAllowedAdeActionNames( + domain: AdeActionDomain, + service: Record, +): string[] { + const allowed = ADE_ACTION_ALLOWLIST[domain] ?? []; + return allowed + .filter((key) => typeof service[key] === "function") + .sort((a, b) => a.localeCompare(b)); +} + +export function isAllowedAdeAction(domain: AdeActionDomain, action: string): boolean { + return (ADE_ACTION_ALLOWLIST[domain] ?? []).includes(action); +} diff --git a/apps/desktop/src/main/services/automations/automationHelpers.test.ts b/apps/desktop/src/main/services/automations/automationHelpers.test.ts new file mode 100644 index 000000000..94556d39b --- /dev/null +++ b/apps/desktop/src/main/services/automations/automationHelpers.test.ts @@ -0,0 +1,285 @@ +import { describe, expect, it } from "vitest"; +import type { AutomationRule, AutomationTrigger } from "../../../shared/types/config"; +import type { TriggerContext } from "./automationService"; +import { + normalizeRuntimeRule, + normalizeTriggerType, + readTriggerPath, + resolvePlaceholders, + triggerMatches, +} from "./automationService"; + +const baseRule: AutomationRule = { + id: "rule-1", + name: "Rule 1", + mode: "review", + triggers: [{ type: "manual" }], + trigger: { type: "manual" }, + executor: { mode: "automation-bot" }, + reviewProfile: "quick", + toolPalette: ["repo", "memory", "mission"], + contextSources: [], + memory: { mode: "none" }, + guardrails: {}, + outputs: { disposition: "comment-only", createArtifact: true }, + verification: { verifyBeforePublish: false, mode: "intervention" }, + billingCode: "auto:rule-1", + actions: [], + enabled: true, +}; + +describe("normalizeTriggerType", () => { + it("aliases legacy git.pr_* to canonical github.pr_*", () => { + expect(normalizeTriggerType("git.pr_opened")).toBe("github.pr_opened"); + expect(normalizeTriggerType("git.pr_updated")).toBe("github.pr_updated"); + expect(normalizeTriggerType("git.pr_merged")).toBe("github.pr_merged"); + expect(normalizeTriggerType("git.pr_closed")).toBe("github.pr_closed"); + }); + + it("maps bare `commit` to git.commit", () => { + expect(normalizeTriggerType("commit" as never)).toBe("git.commit"); + }); + + it("leaves already-canonical triggers untouched", () => { + expect(normalizeTriggerType("github.issue_opened")).toBe("github.issue_opened"); + expect(normalizeTriggerType("github.pr_opened")).toBe("github.pr_opened"); + expect(normalizeTriggerType("schedule")).toBe("schedule"); + expect(normalizeTriggerType("linear.issue_created")).toBe("linear.issue_created"); + }); +}); + +describe("normalizeRuntimeRule", () => { + it("strips per-rule budget fields from guardrails", () => { + const rule = { + ...baseRule, + guardrails: { + ...baseRule.guardrails, + budgetCapUsd: 25, + maxSpendUsd: 40, + budgetUsd: 50, + } as AutomationRule["guardrails"] & { + budgetCapUsd?: number; + maxSpendUsd?: number; + budgetUsd?: number; + }, + }; + + const normalized = normalizeRuntimeRule(rule); + + expect(normalized.guardrails).not.toHaveProperty("budgetCapUsd"); + expect(normalized.guardrails).not.toHaveProperty("maxSpendUsd"); + expect(normalized.guardrails).not.toHaveProperty("budgetUsd"); + }); + + it("canonicalizes legacy git.pr_* triggers to github.pr_*", () => { + const rule = { + ...baseRule, + triggers: [{ type: "git.pr_opened" as const, branch: "main" }], + trigger: { type: "git.pr_opened" as const, branch: "main" }, + }; + + const normalized = normalizeRuntimeRule(rule); + + expect(normalized.triggers[0]?.type).toBe("github.pr_opened"); + expect(normalized.trigger.type).toBe("github.pr_opened"); + }); + + it("forces verification to the neutral no-op shape regardless of input", () => { + const rule = { + ...baseRule, + verification: { verifyBeforePublish: true, mode: "dry-run" as const }, + }; + + const normalized = normalizeRuntimeRule(rule); + + expect(normalized.verification).toEqual({ + verifyBeforePublish: false, + mode: "intervention", + }); + }); + + it("derives includeProjectContext from legacy memory/contextSources", () => { + const none = normalizeRuntimeRule({ + ...baseRule, + memory: { mode: "none" }, + contextSources: [], + }); + expect(none.includeProjectContext).toBe(false); + + const hasMemory = normalizeRuntimeRule({ + ...baseRule, + memory: { mode: "automation-plus-project", ruleScopeKey: "rule-1" }, + contextSources: [], + }); + expect(hasMemory.includeProjectContext).toBe(true); + + const hasContext = normalizeRuntimeRule({ + ...baseRule, + memory: { mode: "none" }, + contextSources: [{ type: "project-memory" }], + }); + expect(hasContext.includeProjectContext).toBe(true); + + const explicitFalse = normalizeRuntimeRule({ + ...baseRule, + includeProjectContext: false, + memory: { mode: "automation-plus-project", ruleScopeKey: "rule-1" }, + contextSources: [{ type: "project-memory" }], + }); + expect(explicitFalse.includeProjectContext).toBe(false); + }); +}); + +describe("readTriggerPath + resolvePlaceholders", () => { + const ctx: TriggerContext = { + triggerType: "github.issue_opened", + issue: { + number: 42, + title: "Payment flow broken", + body: "Repro steps inside.", + author: "arul28", + labels: ["bug", "triage"], + repo: "arul28/ADE", + }, + } as TriggerContext; + + it("reads nested paths with or without the `trigger.` prefix", () => { + expect(readTriggerPath(ctx, "trigger.issue.number")).toBe(42); + expect(readTriggerPath(ctx, "issue.number")).toBe(42); + expect(readTriggerPath(ctx, "trigger.issue.author")).toBe("arul28"); + }); + + it("returns undefined when a segment is missing", () => { + expect(readTriggerPath(ctx, "trigger.pr.number")).toBeUndefined(); + expect(readTriggerPath(ctx, "trigger.issue.does_not_exist")).toBeUndefined(); + expect(readTriggerPath(ctx, "")).toBeUndefined(); + }); + + it("preserves raw type when a string is wholly a single placeholder", () => { + expect(resolvePlaceholders("{{trigger.issue.number}}", ctx)).toBe(42); + expect(resolvePlaceholders("{{trigger.issue.labels}}", ctx)).toEqual(["bug", "triage"]); + }); + + it("templates embedded placeholders and stringifies non-string values", () => { + expect(resolvePlaceholders("Issue #{{trigger.issue.number}}", ctx)).toBe("Issue #42"); + expect(resolvePlaceholders("{{trigger.issue.author}} opened this", ctx)).toBe( + "arul28 opened this", + ); + }); + + it("replaces missing embedded placeholders with the empty string", () => { + expect(resolvePlaceholders("fallback:{{trigger.pr.number}}", ctx)).toBe("fallback:"); + }); + + it("leaves a whole-string placeholder untouched when the path is missing", () => { + expect(resolvePlaceholders("{{trigger.pr.number}}", ctx)).toBe("{{trigger.pr.number}}"); + }); + + it("walks nested objects and arrays", () => { + const tree = { + labels: ["{{trigger.issue.labels}}"], + meta: { + body: "{{trigger.issue.title}}", + author: "{{trigger.issue.author}}", + }, + issueNumber: "{{trigger.issue.number}}", + }; + + const resolved = resolvePlaceholders(tree, ctx); + + expect(resolved).toEqual({ + labels: [["bug", "triage"]], + meta: { + body: "Payment flow broken", + author: "arul28", + }, + issueNumber: 42, + }); + }); + + it("passes non-string primitives through untouched", () => { + expect(resolvePlaceholders(42, ctx)).toBe(42); + expect(resolvePlaceholders(true, ctx)).toBe(true); + expect(resolvePlaceholders(null, ctx)).toBeNull(); + }); +}); + +describe("triggerMatches", () => { + const issueCtx: TriggerContext = { + triggerType: "github.issue_opened", + issue: { + number: 7, + title: "Payment webhook sometimes 500s", + body: "Happens on retry only. Stack trace attached.", + author: "arul28", + labels: ["bug", "payments", "triage"], + repo: "arul28/ADE", + }, + } as TriggerContext; + + const rule = (partial: Partial): AutomationTrigger => ({ + type: "github.issue_opened", + ...partial, + }); + + it("treats labels as a subset check (rule ⊆ event)", () => { + expect(triggerMatches(rule({ labels: ["bug"] }), issueCtx, undefined, undefined)).toBe(true); + expect(triggerMatches(rule({ labels: ["bug", "payments"] }), issueCtx, undefined, undefined)).toBe(true); + expect(triggerMatches(rule({ labels: ["wontfix"] }), issueCtx, undefined, undefined)).toBe(false); + expect(triggerMatches(rule({ labels: ["bug", "wontfix"] }), issueCtx, undefined, undefined)).toBe(false); + }); + + it("ignores label case when matching", () => { + expect(triggerMatches(rule({ labels: ["BUG"] }), issueCtx, undefined, undefined)).toBe(true); + expect(triggerMatches(rule({ labels: ["Payments"] }), issueCtx, undefined, undefined)).toBe(true); + }); + + it("an empty labels filter matches everything", () => { + expect(triggerMatches(rule({ labels: [] }), issueCtx, undefined, undefined)).toBe(true); + expect(triggerMatches(rule({}), issueCtx, undefined, undefined)).toBe(true); + }); + + it("titleRegex matches case-insensitively against issue.title", () => { + expect(triggerMatches(rule({ titleRegex: "webhook" }), issueCtx, undefined, undefined)).toBe(true); + expect(triggerMatches(rule({ titleRegex: "^Payment" }), issueCtx, undefined, undefined)).toBe(true); + expect(triggerMatches(rule({ titleRegex: "deploy failure" }), issueCtx, undefined, undefined)).toBe(false); + }); + + it("bodyRegex matches case-insensitively against issue.body", () => { + expect(triggerMatches(rule({ bodyRegex: "stack trace" }), issueCtx, undefined, undefined)).toBe(true); + expect(triggerMatches(rule({ bodyRegex: "not-in-body" }), issueCtx, undefined, undefined)).toBe(false); + }); + + it("drops the match silently on invalid regex rather than throwing", () => { + expect(triggerMatches(rule({ titleRegex: "[" }), issueCtx, undefined, undefined)).toBe(false); + }); + + it("prefers issue.author over the generic trigger.author for authors[] matching", () => { + expect(triggerMatches(rule({ authors: ["arul28"] }), issueCtx, undefined, undefined)).toBe(true); + expect(triggerMatches(rule({ authors: ["ARUL28"] }), issueCtx, undefined, undefined)).toBe(true); + expect(triggerMatches(rule({ authors: ["other-user"] }), issueCtx, undefined, undefined)).toBe(false); + }); + + it("combines filters — all must pass", () => { + expect( + triggerMatches( + rule({ labels: ["bug"], titleRegex: "payment", authors: ["arul28"] }), + issueCtx, + undefined, + undefined, + ), + ).toBe(true); + expect( + triggerMatches( + rule({ labels: ["bug"], titleRegex: "deploy" }), + issueCtx, + undefined, + undefined, + ), + ).toBe(false); + }); + + it("rejects a mismatched trigger type outright", () => { + expect(triggerMatches(rule({ type: "github.pr_opened" }), issueCtx, undefined, undefined)).toBe(false); + }); +}); diff --git a/apps/desktop/src/main/services/automations/automationService.test.ts b/apps/desktop/src/main/services/automations/automationService.test.ts index e22c104da..544a4c71f 100644 --- a/apps/desktop/src/main/services/automations/automationService.test.ts +++ b/apps/desktop/src/main/services/automations/automationService.test.ts @@ -544,163 +544,6 @@ describe("automationService integration", () => { await expect(service.triggerManually({ id: "echo" })).rejects.toThrow(/untrusted/i); }); - it("runs agent-session automations in plan mode when publish verification is required", async () => { - const { db, raw } = createInMemoryAdeDb(); - const logger = createLogger(); - const projectId = "proj"; - const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-automation-agent-session-")); - const createSession = vi.fn(async () => ({ id: "session-1" })); - const runSessionTurn = vi.fn(async () => ({ outputText: "Prepared a review summary." })); - - const rule = { - id: "agent-review", - name: "Agent review", - enabled: true, - mode: "review", - reviewProfile: "quick", - trigger: { type: "manual" as const }, - triggers: [{ type: "manual" as const }], - executor: { mode: "automation-bot", targetId: null }, - toolPalette: ["github"] as const, - contextSources: [], - memory: { mode: "project" as const }, - guardrails: { maxDurationMin: 5 }, - outputs: { disposition: "comment-only" as const, createArtifact: true }, - verification: { verifyBeforePublish: true, mode: "intervention" as const }, - billingCode: "auto:test", - execution: { - kind: "agent-session" as const, - targetLaneId: "lane-target", - session: { title: "Review output" }, - }, - modelConfig: { - orchestratorModel: { - modelId: "openai/gpt-5.4-codex", - thinkingLevel: "medium", - }, - }, - prompt: "Review the latest PR status.", - }; - - const projectConfigService = { - get: () => ({ - trust: { requiresSharedTrust: false }, - effective: { automations: [rule], providerMode: "guest" } - }) - } as any; - - const laneService = { - list: async () => [{ id: "lane-primary", laneType: "primary" }, { id: "lane-target", laneType: "child" }], - getLaneWorktreePath: () => projectRoot, - getLaneBaseAndBranch: () => ({ baseRef: "main", branchRef: "main", worktreePath: projectRoot }) - } as any; - - const service = createAutomationService({ - db: db as any, - logger, - projectId, - projectRoot, - laneService, - projectConfigService, - agentChatService: { - createSession, - runSessionTurn, - } as any, - }); - - try { - const run = await service.triggerManually({ id: "agent-review", laneId: "lane-primary" }); - expect(run.status).toBe("succeeded"); - expect(createSession).toHaveBeenCalledWith(expect.objectContaining({ - laneId: "lane-target", - permissionMode: "plan", - })); - const turnArgs = (runSessionTurn as any).mock.calls[0]?.[0] as { text?: string } | undefined; - expect(turnArgs?.text).toContain("Lane ID: lane-target"); - expect(turnArgs?.text).not.toContain("Lane ID: lane-primary"); - const row = mapExecRows(raw.exec("select queue_status from automation_runs where automation_id = 'agent-review'"))[0]; - expect(String(row?.queue_status)).toBe("verification-required"); - } finally { - fs.rmSync(projectRoot, { recursive: true, force: true }); - } - }); - - it("does not attach codex sandbox for agent-session automations in dry-run mode", async () => { - const { db } = createInMemoryAdeDb(); - const logger = createLogger(); - const projectId = "proj"; - const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-automation-dry-run-")); - const createSession = vi.fn(async () => ({ id: "session-1" })); - const runSessionTurn = vi.fn(async () => ({ outputText: "Prepared a dry-run summary." })); - - const rule = { - id: "agent-dry-run", - name: "Agent dry run", - enabled: true, - mode: "review", - reviewProfile: "quick", - trigger: { type: "manual" as const }, - triggers: [{ type: "manual" as const }], - executor: { mode: "automation-bot", targetId: null }, - toolPalette: ["github"] as const, - contextSources: [], - memory: { mode: "project" as const }, - guardrails: { maxDurationMin: 5 }, - outputs: { disposition: "comment-only" as const, createArtifact: true }, - verification: { verifyBeforePublish: false, mode: "dry-run" as const }, - billingCode: "auto:test", - execution: { - kind: "agent-session" as const, - session: { title: "Dry run output" }, - }, - modelConfig: { - orchestratorModel: { - modelId: "openai/gpt-5.4-codex", - thinkingLevel: "medium", - }, - }, - prompt: "Dry-run the automation.", - }; - - const projectConfigService = { - get: () => ({ - trust: { requiresSharedTrust: false }, - effective: { automations: [rule], providerMode: "guest" } - }) - } as any; - - const laneService = { - list: async () => [{ id: "lane-1", laneType: "primary" }], - getLaneWorktreePath: () => projectRoot, - getLaneBaseAndBranch: () => ({ baseRef: "main", branchRef: "main", worktreePath: projectRoot }) - } as any; - - const service = createAutomationService({ - db: db as any, - logger, - projectId, - projectRoot, - laneService, - projectConfigService, - agentChatService: { - createSession, - runSessionTurn, - } as any, - }); - - try { - const run = await service.triggerManually({ id: "agent-dry-run" }); - expect(run.status).toBe("succeeded"); - expect(createSession).toHaveBeenCalledWith(expect.objectContaining({ - permissionMode: "plan", - })); - const sessionArgs = (createSession as any).mock.calls[0][0] as Record; - expect(sessionArgs).not.toHaveProperty("codexSandbox"); - } finally { - fs.rmSync(projectRoot, { recursive: true, force: true }); - } - }); - it("simulates manual dry runs without starting automation side effects", async () => { const { db } = createInMemoryAdeDb(); const logger = createLogger(); @@ -904,85 +747,4 @@ describe("automationService integration", () => { } }); - it("does not attach codexSandbox when a codex automation runs in dry-run mode", async () => { - const { db } = createInMemoryAdeDb(); - const logger = createLogger(); - const projectId = "proj"; - const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-automation-codex-dryrun-")); - const createSession = vi.fn(async () => ({ id: "session-1" })); - const runSessionTurn = vi.fn(async () => ({ outputText: "Planned changes only." })); - - const rule = { - id: "agent-codex-dryrun", - name: "Agent codex dry run", - enabled: true, - mode: "review", - reviewProfile: "quick", - trigger: { type: "manual" as const }, - triggers: [{ type: "manual" as const }], - executor: { mode: "automation-bot", targetId: null }, - toolPalette: ["github"] as const, - contextSources: [], - memory: { mode: "project" as const }, - guardrails: { maxDurationMin: 5 }, - outputs: { disposition: "comment-only" as const, createArtifact: true }, - verification: { verifyBeforePublish: false, mode: "dry-run" as const }, - billingCode: "auto:test", - execution: { - kind: "agent-session" as const, - }, - permissionConfig: { - providers: { - codexSandbox: "workspace-write" as const, - }, - }, - modelConfig: { - orchestratorModel: { - modelId: "openai/gpt-5.4-codex", - thinkingLevel: "medium", - }, - }, - prompt: "Plan the latest changes.", - }; - - const projectConfigService = { - get: () => ({ - trust: { requiresSharedTrust: false }, - effective: { automations: [rule], providerMode: "guest" } - }) - } as any; - - const laneService = { - list: async () => [{ id: "lane-1", laneType: "primary" }], - getLaneWorktreePath: () => projectRoot, - getLaneBaseAndBranch: () => ({ baseRef: "main", branchRef: "main", worktreePath: projectRoot }) - } as any; - - const service = createAutomationService({ - db: db as any, - logger, - projectId, - projectRoot, - laneService, - projectConfigService, - agentChatService: { - createSession, - runSessionTurn, - } as any, - }); - - try { - const run = await service.triggerManually({ id: "agent-codex-dryrun" }); - expect(run.status).toBe("succeeded"); - expect(createSession).toHaveBeenCalledWith(expect.not.objectContaining({ - codexSandbox: "workspace-write", - })); - expect(createSession).toHaveBeenCalledWith(expect.objectContaining({ - permissionMode: "plan", - })); - } finally { - fs.rmSync(projectRoot, { recursive: true, force: true }); - } - }); - }); diff --git a/apps/desktop/src/main/services/automations/automationService.ts b/apps/desktop/src/main/services/automations/automationService.ts index 136289d92..9ae6b1033 100644 --- a/apps/desktop/src/main/services/automations/automationService.ts +++ b/apps/desktop/src/main/services/automations/automationService.ts @@ -27,6 +27,7 @@ import type { AutomationTriggerType, NormalizedLinearIssue, PrSummary, + RunAdeActionConfig, } from "../../../shared/types"; import type { Logger } from "../logging/logger"; import type { AdeDb, SqlValue } from "../state/kvDb"; @@ -50,7 +51,47 @@ type CronTask = { stop: () => void; }; -type TriggerContext = { +/** + * Contract for the shared ADE-action registry. Implementations bridge to the + * same allowlist and service map that the RPC server's `run_ade_action` uses + * so that automations and CTO calls cannot diverge. + */ +export type AutomationAdeActionRegistry = { + isAllowed(domain: string, action: string): boolean; + getService(domain: string): Record | null; + listDomains(): string[]; + listActions(domain: string): string[]; +}; + +type TriggerIssueContext = { + number: number; + title: string; + body?: string; + author?: string; + labels?: string[]; + repo?: string; + url?: string; +}; + +type TriggerPrContext = TriggerIssueContext & { + baseBranch?: string; + headBranch?: string; + draft?: boolean; + merged?: boolean; +}; + +type TriggerLinearIssueContext = { + id: string; + title?: string; + team?: string; + project?: string; + assignee?: string; + state?: string; + previousState?: string; + labels?: string[]; +}; + +export type TriggerContext = { triggerType: AutomationTriggerType; laneId?: string; laneName?: string; @@ -77,6 +118,13 @@ type TriggerContext = { changedFields?: string[]; draftState?: "draft" | "ready" | "any"; summary?: string; + repo?: string; + /** Structured GitHub issue payload for `github.issue_*` triggers. */ + issue?: TriggerIssueContext; + /** Structured GitHub PR payload for `github.pr_*` triggers. */ + pr?: TriggerPrContext; + /** Structured Linear payload for `linear.*` triggers. */ + linear?: { issue: TriggerLinearIssueContext }; }; type WatchedFileRoot = { @@ -256,8 +304,15 @@ function dedupeStrings(values: Array): string[] { } -function normalizeTriggerType(type: AutomationTriggerType): AutomationTriggerType { +export function normalizeTriggerType(type: AutomationTriggerType): AutomationTriggerType { if (type === "commit") return "git.commit"; + // Legacy `git.pr_*` names now alias to the canonical `github.pr_*` names so + // pre-existing rules continue to match new runtime events without a + // config-file migration. + if (type === "git.pr_opened") return "github.pr_opened"; + if (type === "git.pr_updated") return "github.pr_updated"; + if (type === "git.pr_merged") return "github.pr_merged"; + if (type === "git.pr_closed") return "github.pr_closed"; return type; } @@ -265,6 +320,56 @@ function triggerTypesMatch(ruleType: AutomationTriggerType, runtimeType: Automat return normalizeTriggerType(ruleType) === normalizeTriggerType(runtimeType); } +/** + * Walk a dotted path (e.g. `trigger.issue.number` or `issue.number`) against a + * TriggerContext and return the raw value, or undefined if any segment is + * missing. Pure — safe to use outside the service closure (and in tests). + */ +export function readTriggerPath(trigger: TriggerContext, pathExpr: string): unknown { + const raw = pathExpr.trim(); + if (!raw) return undefined; + const segments = (raw.startsWith("trigger.") ? raw.slice("trigger.".length) : raw).split(".").filter(Boolean); + let cursor: unknown = trigger as unknown; + for (const segment of segments) { + if (cursor == null || typeof cursor !== "object") return undefined; + cursor = (cursor as Record)[segment]; + } + return cursor; +} + +/** + * Recursively substitute `{{trigger.*}}` placeholders inside a JSON-ish args + * tree with values read from the trigger context. Strings that are wholly a + * single placeholder (`"{{trigger.issue.number}}"`) are replaced with the raw + * value (preserves number/boolean types); strings with embedded placeholders + * are templated. Pure — safe to use outside the service closure. + */ +export function resolvePlaceholders(node: unknown, trigger: TriggerContext): unknown { + if (node == null) return node; + if (Array.isArray(node)) return node.map((entry) => resolvePlaceholders(entry, trigger)); + if (typeof node === "object") { + const out: Record = {}; + for (const [key, value] of Object.entries(node as Record)) { + out[key] = resolvePlaceholders(value, trigger); + } + return out; + } + if (typeof node === "string") { + const wholeMatch = /^\{\{\s*(trigger\.[^}\s]+)\s*\}\}$/.exec(node); + if (wholeMatch) { + const value = readTriggerPath(trigger, wholeMatch[1]!); + return value === undefined ? node : value; + } + return node.replace(/\{\{\s*(trigger\.[^}\s]+)\s*\}\}/g, (_, expr) => { + const value = readTriggerPath(trigger, String(expr)); + if (value == null) return ""; + if (typeof value === "string") return value; + try { return JSON.stringify(value); } catch { return String(value); } + }); + } + return node; +} + function listMatches(expected: string[] | undefined, actual: string[] | undefined): boolean { if (!expected?.length) return true; @@ -272,6 +377,97 @@ function listMatches(expected: string[] | undefined, actual: string[] | undefine return expected.some((entry) => actualSet.has(entry.trim().toLowerCase())); } +/** + * Decide whether an incoming trigger event (context + lane metadata) matches a + * rule's filter. Pure — no closure state, safe to use outside the service. + * Semantics: + * - `labels[]` is a subset check (rule.labels ⊆ event.labels). + * - `titleRegex` / `bodyRegex` are case-insensitive; invalid patterns drop the match. + * - `authors[]` prefers `trigger.issue.author` / `trigger.pr.author` over `trigger.author`. + */ +export function triggerMatches( + ruleTrigger: AutomationTrigger, + trigger: TriggerContext, + laneBranch: string | undefined, + laneName: string | undefined, +): boolean { + if (!triggerTypesMatch(ruleTrigger.type, trigger.triggerType)) return false; + + const canonicalType = normalizeTriggerType(ruleTrigger.type); + const isPrCanonical = + canonicalType === "github.pr_opened" + || canonicalType === "github.pr_updated" + || canonicalType === "github.pr_closed"; + if (canonicalType === "github.pr_merged") { + const expectedTarget = (ruleTrigger.targetBranch ?? ruleTrigger.branch ?? "").trim(); + if (expectedTarget && !matchesGlob(expectedTarget, trigger.targetBranch)) return false; + } else if (ruleTrigger.branch?.trim()) { + const branchToMatch = isPrCanonical ? trigger.branch : laneBranch; + if (!matchesGlob(ruleTrigger.branch, branchToMatch)) return false; + } + if (ruleTrigger.event?.trim() && ruleTrigger.event.trim() !== (trigger.eventName ?? "").trim()) return false; + + const triggerAuthor = (trigger.issue?.author ?? trigger.pr?.author ?? trigger.author ?? "").trim().toLowerCase(); + const expectedAuthors = [ + ...(ruleTrigger.authors ?? []), + ...(ruleTrigger.author ? [ruleTrigger.author] : []), + ] + .map((a) => a.trim().toLowerCase()) + .filter(Boolean); + if (expectedAuthors.length) { + if (!triggerAuthor || !expectedAuthors.includes(triggerAuthor)) return false; + } + + const eventLabels = normalizeSet(trigger.issue?.labels ?? trigger.pr?.labels ?? trigger.labels ?? []); + if (ruleTrigger.labels?.length) { + const expected = ruleTrigger.labels.map((l) => l.trim().toLowerCase()).filter(Boolean); + if (!expected.every((l) => eventLabels.has(l))) return false; + } + + if (ruleTrigger.paths?.length) { + const paths = trigger.paths ?? []; + if (!paths.some((entry) => ruleTrigger.paths?.some((expected) => matchesGlob(expected, entry)))) return false; + } + if (ruleTrigger.keywords?.length) { + const haystack = `${trigger.summary ?? ""} ${(trigger.keywords ?? []).join(" ")}`.toLowerCase(); + if (!ruleTrigger.keywords.some((entry) => haystack.includes(entry.trim().toLowerCase()))) return false; + } + + const titleHaystack = trigger.issue?.title ?? trigger.pr?.title ?? ""; + const bodyHaystack = trigger.issue?.body ?? trigger.pr?.body ?? ""; + if (ruleTrigger.titleRegex?.trim()) { + try { + if (!new RegExp(ruleTrigger.titleRegex, "i").test(titleHaystack)) return false; + } catch { + return false; + } + } + if (ruleTrigger.bodyRegex?.trim()) { + try { + if (!new RegExp(ruleTrigger.bodyRegex, "i").test(bodyHaystack)) return false; + } catch { + return false; + } + } + + if (ruleTrigger.repo?.trim()) { + const repoCandidate = (trigger.issue?.repo ?? trigger.pr?.repo ?? trigger.repo ?? "").trim(); + if (!matchesGlob(ruleTrigger.repo, repoCandidate)) return false; + } + + if (ruleTrigger.namePattern?.trim() && !matchesGlob(ruleTrigger.namePattern, laneName)) return false; + if (ruleTrigger.project?.trim() && !matchesGlob(ruleTrigger.project, trigger.linear?.issue?.project ?? trigger.project)) return false; + if (ruleTrigger.team?.trim() && !matchesGlob(ruleTrigger.team, trigger.linear?.issue?.team ?? trigger.team)) return false; + if (ruleTrigger.assignee?.trim() && !matchesGlob(ruleTrigger.assignee, trigger.linear?.issue?.assignee ?? trigger.assignee)) return false; + if (ruleTrigger.stateTransition?.trim() && (trigger.stateTransition ?? "").trim() !== ruleTrigger.stateTransition.trim()) return false; + if (!listMatches(ruleTrigger.changedFields, trigger.changedFields)) return false; + if (ruleTrigger.draftState && ruleTrigger.draftState !== "any" && trigger.draftState && ruleTrigger.draftState !== trigger.draftState) { + return false; + } + if (ruleTrigger.activeHours && !isWithinActiveHours(ruleTrigger.activeHours)) return false; + return true; +} + function parseCronPart(field: string, value: number, min: number, max: number): boolean { const trimmed = field.trim(); if (!trimmed.length) return false; @@ -331,9 +527,11 @@ function normalizeRunStatus(value: string, fallback: AutomationRunStatus): Autom value === "succeeded" || value === "failed" || value === "cancelled" || - value === "paused" || - value === "needs_review" + value === "paused" ) return value; + // Legacy `needs_review` rows fold into `succeeded` now that the review + // gate has been removed from the UI/runtime. + if (value === "needs_review") return "succeeded"; return fallback; } @@ -404,8 +602,21 @@ function normalizedRuleTriggers(rule: AutomationRule): AutomationTrigger[] { return [{ type: "manual" }]; } -function normalizeRuntimeRule(rule: AutomationRule): AutomationRule { - const triggers = normalizedRuleTriggers(rule); +function canonicalizeTriggerForRuntime(trigger: AutomationTrigger): AutomationTrigger { + const canonical = normalizeTriggerType(trigger.type); + return canonical === trigger.type ? trigger : { ...trigger, type: canonical }; +} + +function deriveIncludeProjectContext(rule: AutomationRule): boolean { + if (typeof rule.includeProjectContext === "boolean") return rule.includeProjectContext; + const memoryMode = rule.memory?.mode; + if (memoryMode && memoryMode !== "none") return true; + if ((rule.contextSources ?? []).length > 0) return true; + return false; +} + +export function normalizeRuntimeRule(rule: AutomationRule): AutomationRule { + const triggers = normalizedRuleTriggers(rule).map(canonicalizeTriggerForRuntime); const legacyActions = Array.isArray(rule.legacy?.actions) ? rule.legacy.actions : Array.isArray((rule as AutomationRule & { actions?: AutomationAction[] }).actions) @@ -435,6 +646,17 @@ 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. + const sanitizedGuardrails = { ...(rule.guardrails ?? {}) } as AutomationRule["guardrails"] & { + budgetCapUsd?: number; + maxSpendUsd?: number; + budgetUsd?: number; + }; + delete (sanitizedGuardrails as { budgetCapUsd?: number }).budgetCapUsd; + delete (sanitizedGuardrails as { maxSpendUsd?: number }).maxSpendUsd; + delete (sanitizedGuardrails as { budgetUsd?: number }).budgetUsd; return { ...rule, enabled: rule.enabled !== false, @@ -445,15 +667,18 @@ function normalizeRuntimeRule(rule: AutomationRule): AutomationRule { toolPalette: rule.toolPalette?.length ? rule.toolPalette : ["repo", "memory", "mission"], contextSources: rule.contextSources?.length ? rule.contextSources : [{ type: "project-memory" }, { type: "procedures" }], memory: rule.memory ?? { mode: "automation-plus-project", ruleScopeKey: rule.id }, - guardrails: rule.guardrails ?? {}, + guardrails: sanitizedGuardrails, + includeProjectContext: deriveIncludeProjectContext(rule), outputs: { disposition: outputDisposition, createArtifact: rule.outputs?.createArtifact ?? true, ...(rule.outputs?.notificationChannel ? { notificationChannel: rule.outputs.notificationChannel } : {}), }, + // Verification gate is retired — force a neutral no-op shape regardless of + // what was on disk. The YAML file retains the original for downgrades. verification: { - verifyBeforePublish: rule.verification?.verifyBeforePublish === true, - mode: rule.verification?.mode ?? "intervention", + verifyBeforePublish: false, + mode: "intervention", }, billingCode: rule.billingCode?.trim() || `auto:${rule.id}`, execution: normalizedExecution, @@ -473,7 +698,7 @@ function summarizeLegacyActions(actions: AutomationAction[]): string { return actions.map((action) => action.type).join(", "); } -function mapMissionStatus(status: string, verificationRequired: boolean): AutomationRunStatus { +function mapMissionStatus(status: string, _verificationRequired: boolean): AutomationRunStatus { switch (status) { case "queued": case "planning": @@ -481,19 +706,19 @@ function mapMissionStatus(status: string, verificationRequired: boolean): Automa case "in_progress": return "running"; case "intervention_required": - return verificationRequired ? "needs_review" : "paused"; + return "paused"; case "completed": - return verificationRequired ? "needs_review" : "succeeded"; + return "succeeded"; case "failed": return "failed"; case "canceled": return "cancelled"; default: - return verificationRequired ? "needs_review" : "running"; + return "running"; } } -function mapWorkerStatus(status: string, verificationRequired: boolean): AutomationRunStatus { +function mapWorkerStatus(status: string, _verificationRequired: boolean): AutomationRunStatus { switch (status) { case "queued": case "deferred": @@ -501,11 +726,11 @@ function mapWorkerStatus(status: string, verificationRequired: boolean): Automat case "running": return "running"; case "completed": - return verificationRequired ? "needs_review" : "succeeded"; + return "succeeded"; case "cancelled": return "cancelled"; case "skipped": - return verificationRequired ? "needs_review" : "paused"; + return "paused"; case "failed": default: return "failed"; @@ -622,6 +847,7 @@ export function createAutomationService({ memoryBriefingService, proceduralLearningService, budgetCapService, + adeActionRegistry, onEvent }: { db: AdeDb; @@ -638,6 +864,13 @@ export function createAutomationService({ memoryBriefingService?: ReturnType; proceduralLearningService?: ReturnType; budgetCapService?: ReturnType; + /** + * Registry that resolves `ade-action` automation steps to a concrete + * (domain, action) callable. Wired up from main.ts so that both the RPC + * server and the automation runtime share the same allowlist and service + * instances. See `apps/desktop/src/main/services/adeActions/registry.ts`. + */ + adeActionRegistry?: AutomationAdeActionRegistry | null; onEvent?: (payload: { type: "runs-updated" | "webhook-status-updated" | "ingress-updated"; automationId?: string; @@ -655,6 +888,7 @@ export function createAutomationService({ let proceduralLearningServiceRef = proceduralLearningService; let budgetCapServiceRef = budgetCapService; let workerHeartbeatServiceRef: ReturnType | null = null; + let adeActionRegistryRef: AutomationAdeActionRegistry | null = adeActionRegistry ?? null; let ingressStatusRef: AutomationIngressStatus = { githubRelay: { configured: false, @@ -943,6 +1177,10 @@ export function createAutomationService({ ...(trigger.changedFields?.length ? { changedFields: trigger.changedFields } : {}), ...(trigger.draftState ? { draftState: trigger.draftState } : {}), ...(trigger.summary ? { summary: trigger.summary } : {}), + ...(trigger.repo ? { repo: trigger.repo } : {}), + ...(trigger.issue ? { issue: trigger.issue } : {}), + ...(trigger.pr ? { pr: trigger.pr } : {}), + ...(trigger.linear ? { linear: trigger.linear } : {}), }); const insertRun = (args: { @@ -1295,7 +1533,13 @@ export function createAutomationService({ if (args.trigger.eventName) lines.push(`Event: ${args.trigger.eventName}`); if (args.trigger.summary) lines.push(`Ingress summary: ${args.trigger.summary}`); if (args.rule.prompt?.trim()) { - lines.push("", args.rule.prompt.trim()); + // Substitute `{{trigger.*}}` placeholders inside the user-authored prompt + // (same mechanism used for ade-action args) so rules like + // "Triage issue #{{trigger.issue.number}} by {{trigger.issue.author}}" + // reach the agent with real values. + const interpolated = resolvePlaceholders(args.rule.prompt, args.trigger); + const text = typeof interpolated === "string" ? interpolated.trim() : args.rule.prompt.trim(); + lines.push("", text); } else if (args.rule.mode === "review") { lines.push("", "Review the latest relevant changes, surface only high-signal findings, and summarize merge readiness."); } else if (args.rule.mode === "fix") { @@ -1378,6 +1622,61 @@ export function createAutomationService({ return laneId.length ? laneId : null; }; + const dispatchAdeAction = async ( + config: RunAdeActionConfig, + trigger: TriggerContext, + ): Promise<{ status: AutomationActionStatus; output?: string }> => { + if (!adeActionRegistryRef) { + return { status: "failed", output: "ADE action registry is not available in this process." }; + } + const domain = (config.domain ?? "").trim(); + const actionName = (config.action ?? "").trim(); + if (!domain || !actionName) { + return { status: "failed", output: "ade-action requires both 'domain' and 'action'." }; + } + if (!adeActionRegistryRef.isAllowed(domain, actionName)) { + return { status: "failed", output: `Action '${domain}.${actionName}' is not in the ADE action registry.` }; + } + const service = adeActionRegistryRef.getService(domain); + if (!service) { + return { status: "failed", output: `Service for domain '${domain}' is not available in this process.` }; + } + const fn = service[actionName]; + if (typeof fn !== "function") { + return { status: "failed", output: `Action '${domain}.${actionName}' is not callable on the resolved service.` }; + } + + // Resolve placeholders in args + any explicit `resolvers` map. + const resolvedArgs = resolvePlaceholders(config.args ?? {}, trigger); + if (config.resolvers && typeof resolvedArgs === "object" && resolvedArgs !== null && !Array.isArray(resolvedArgs)) { + for (const [key, pathExpr] of Object.entries(config.resolvers)) { + const value = readTriggerPath(trigger, pathExpr); + if (value !== undefined) { + (resolvedArgs as Record)[key] = value; + } + } + } + + try { + const callable = fn as (...a: unknown[]) => unknown; + const result = Array.isArray(resolvedArgs) + ? await callable(...resolvedArgs) + : await callable(resolvedArgs); + let output: string; + try { + output = result === undefined ? "" : typeof result === "string" ? result : JSON.stringify(result); + } catch { + output = String(result); + } + return { status: "succeeded", output }; + } catch (error) { + return { + status: "failed", + output: error instanceof Error ? error.message : String(error), + }; + } + }; + const runLegacyAction = async ( rule: AutomationRule, action: AutomationAction, @@ -1408,6 +1707,76 @@ export function createAutomationService({ await testService.run({ laneId, suiteId }); return { status: "succeeded" }; } + if (action.type === "ade-action") { + const config = action.adeAction; + if (!config) { + return { status: "failed", output: "ade-action action is missing adeAction config." }; + } + return await dispatchAdeAction(config, trigger); + } + if (action.type === "agent-session") { + // Spawn a scoped agent chat session as one step in a built-in chain. + // The prompt on the action wins over the rule-level prompt; placeholders + // inside the prompt are substituted the same way buildMissionPrompt does. + if (!agentChatServiceRef) { + return { status: "failed", output: "Agent chat service is unavailable." }; + } + const laneId = await resolveExecutionLaneId(rule, trigger); + if (!laneId) { + return { status: "failed", output: "No lane is available for this automation run." }; + } + const rawPrompt = (action.prompt ?? rule.prompt ?? "").trim(); + if (!rawPrompt) { + return { status: "failed", output: "agent-session action requires a prompt." }; + } + const interpolated = resolvePlaceholders(rawPrompt, trigger); + const promptText = typeof interpolated === "string" ? interpolated : rawPrompt; + const { modelId, modelDescriptor, providerGroup } = resolveAutomationModelDescriptor(rule); + const resolvedChat = resolveChatProviderForDescriptor(modelDescriptor); + const permissionConfig = buildPermissionConfig(rule, { publishPhase: false }); + const permissionMode = providerGroup === "claude" + ? permissionConfig.providers?.claude ?? "edit" + : providerGroup === "codex" + ? permissionConfig.providers?.codex ?? "default" + : permissionConfig.providers?.opencode ?? "edit"; + const reasoningEffort = rule.execution?.session?.reasoningEffort + ?? rule.modelConfig?.orchestratorModel?.thinkingLevel + ?? null; + const timeoutMs = Math.max( + 15_000, + Math.floor((action.timeoutMs ?? rule.guardrails.maxDurationMin ?? 10) * 60_000), + ); + try { + const session = await agentChatServiceRef.createSession({ + laneId, + provider: resolvedChat.provider, + model: resolvedChat.model, + modelId, + sessionProfile: "workflow", + reasoningEffort, + permissionMode, + ...(providerGroup === "codex" && permissionConfig.providers?.codexSandbox + ? { codexSandbox: permissionConfig.providers.codexSandbox } + : {}), + surface: "automation", + automationId: rule.id, + automationRunId: null, + }); + const result = await agentChatServiceRef.runSessionTurn({ + sessionId: session.id, + text: promptText, + displayText: action.sessionTitle?.trim() || promptText, + reasoningEffort, + timeoutMs, + }); + const output = result.outputText?.trim() + ? result.outputText + : `Agent session ${session.id} completed.`; + return { status: "succeeded", output }; + } catch (err) { + return { status: "failed", output: err instanceof Error ? err.message : String(err) }; + } + } if (action.type === "run-command") { const command = (action.command ?? "").trim(); if (!command) throw new Error("run-command requires command"); @@ -2066,47 +2435,6 @@ export function createAutomationService({ return { laneBranch, laneName }; }; - const triggerMatches = (ruleTrigger: AutomationTrigger, trigger: TriggerContext, laneBranch: string | undefined, laneName: string | undefined): boolean => { - if (!triggerTypesMatch(ruleTrigger.type, trigger.triggerType)) return false; - - const canonicalType = normalizeTriggerType(ruleTrigger.type); - if (canonicalType === "git.pr_merged") { - const expectedTarget = (ruleTrigger.targetBranch ?? ruleTrigger.branch ?? "").trim(); - if (expectedTarget && !matchesGlob(expectedTarget, trigger.targetBranch)) return false; - } else if (ruleTrigger.branch?.trim()) { - const branchToMatch = - canonicalType === "git.pr_opened" || canonicalType === "git.pr_updated" || canonicalType === "git.pr_closed" - ? trigger.branch - : laneBranch; - if (!matchesGlob(ruleTrigger.branch, branchToMatch)) return false; - } - if (ruleTrigger.event?.trim() && ruleTrigger.event.trim() !== (trigger.eventName ?? "").trim()) return false; - if (ruleTrigger.author?.trim()) { - const author = (trigger.author ?? "").trim().toLowerCase(); - if (!author || author !== ruleTrigger.author.trim().toLowerCase()) return false; - } - if (!listMatches(ruleTrigger.labels, trigger.labels)) return false; - if (ruleTrigger.paths?.length) { - const paths = trigger.paths ?? []; - if (!paths.some((entry) => ruleTrigger.paths?.some((expected) => matchesGlob(expected, entry)))) return false; - } - if (ruleTrigger.keywords?.length) { - const haystack = `${trigger.summary ?? ""} ${(trigger.keywords ?? []).join(" ")}`.toLowerCase(); - if (!ruleTrigger.keywords.some((entry) => haystack.includes(entry.trim().toLowerCase()))) return false; - } - if (ruleTrigger.namePattern?.trim() && !matchesGlob(ruleTrigger.namePattern, laneName)) return false; - if (ruleTrigger.project?.trim() && !matchesGlob(ruleTrigger.project, trigger.project)) return false; - if (ruleTrigger.team?.trim() && !matchesGlob(ruleTrigger.team, trigger.team)) return false; - if (ruleTrigger.assignee?.trim() && !matchesGlob(ruleTrigger.assignee, trigger.assignee)) return false; - if (ruleTrigger.stateTransition?.trim() && (trigger.stateTransition ?? "").trim() !== ruleTrigger.stateTransition.trim()) return false; - if (!listMatches(ruleTrigger.changedFields, trigger.changedFields)) return false; - if (ruleTrigger.draftState && ruleTrigger.draftState !== "any" && trigger.draftState && ruleTrigger.draftState !== trigger.draftState) { - return false; - } - if (ruleTrigger.activeHours && !isWithinActiveHours(ruleTrigger.activeHours)) return false; - return true; - }; - const dispatchTrigger = async (trigger: TriggerContext) => { const rules = listRules().filter((rule) => rule.enabled); const { laneBranch, laneName } = await resolveTriggerLaneInfo(trigger); @@ -2459,10 +2787,21 @@ export function createAutomationService({ labels?: string[]; paths?: string[]; keywords?: string[]; + branch?: string | null; + targetBranch?: string | null; draftState?: "draft" | "ready" | "any"; cursor?: string | null; rawPayload?: Record | null; automationId?: string | null; + repo?: string | null; + issue?: TriggerIssueContext | null; + pr?: TriggerPrContext | null; + linear?: { issue: TriggerLinearIssueContext } | null; + project?: string | null; + team?: string | null; + assignee?: string | null; + stateTransition?: string | null; + changedFields?: string[]; }): Promise => { const eventKey = args.eventKey.trim(); if (!eventKey.length) return null; @@ -2513,9 +2852,20 @@ export function createAutomationService({ labels: args.labels, paths: args.paths, keywords: args.keywords, + branch: args.branch ?? undefined, + targetBranch: args.targetBranch ?? undefined, draftState: args.draftState, reason: args.eventName ?? eventKey, scheduledAt: receivedAt, + repo: args.repo ?? undefined, + issue: args.issue ?? undefined, + pr: args.pr ?? undefined, + linear: args.linear ?? undefined, + project: args.project ?? undefined, + team: args.team ?? undefined, + assignee: args.assignee ?? undefined, + stateTransition: args.stateTransition ?? undefined, + changedFields: args.changedFields, }; const candidateRules = listRules() @@ -2581,6 +2931,10 @@ export function createAutomationService({ workerHeartbeatServiceRef = args.workerHeartbeatService ?? workerHeartbeatServiceRef; }, + bindAdeActionRegistry(registry: AutomationAdeActionRegistry | null) { + adeActionRegistryRef = registry; + }, + list(): AutomationRuleSummary[] { const rules = listRules(); const snapshot = projectConfigService.get(); @@ -2812,10 +3166,21 @@ export function createAutomationService({ labels?: string[]; paths?: string[]; keywords?: string[]; + branch?: string | null; + targetBranch?: string | null; draftState?: "draft" | "ready" | "any"; cursor?: string | null; rawPayload?: Record | null; automationId?: string | null; + repo?: string | null; + issue?: TriggerIssueContext | null; + pr?: TriggerPrContext | null; + linear?: { issue: TriggerLinearIssueContext } | null; + project?: string | null; + team?: string | null; + assignee?: string | null; + stateTransition?: string | null; + changedFields?: string[]; }): Promise { return await dispatchIngressTrigger(args); }, diff --git a/apps/desktop/src/main/services/automations/githubPollingService.ts b/apps/desktop/src/main/services/automations/githubPollingService.ts new file mode 100644 index 000000000..c82af0c67 --- /dev/null +++ b/apps/desktop/src/main/services/automations/githubPollingService.ts @@ -0,0 +1,464 @@ +import type { Logger } from "../logging/logger"; +import type { + AutomationTriggerIssueContext, + AutomationTriggerPrContext, + AutomationTriggerType, +} from "../../../shared/types"; +import type { GithubService, GitHubIssue, GitHubLabel, GitHubPullRequest } from "../github/githubService"; + +type AutomationServiceHandle = { + getIngressCursor(source: "github-polling" | "linear-relay" | "github-relay" | "local-webhook"): string | null; + setIngressCursor(args: { source: "github-polling"; cursor: string | null }): void; + dispatchIngressTrigger(args: { + source: "github-polling"; + eventKey: string; + triggerType: AutomationTriggerType; + eventName?: string | null; + summary?: string | null; + author?: string | null; + labels?: string[]; + keywords?: string[]; + branch?: string | null; + targetBranch?: string | null; + draftState?: "draft" | "ready" | "any"; + cursor?: string | null; + rawPayload?: Record | null; + repo?: string | null; + issue?: AutomationTriggerIssueContext | null; + pr?: AutomationTriggerPrContext | null; + }): Promise; +}; + +type RepoRef = { owner: string; name: string }; + +type GithubPollingServiceArgs = { + logger: Logger; + githubService: GithubService; + automationService: AutomationServiceHandle; + /** Extra repos to poll in addition to the detected origin. */ + extraRepos?: RepoRef[]; + pollIntervalMs?: number; +}; + +const DEFAULT_POLL_INTERVAL_MS = 30_000; + +function labelsToStrings(raw: GitHubIssue["labels"] | GitHubPullRequest["labels"]): string[] { + if (!Array.isArray(raw)) return []; + return raw + .map((entry) => { + if (typeof entry === "string") return entry.trim(); + if (entry && typeof entry === "object" && typeof (entry as { name?: unknown }).name === "string") { + return ((entry as { name: string }).name ?? "").trim(); + } + return ""; + }) + .filter(Boolean); +} + +function repoSlug(repo: RepoRef): string { + return `${repo.owner}/${repo.name}`; +} + +function issueContext(repo: RepoRef, issue: GitHubIssue): AutomationTriggerIssueContext { + return { + number: issue.number, + title: issue.title, + body: issue.body ?? undefined, + author: issue.user?.login ?? undefined, + labels: labelsToStrings(issue.labels), + repo: repoSlug(repo), + url: issue.html_url, + }; +} + +function prContext(repo: RepoRef, pr: GitHubPullRequest): AutomationTriggerPrContext { + return { + number: pr.number, + title: pr.title, + body: pr.body ?? undefined, + author: pr.user?.login ?? undefined, + labels: labelsToStrings(pr.labels), + repo: repoSlug(repo), + url: pr.html_url, + baseBranch: pr.base?.ref, + headBranch: pr.head?.ref, + draft: pr.draft ?? undefined, + merged: pr.merged ?? Boolean(pr.merged_at), + }; +} + +type IssueSnapshot = { + labels: string[]; + updatedAt: string; + state: "open" | "closed"; + commentCount: number; +}; + +type PrSnapshot = { + updatedAt: string; + state: "open" | "closed"; + merged: boolean; + mergedAt: string | null; +}; + +export function createGithubPollingService(args: GithubPollingServiceArgs) { + const { logger, githubService, automationService } = args; + const pollIntervalMs = Math.max(10_000, Math.floor(args.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS)); + + // Per-repo memory of last-seen issue/PR state so we can diff on each poll + // and emit `edited`/`labeled`/`closed` events without a webhook feed. + const issueSnapshots = new Map>(); + const prSnapshots = new Map>(); + const commentCursors = new Map(); // key: `${slug}:${issueNumber}` → last-seen created_at + + let timer: NodeJS.Timeout | null = null; + let running = false; + let stopped = false; + + const listRepos = async (): Promise => { + const out: RepoRef[] = []; + const seen = new Set(); + try { + const detected = await githubService.detectRepo(); + if (detected) { + const key = repoSlug(detected); + if (!seen.has(key)) { + seen.add(key); + out.push(detected); + } + } + } catch { + // ignore detection errors + } + for (const repo of args.extraRepos ?? []) { + const key = repoSlug(repo); + if (seen.has(key)) continue; + seen.add(key); + out.push(repo); + } + return out; + }; + + const readCursor = (repo: RepoRef): string | null => { + try { + // The cursor table is keyed by source, so stash the repo slug inside the + // stored cursor value as `@` when multiple repos + // are polled. For the common single-repo case we just store the ISO. + const stored = automationService.getIngressCursor("github-polling"); + if (!stored) return null; + const slug = repoSlug(repo); + if (stored.includes("|")) { + // Multi-repo stored as `slug1=iso1|slug2=iso2` + for (const part of stored.split("|")) { + const [key, value] = part.split("="); + if (key === slug && value) return value; + } + return null; + } + return stored; + } catch { + return null; + } + }; + + const writeCursor = (repo: RepoRef, cursor: string) => { + try { + const prev = automationService.getIngressCursor("github-polling") ?? ""; + const slug = repoSlug(repo); + if (!prev || !prev.includes("|")) { + // If a single-repo cursor already exists and it's a different slug, + // migrate to the multi-repo format. Otherwise just overwrite. + if (prev && !prev.includes("=")) { + automationService.setIngressCursor({ source: "github-polling", cursor: `${slug}=${cursor}` }); + } else { + const parts = new Map(); + for (const part of prev.split("|").filter(Boolean)) { + const [k, v] = part.split("="); + if (k && v) parts.set(k, v); + } + parts.set(slug, cursor); + const joined = [...parts.entries()].map(([k, v]) => `${k}=${v}`).join("|"); + automationService.setIngressCursor({ source: "github-polling", cursor: joined }); + } + } else { + const parts = new Map(); + for (const part of prev.split("|").filter(Boolean)) { + const [k, v] = part.split("="); + if (k && v) parts.set(k, v); + } + parts.set(slug, cursor); + const joined = [...parts.entries()].map(([k, v]) => `${k}=${v}`).join("|"); + automationService.setIngressCursor({ source: "github-polling", cursor: joined }); + } + } catch (error) { + logger.warn("automations.github_polling.cursor_write_failed", { + repo: repoSlug(repo), + error: error instanceof Error ? error.message : String(error), + }); + } + }; + + const dispatch = async ( + repo: RepoRef, + triggerType: AutomationTriggerType, + eventKey: string, + ctx: { issue?: AutomationTriggerIssueContext; pr?: AutomationTriggerPrContext; summary: string; rawPayload?: Record }, + ) => { + try { + await automationService.dispatchIngressTrigger({ + source: "github-polling", + eventKey, + triggerType, + eventName: triggerType, + summary: ctx.summary, + author: ctx.issue?.author ?? ctx.pr?.author ?? null, + labels: ctx.issue?.labels ?? ctx.pr?.labels ?? [], + branch: ctx.pr?.headBranch ?? null, + targetBranch: ctx.pr?.baseBranch ?? null, + draftState: ctx.pr?.draft === true ? "draft" : ctx.pr?.draft === false ? "ready" : "any", + repo: repoSlug(repo), + issue: ctx.issue ?? null, + pr: ctx.pr ?? null, + rawPayload: ctx.rawPayload ?? null, + }); + } catch (error) { + logger.warn("automations.github_polling.dispatch_failed", { + triggerType, + eventKey, + error: error instanceof Error ? error.message : String(error), + }); + } + }; + + const pollIssues = async (repo: RepoRef, since: string | undefined): Promise => { + const issues = await githubService.listRepoIssues(repo.owner, repo.name, { + state: "all", + sort: "updated", + since, + }); + // GitHub's `issues` endpoint mixes PRs in. Filter those out. + const realIssues = issues.filter((row) => !row.pull_request); + let maxUpdatedAt: string | null = null; + const snapshotByRepo = issueSnapshots.get(repoSlug(repo)) ?? new Map(); + issueSnapshots.set(repoSlug(repo), snapshotByRepo); + + for (const issue of realIssues) { + if (!maxUpdatedAt || issue.updated_at > maxUpdatedAt) maxUpdatedAt = issue.updated_at; + const prev = snapshotByRepo.get(issue.number); + const currentLabels = labelsToStrings(issue.labels); + const ctx = issueContext(repo, issue); + + if (!prev) { + // First time seeing this issue. On the very first poll (since is + // undefined) we treat pre-existing issues as "known" and don't emit + // `issue_opened` retroactively. If `since` is set, anything we haven't + // seen but whose `created_at === updated_at` is an open event. + const isNew = issue.created_at === issue.updated_at; + if (since !== undefined && isNew) { + await dispatch(repo, "github.issue_opened", `${repoSlug(repo)}#${issue.number}:opened`, { + issue: ctx, + summary: `Issue #${issue.number} opened: ${issue.title}`, + }); + } + snapshotByRepo.set(issue.number, { + labels: currentLabels, + updatedAt: issue.updated_at, + state: issue.state, + commentCount: issue.comments ?? 0, + }); + continue; + } + + if (prev.updatedAt === issue.updated_at && prev.state === issue.state && prev.labels.join("|") === currentLabels.join("|")) { + continue; + } + + // Label diff + const addedLabels = currentLabels.filter((l) => !prev.labels.includes(l)); + if (addedLabels.length) { + await dispatch(repo, "github.issue_labeled", `${repoSlug(repo)}#${issue.number}:labeled:${issue.updated_at}:${addedLabels.join(",")}`, { + issue: ctx, + summary: `Issue #${issue.number} labeled: ${addedLabels.join(", ")}`, + rawPayload: { addedLabels }, + }); + } + + // State transition + if (prev.state === "open" && issue.state === "closed") { + await dispatch(repo, "github.issue_closed", `${repoSlug(repo)}#${issue.number}:closed:${issue.updated_at}`, { + issue: ctx, + summary: `Issue #${issue.number} closed: ${issue.title}`, + }); + } + + // Generic edit (title/body changed without state/label change) + if (prev.updatedAt !== issue.updated_at && prev.state === issue.state && !addedLabels.length) { + await dispatch(repo, "github.issue_edited", `${repoSlug(repo)}#${issue.number}:edited:${issue.updated_at}`, { + issue: ctx, + summary: `Issue #${issue.number} edited: ${issue.title}`, + }); + } + + snapshotByRepo.set(issue.number, { + labels: currentLabels, + updatedAt: issue.updated_at, + state: issue.state, + commentCount: issue.comments ?? prev.commentCount, + }); + + // New comments. The `issues` endpoint gives us a count; if it grew we + // fetch comments since the last cursor. + const newCommentCount = issue.comments ?? 0; + if (newCommentCount > prev.commentCount) { + await pollComments(repo, issue.number, since, ctx, /* isPr */ false); + } + } + + return maxUpdatedAt; + }; + + const pollPulls = async (repo: RepoRef, since: string | undefined): Promise => { + const pulls = await githubService.listRepoPulls(repo.owner, repo.name, { + state: "all", + sort: "updated", + }); + // The pulls endpoint doesn't accept `since`; filter client-side. + const filtered = since ? pulls.filter((pr) => pr.updated_at > since) : pulls; + let maxUpdatedAt: string | null = null; + const snapshotByRepo = prSnapshots.get(repoSlug(repo)) ?? new Map(); + prSnapshots.set(repoSlug(repo), snapshotByRepo); + + for (const pr of filtered) { + if (!maxUpdatedAt || pr.updated_at > maxUpdatedAt) maxUpdatedAt = pr.updated_at; + const prev = snapshotByRepo.get(pr.number); + const ctx = prContext(repo, pr); + + if (!prev) { + const isNew = pr.created_at === pr.updated_at; + if (since !== undefined && isNew) { + await dispatch(repo, "github.pr_opened", `${repoSlug(repo)}#${pr.number}:pr_opened`, { + pr: ctx, + summary: `PR #${pr.number} opened: ${pr.title}`, + }); + } + snapshotByRepo.set(pr.number, { + updatedAt: pr.updated_at, + state: pr.state, + merged: Boolean(pr.merged ?? pr.merged_at), + mergedAt: pr.merged_at ?? null, + }); + continue; + } + + if (prev.updatedAt === pr.updated_at && prev.state === pr.state && prev.merged === Boolean(pr.merged ?? pr.merged_at)) { + continue; + } + + if (prev.state === "open" && pr.state === "closed") { + if (pr.merged || pr.merged_at) { + await dispatch(repo, "github.pr_merged", `${repoSlug(repo)}#${pr.number}:pr_merged:${pr.merged_at ?? pr.updated_at}`, { + pr: ctx, + summary: `PR #${pr.number} merged: ${pr.title}`, + }); + } else { + await dispatch(repo, "github.pr_closed", `${repoSlug(repo)}#${pr.number}:pr_closed:${pr.updated_at}`, { + pr: ctx, + summary: `PR #${pr.number} closed: ${pr.title}`, + }); + } + } else if (prev.updatedAt !== pr.updated_at) { + await dispatch(repo, "github.pr_updated", `${repoSlug(repo)}#${pr.number}:pr_updated:${pr.updated_at}`, { + pr: ctx, + summary: `PR #${pr.number} updated: ${pr.title}`, + }); + } + + snapshotByRepo.set(pr.number, { + updatedAt: pr.updated_at, + state: pr.state, + merged: Boolean(pr.merged ?? pr.merged_at), + mergedAt: pr.merged_at ?? null, + }); + } + + return maxUpdatedAt; + }; + + const pollComments = async ( + repo: RepoRef, + issueNumber: number, + _since: string | undefined, + ctx: AutomationTriggerIssueContext, + isPr: boolean, + ) => { + const key = `${repoSlug(repo)}:${issueNumber}`; + const cursor = commentCursors.get(key); + const comments = await githubService.listIssueComments(repo.owner, repo.name, issueNumber, { since: cursor }); + for (const comment of comments) { + if (cursor && comment.created_at <= cursor) continue; + await dispatch( + repo, + isPr ? "github.pr_commented" : "github.issue_commented", + `${repoSlug(repo)}#${issueNumber}:comment:${comment.id}`, + { + issue: isPr ? undefined : { ...ctx, body: comment.body }, + pr: isPr ? { ...(ctx as AutomationTriggerPrContext), body: comment.body } : undefined, + summary: `${isPr ? "PR" : "Issue"} #${issueNumber} commented by ${comment.user?.login ?? "unknown"}`, + rawPayload: { commentId: comment.id, body: comment.body }, + }, + ); + if (!cursor || comment.created_at > cursor) { + commentCursors.set(key, comment.created_at); + } + } + }; + + const pollOnce = async () => { + if (running || stopped) return; + running = true; + try { + const repos = await listRepos(); + for (const repo of repos) { + try { + const cursor = readCursor(repo) ?? undefined; + const maxIssues = await pollIssues(repo, cursor); + const maxPulls = await pollPulls(repo, cursor); + const maxOverall = [maxIssues, maxPulls].filter((v): v is string => typeof v === "string").sort().at(-1); + if (maxOverall && maxOverall !== cursor) { + writeCursor(repo, maxOverall); + } + } catch (error) { + logger.warn("automations.github_polling.repo_failed", { + repo: repoSlug(repo), + error: error instanceof Error ? error.message : String(error), + }); + } + } + } finally { + running = false; + } + }; + + return { + async start() { + if (timer || stopped) return; + // Kick off one poll on start, then schedule the interval. + void pollOnce().catch(() => {}); + timer = setInterval(() => { + void pollOnce().catch(() => {}); + }, pollIntervalMs); + }, + async pollNow() { + await pollOnce(); + }, + dispose() { + stopped = true; + if (timer) { + clearInterval(timer); + timer = null; + } + }, + }; +} + +export type GithubPollingService = ReturnType; diff --git a/apps/desktop/src/main/services/github/githubService.ts b/apps/desktop/src/main/services/github/githubService.ts index 16254eb99..3412c6e0e 100644 --- a/apps/desktop/src/main/services/github/githubService.ts +++ b/apps/desktop/src/main/services/github/githubService.ts @@ -360,6 +360,148 @@ export function createGithubService({ } }; + const listRepoLabels = async (owner: string, name: string): Promise => { + const { data } = await apiRequest({ + method: "GET", + path: `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/labels`, + query: { per_page: 100 }, + }); + return Array.isArray(data) ? data : []; + }; + + const listRepoCollaborators = async (owner: string, name: string): Promise => { + const { data } = await apiRequest({ + method: "GET", + path: `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/collaborators`, + query: { per_page: 100 }, + }); + return Array.isArray(data) ? data : []; + }; + + const listRepoIssues = async ( + owner: string, + name: string, + opts: { since?: string; state?: "open" | "closed" | "all"; sort?: "created" | "updated"; perPage?: number } = {} + ): Promise => { + const { data } = await apiRequest({ + method: "GET", + path: `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/issues`, + query: { + state: opts.state ?? "all", + sort: opts.sort ?? "updated", + per_page: opts.perPage ?? 50, + ...(opts.since ? { since: opts.since } : {}), + }, + }); + return Array.isArray(data) ? data : []; + }; + + const getIssue = async (owner: string, name: string, number: number): Promise => { + try { + const { data } = await apiRequest({ + method: "GET", + path: `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/issues/${number}`, + }); + return data ?? null; + } catch (error) { + logger.warn("github.get_issue_failed", { + owner, name, number, + error: error instanceof Error ? error.message : String(error), + }); + return null; + } + }; + + const listIssueComments = async ( + owner: string, + name: string, + number: number, + opts: { since?: string } = {} + ): Promise => { + const { data } = await apiRequest({ + method: "GET", + path: `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/issues/${number}/comments`, + query: { + per_page: 100, + ...(opts.since ? { since: opts.since } : {}), + }, + }); + return Array.isArray(data) ? data : []; + }; + + const listRepoPulls = async ( + owner: string, + name: string, + opts: { state?: "open" | "closed" | "all"; sort?: "created" | "updated"; perPage?: number } = {} + ): Promise => { + const { data } = await apiRequest({ + method: "GET", + path: `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/pulls`, + query: { + state: opts.state ?? "all", + sort: opts.sort ?? "updated", + direction: "desc", + per_page: opts.perPage ?? 50, + }, + }); + return Array.isArray(data) ? data : []; + }; + + // Issue-domain action helpers (used by the automations `issue` domain). + const addIssueComment = async (owner: string, name: string, number: number, body: string): Promise => { + const { data } = await apiRequest({ + method: "POST", + path: `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/issues/${number}/comments`, + body: { body }, + }); + return data ?? null; + }; + + const setIssueLabels = async (owner: string, name: string, number: number, labels: string[]): Promise => { + const { data } = await apiRequest({ + method: "PUT", + path: `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/issues/${number}/labels`, + body: { labels }, + }); + return Array.isArray(data) ? data : []; + }; + + const closeIssue = async (owner: string, name: string, number: number, reason?: "completed" | "not_planned"): Promise => { + const { data } = await apiRequest({ + method: "PATCH", + path: `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/issues/${number}`, + body: { state: "closed", ...(reason ? { state_reason: reason } : {}) }, + }); + return data ?? null; + }; + + const reopenIssue = async (owner: string, name: string, number: number): Promise => { + const { data } = await apiRequest({ + method: "PATCH", + path: `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/issues/${number}`, + body: { state: "open" }, + }); + return data ?? null; + }; + + const assignIssue = async (owner: string, name: string, number: number, assignees: string[]): Promise => { + const { data } = await apiRequest({ + method: "POST", + path: `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/issues/${number}/assignees`, + body: { assignees }, + }); + return data ?? null; + }; + + const setIssueTitle = async (owner: string, name: string, number: number, title: string): Promise => { + const { data } = await apiRequest({ + method: "PATCH", + path: `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/issues/${number}`, + body: { title }, + }); + return data ?? null; + }; + return { getStatus, @@ -389,6 +531,95 @@ export function createGithubService({ return token; }, - apiRequest + detectRepo, + apiRequest, + + // Polling/picker read helpers + listRepoLabels, + listRepoCollaborators, + listRepoIssues, + getIssue, + listIssueComments, + listRepoPulls, + + // Issue-domain action helpers (exposed via `issue` domain in the + // automations action registry). + addIssueComment, + setIssueLabels, + closeIssue, + reopenIssue, + assignIssue, + setIssueTitle, }; } + +export type GitHubLabel = { + id?: number; + node_id?: string; + url?: string; + name: string; + color?: string; + default?: boolean; + description?: string | null; +}; + +export type GitHubUser = { + id?: number; + login: string; + avatar_url?: string; + html_url?: string; + type?: string; +}; + +export type GitHubIssueComment = { + id: number; + body: string; + user?: GitHubUser | null; + created_at: string; + updated_at: string; + html_url?: string; +}; + +export type GitHubIssue = { + id?: number; + number: number; + title: string; + body?: string | null; + state: "open" | "closed"; + state_reason?: string | null; + user?: GitHubUser | null; + labels?: Array; + assignees?: GitHubUser[]; + created_at: string; + updated_at: string; + closed_at?: string | null; + html_url?: string; + comments?: number; + /** + * GitHub returns a `pull_request` sub-object on issue rows when the row is + * actually a PR. Callers should filter those out when fetching issues. + */ + pull_request?: unknown; +}; + +export type GitHubPullRequest = { + id?: number; + number: number; + title: string; + body?: string | null; + state: "open" | "closed"; + draft?: boolean; + merged?: boolean; + merged_at?: string | null; + closed_at?: string | null; + created_at: string; + updated_at: string; + user?: GitHubUser | null; + labels?: Array; + assignees?: GitHubUser[]; + base?: { ref?: string; sha?: string }; + head?: { ref?: string; sha?: string }; + html_url?: string; +}; + +export type GithubService = ReturnType; diff --git a/apps/desktop/src/main/services/ipc/registerIpc.ts b/apps/desktop/src/main/services/ipc/registerIpc.ts index 0f936f4a5..35689e006 100644 --- a/apps/desktop/src/main/services/ipc/registerIpc.ts +++ b/apps/desktop/src/main/services/ipc/registerIpc.ts @@ -40,6 +40,7 @@ import type { AutomationSaveDraftResult, AutomationSimulateRequest, AutomationSimulateResult, + AdeActionRegistryEntry, AddMissionArtifactArgs, AddMissionInterventionArgs, ConflictProposal, @@ -542,6 +543,9 @@ import type { createOnboardingService } from "../onboarding/onboardingService"; import type { createAutomationService } from "../automations/automationService"; import type { createAutomationPlannerService } from "../automations/automationPlannerService"; import type { createAutomationIngressService } from "../automations/automationIngressService"; +import type { createGithubPollingService } from "../automations/githubPollingService"; +import { ADE_ACTION_ALLOWLIST, getAdeActionDomainServices, listAllowedAdeActionNames } from "../adeActions/registry"; +import type { AdeRuntime } from "../../../../../ade-cli/src/bootstrap"; import { type createMissionService } from "../missions/missionService"; import type { createMissionPreflightService } from "../missions/missionPreflightService"; @@ -628,6 +632,7 @@ export type AppContext = { automationService: ReturnType; automationPlannerService: ReturnType; automationIngressService?: ReturnType | null; + githubPollingService?: ReturnType | null; missionService: ReturnType; missionPreflightService: ReturnType; orchestratorService: ReturnType; @@ -2715,6 +2720,24 @@ export function registerIpc({ return ctx.automationPlannerService.simulate(arg); }); + ipcMain.handle(IPC.adeActionsListRegistry, async (): Promise => { + const ctx = getCtx(); + const services = getAdeActionDomainServices(ctx as unknown as AdeRuntime); + const entries: AdeActionRegistryEntry[] = []; + for (const domain of Object.keys(ADE_ACTION_ALLOWLIST) as Array) { + const service = services[domain]; + if (!service) continue; + const actionNames = listAllowedAdeActionNames(domain, service as Record); + if (actionNames.length === 0) continue; + entries.push({ + domain, + actions: actionNames.map((name) => ({ name })), + }); + } + entries.sort((a, b) => a.domain.localeCompare(b.domain)); + return entries; + }); + ipcMain.handle(IPC.missionsList, async (_event, arg: ListMissionsArgs = {}): Promise => { const ctx = getCtx(); return ctx.missionService.list(arg); @@ -5063,6 +5086,41 @@ export function registerIpc({ return await ctx.githubService.getStatus(); }); + const resolveGithubRepoRef = async ( + githubService: ReturnType, + arg?: { owner?: string; name?: string } | null + ): Promise<{ owner: string; name: string }> => { + const owner = arg?.owner?.trim(); + const name = arg?.name?.trim(); + if (owner && name) return { owner, name }; + const detected = await githubService.detectRepo(); + if (!detected) { + throw new Error("Unable to detect GitHub repo from git remote 'origin'. Provide owner/name explicitly."); + } + return detected; + }; + + ipcMain.handle(IPC.githubListRepoLabels, async (_event, arg: { owner?: string; name?: string }) => { + const ctx = getCtx(); + const { owner, name } = await resolveGithubRepoRef(ctx.githubService, arg); + return await ctx.githubService.listRepoLabels(owner, name); + }); + + ipcMain.handle(IPC.githubListRepoCollaborators, async (_event, arg: { owner?: string; name?: string }) => { + const ctx = getCtx(); + const { owner, name } = await resolveGithubRepoRef(ctx.githubService, arg); + return await ctx.githubService.listRepoCollaborators(owner, name); + }); + + ipcMain.handle(IPC.githubListRepoIssues, async (_event, arg: { owner?: string; name?: string; state?: "open" | "closed" | "all"; since?: string }) => { + const ctx = getCtx(); + const { owner, name } = await resolveGithubRepoRef(ctx.githubService, arg); + return await ctx.githubService.listRepoIssues(owner, name, { + state: arg?.state ?? "all", + since: arg?.since, + }); + }); + // ── Feedback Reporter ────────────────────────────────────────────── ipcMain.handle(IPC.feedbackPrepareDraft, async (_event, arg: FeedbackPrepareDraftArgs): Promise => { const ctx = getCtx(); diff --git a/apps/desktop/src/preload/global.d.ts b/apps/desktop/src/preload/global.d.ts index 196d4b513..ba91bd8a0 100644 --- a/apps/desktop/src/preload/global.d.ts +++ b/apps/desktop/src/preload/global.d.ts @@ -113,6 +113,7 @@ import type { AutomationSaveDraftResult, AutomationSimulateRequest, AutomationSimulateResult, + AdeActionRegistryEntry, UsageSnapshot, BudgetCheckResult, BudgetCapScope, @@ -770,6 +771,9 @@ declare global { ) => Promise; onEvent: (cb: (ev: AutomationsEventPayload) => void) => () => void; }; + actions: { + listRegistry: () => Promise; + }; usage: { getSnapshot: () => Promise; refresh: () => Promise; diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 4205489b7..54ce574a0 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -35,6 +35,7 @@ import type { AutomationSaveDraftResult, AutomationSimulateRequest, AutomationSimulateResult, + AdeActionRegistryEntry, AdeCliInstallResult, AdeCliStatus, AiApiKeyVerificationResult, @@ -896,6 +897,10 @@ contextBridge.exposeInMainWorld("ade", { return () => ipcRenderer.removeListener(IPC.automationsEvent, listener); }, }, + actions: { + listRegistry: async (): Promise => + ipcRenderer.invoke(IPC.adeActionsListRegistry), + }, usage: { getSnapshot: async (): Promise => ipcRenderer.invoke(IPC.usageGetSnapshot), diff --git a/apps/desktop/src/renderer/browserMock.ts b/apps/desktop/src/renderer/browserMock.ts index c93f3d586..e68bc7369 100644 --- a/apps/desktop/src/renderer/browserMock.ts +++ b/apps/desktop/src/renderer/browserMock.ts @@ -2029,6 +2029,9 @@ if (typeof window !== "undefined" && !(window as any).ade) { }), onEvent: noop, }, + actions: { + listRegistry: resolved([]), + }, missions: { list: resolved([]), get: resolvedArg(null), diff --git a/apps/desktop/src/renderer/components/app/App.tsx b/apps/desktop/src/renderer/components/app/App.tsx index d6b9465bb..c657413bd 100644 --- a/apps/desktop/src/renderer/components/app/App.tsx +++ b/apps/desktop/src/renderer/components/app/App.tsx @@ -38,6 +38,9 @@ const HistoryPage = React.lazy(() => const AutomationsPage = React.lazy(() => import("../automations/AutomationsPage").then((m) => ({ default: m.AutomationsPage })) ); +const AutomationsTemplatesPage = React.lazy(() => + import("../automations/AutomationsTemplatesPage").then((m) => ({ default: m.AutomationsTemplatesPage })) +); const SettingsPage = React.lazy(() => import("./SettingsPage").then((m) => ({ default: m.SettingsPage })) ); @@ -219,6 +222,7 @@ export function App() { )} /> )} /> )} /> + )} /> )} /> )} /> )} /> diff --git a/apps/desktop/src/renderer/components/automations/ActionList.tsx b/apps/desktop/src/renderer/components/automations/ActionList.tsx new file mode 100644 index 000000000..f50c257c7 --- /dev/null +++ b/apps/desktop/src/renderer/components/automations/ActionList.tsx @@ -0,0 +1,165 @@ +import { useRef, useState } from "react"; +import { + Code, + Lightning, + Plus, + Rocket, + TerminalWindow, + TestTube, + Warning, +} from "@phosphor-icons/react"; +import type { ElementType } from "react"; +import type { TestSuiteDefinition } from "../../../shared/types"; +import { cn } from "../ui/cn"; +import { ActionRow, type ActionRowKind, type ActionRowValue } from "./ActionRow"; + +const ADD_OPTIONS: Array<{ kind: ActionRowKind; label: string; icon: ElementType; disabled?: boolean; hint?: string }> = [ + { kind: "agent-session", label: "Agent session", icon: Lightning }, + { kind: "ade-action", label: "Run ADE action", icon: Code }, + { kind: "run-tests", label: "Run tests", icon: TestTube }, + { kind: "run-command", label: "Run command", icon: TerminalWindow }, + { kind: "predict-conflicts", label: "Predict conflicts", icon: Warning }, + { kind: "launch-mission", label: "Mission", icon: Rocket, disabled: true, hint: "Coming soon" }, +]; + +function createBlankAction(kind: ActionRowKind, suites: TestSuiteDefinition[]): ActionRowValue { + switch (kind) { + case "agent-session": + return { kind, prompt: "", sessionTitle: "" }; + case "ade-action": + return { kind, adeAction: { domain: "", action: "" } }; + case "run-tests": + return { kind, suiteId: suites[0]?.id ?? "" }; + case "run-command": + return { kind, command: "", cwd: "" }; + case "predict-conflicts": + return { kind }; + case "launch-mission": + return { kind, missionTitle: "" }; + } +} + +function newKey(): string { + // Prefer crypto.randomUUID in modern browsers / Electron; fall back to a + // timestamp-based id so tests and legacy runtimes don't crash. + const c = (globalThis as { crypto?: { randomUUID?: () => string } }).crypto; + return c?.randomUUID?.() ?? `action-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; +} + +export function ActionList({ + actions, + suites, + onChange, +}: { + actions: ActionRowValue[]; + suites: TestSuiteDefinition[]; + onChange: (next: ActionRowValue[]) => void; +}) { + const [menuOpen, setMenuOpen] = useState(false); + // Stable per-row keys that survive reorders so React preserves focus/DOM + // identity when the user clicks up/down arrows. Keys are regenerated only + // when the row count changes (backfilling appended rows or trimming). + const keysRef = useRef(actions.map(() => newKey())); + if (keysRef.current.length !== actions.length) { + const next = keysRef.current.slice(0, actions.length); + while (next.length < actions.length) next.push(newKey()); + keysRef.current = next; + } + + const addAction = (kind: ActionRowKind) => { + setMenuOpen(false); + keysRef.current = [...keysRef.current, newKey()]; + onChange([...actions, createBlankAction(kind, suites)]); + }; + + const updateAction = (index: number, next: ActionRowValue) => { + // Key at `index` stays the same — only the value mutates. + onChange(actions.map((action, i) => (i === index ? next : action))); + }; + + const removeAction = (index: number) => { + keysRef.current = keysRef.current.filter((_, i) => i !== index); + onChange(actions.filter((_, i) => i !== index)); + }; + + const moveAction = (index: number, direction: -1 | 1) => { + const target = index + direction; + if (target < 0 || target >= actions.length) return; + const nextActions = [...actions]; + const nextKeys = [...keysRef.current]; + [nextActions[index], nextActions[target]] = [nextActions[target], nextActions[index]]; + [nextKeys[index], nextKeys[target]] = [nextKeys[target], nextKeys[index]]; + keysRef.current = nextKeys; + onChange(nextActions); + }; + + + return ( +
+ {actions.length === 0 ? ( +
+ No actions yet. Add at least one step below. +
+ ) : ( +
+ {actions.map((action, index) => ( + updateAction(index, next)} + onRemove={() => removeAction(index)} + onMove={(direction) => moveAction(index, direction)} + /> + ))} +
+ )} + +
+ + {menuOpen ? ( +
setMenuOpen(false)} + > + {ADD_OPTIONS.map((option) => { + const Icon = option.icon; + return ( + + ); + })} +
+ ) : null} +
+
+ ); +} diff --git a/apps/desktop/src/renderer/components/automations/ActionRow.tsx b/apps/desktop/src/renderer/components/automations/ActionRow.tsx new file mode 100644 index 000000000..956ab343b --- /dev/null +++ b/apps/desktop/src/renderer/components/automations/ActionRow.tsx @@ -0,0 +1,205 @@ +import { + ArrowDown, + ArrowUp, + Code, + Gear, + Lightning, + Rocket, + TerminalWindow, + TestTube, + Trash, + Warning, +} from "@phosphor-icons/react"; +import type { ElementType } from "react"; +import type { TestSuiteDefinition } from "../../../shared/types"; +import { Chip } from "../ui/Chip"; +import { cn } from "../ui/cn"; +import { INPUT_CLS, INPUT_STYLE } from "./shared"; +import { AdeActionEditor, type AdeActionValue } from "./AdeActionEditor"; + +export type ActionRowKind = + | "agent-session" + | "ade-action" + | "run-tests" + | "run-command" + | "predict-conflicts" + | "launch-mission"; + +export type ActionRowValue = { + kind: ActionRowKind; + // Agent-session + prompt?: string; + sessionTitle?: string; + // ade-action + adeAction?: AdeActionValue; + // run-tests + suiteId?: string; + // run-command + command?: string; + cwd?: string; + // Mission + missionTitle?: string; +}; + +const KIND_META: Record = { + "agent-session": { label: "Agent session", icon: Lightning, accent: "#38BDF8" }, + "ade-action": { label: "Run ADE action", icon: Code, accent: "#A78BFA" }, + "run-tests": { label: "Run tests", icon: TestTube, accent: "#22C55E" }, + "run-command": { label: "Run command", icon: TerminalWindow, accent: "#F59E0B" }, + "predict-conflicts": { label: "Predict conflicts", icon: Warning, accent: "#F97316" }, + "launch-mission": { label: "Mission", icon: Rocket, accent: "#94A3B8" }, +}; + +export function ActionRow({ + index, + total, + value, + suites, + onChange, + onRemove, + onMove, +}: { + index: number; + total: number; + value: ActionRowValue; + suites: TestSuiteDefinition[]; + onChange: (next: ActionRowValue) => void; + onRemove: () => void; + onMove: (direction: -1 | 1) => void; +}) { + const meta = KIND_META[value.kind]; + const Icon = meta.icon; + + return ( +
+
+
+ + + {index + 1}. {meta.label} + +
+
+ + + +
+
+ +
+ {value.kind === "agent-session" ? ( +
+ onChange({ ...value, sessionTitle: event.target.value })} + placeholder="Thread title (optional)" + /> +