From 626c355df0060c6fd42901001c486d4b62dd0e29 Mon Sep 17 00:00:00 2001 From: Jan Burzinski <156842394+janburzinski@users.noreply.github.com> Date: Tue, 7 Apr 2026 16:14:41 +0200 Subject: [PATCH 01/14] fix: resolve auto-approve sandbox conflict - Refactor agent command handling for sandbox mode compatibility - Update local and SSH conversation implementations - Add auto-approve utility with tests - Add session respawn functionality - Improve PTY environment handling --- .../conversations/impl/agent-command.test.ts | 77 +++++++++++++++++++ .../core/conversations/impl/agent-command.ts | 12 ++- .../conversations/impl/auto-approve.test.ts | 49 ++++++++++++ .../core/conversations/impl/auto-approve.ts | 9 +++ .../conversations/impl/local-conversation.ts | 51 ++++++------ .../conversations/impl/session-respawn.ts | 66 ++++++++++++++++ .../conversations/impl/ssh-conversation.ts | 51 ++++++------ src/main/core/pty/pty-env.ts | 45 ++++++----- 8 files changed, 293 insertions(+), 67 deletions(-) create mode 100644 src/main/core/conversations/impl/agent-command.test.ts create mode 100644 src/main/core/conversations/impl/auto-approve.test.ts create mode 100644 src/main/core/conversations/impl/auto-approve.ts create mode 100644 src/main/core/conversations/impl/session-respawn.ts diff --git a/src/main/core/conversations/impl/agent-command.test.ts b/src/main/core/conversations/impl/agent-command.test.ts new file mode 100644 index 000000000..42b4664bd --- /dev/null +++ b/src/main/core/conversations/impl/agent-command.test.ts @@ -0,0 +1,77 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { providerOverrideSettings } from '@main/core/settings/provider-settings-service'; +import { buildAgentCommand } from './agent-command'; + +vi.mock('@main/core/settings/provider-settings-service', () => ({ + providerOverrideSettings: { + getItem: vi.fn(), + }, +})); + +describe('buildAgentCommand', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('forces Claude bypassPermissions when auto-approve is enabled', async () => { + vi.mocked(providerOverrideSettings.getItem).mockResolvedValue({ + cli: 'claude', + resumeFlag: '--resume', + autoApproveFlag: '--dangerously-skip-permissions', + defaultArgs: [], + }); + + const result = await buildAgentCommand({ + providerId: 'claude', + autoApprove: true, + sessionId: 'session-1', + isResuming: false, + }); + + expect(result.command).toBe('claude'); + expect(result.args).toContain('--permission-mode'); + expect(result.args).toContain('bypassPermissions'); + expect(result.args).not.toContain('--dangerously-skip-permissions'); + }); + + it('forces Claude default mode when auto-approve is disabled', async () => { + vi.mocked(providerOverrideSettings.getItem).mockResolvedValue({ + cli: 'claude', + resumeFlag: '--resume', + autoApproveFlag: '--dangerously-skip-permissions', + defaultArgs: [], + }); + + const result = await buildAgentCommand({ + providerId: 'claude', + autoApprove: false, + sessionId: 'session-1', + isResuming: false, + }); + + expect(result.args).toContain('--permission-mode'); + expect(result.args).toContain('default'); + }); + + it('splits resumeFlag with spaces when resuming', async () => { + vi.mocked(providerOverrideSettings.getItem).mockResolvedValue({ + cli: 'opencode', + resumeFlag: '--resume --force', + sessionIdFlag: '--session-id', + autoApproveFlag: '--yes', + defaultArgs: [], + }); + + const result = await buildAgentCommand({ + providerId: 'opencode', + autoApprove: false, + sessionId: 'session-123', + isResuming: true, + }); + + expect(result.args).toContain('--resume'); + expect(result.args).toContain('--force'); + // When resuming, only the session ID is added (without the flag) + expect(result.args).toContain('session-123'); + }); +}); diff --git a/src/main/core/conversations/impl/agent-command.ts b/src/main/core/conversations/impl/agent-command.ts index d14ec73cd..a98e00a4a 100644 --- a/src/main/core/conversations/impl/agent-command.ts +++ b/src/main/core/conversations/impl/agent-command.ts @@ -1,6 +1,10 @@ import { AgentProviderId, getProvider } from '@shared/agent-provider-registry'; import { providerOverrideSettings } from '@main/core/settings/provider-settings-service'; +function splitFlagArgs(flag: string): string[] { + return flag.split(' ').filter(Boolean); +} + export async function buildAgentCommand({ providerId, autoApprove, @@ -21,7 +25,7 @@ export async function buildAgentCommand({ const args: string[] = []; if (isResuming && providerConfig?.resumeFlag) { - args.push(...providerConfig.resumeFlag.split(' ')); + args.push(...splitFlagArgs(providerConfig.resumeFlag)); if (providerConfig?.sessionIdFlag) { args.push(sessionId); } @@ -29,8 +33,10 @@ export async function buildAgentCommand({ args.push(providerConfig.sessionIdFlag, sessionId); } - if (autoApprove && providerConfig?.autoApproveFlag) { - args.push(providerConfig.autoApproveFlag); + if (providerId === 'claude') { + args.push('--permission-mode', autoApprove ? 'bypassPermissions' : 'default'); + } else if (autoApprove && providerConfig?.autoApproveFlag) { + args.push(...splitFlagArgs(providerConfig.autoApproveFlag)); } if (!isResuming && initialPrompt && !providerDef?.useKeystrokeInjection) { diff --git a/src/main/core/conversations/impl/auto-approve.test.ts b/src/main/core/conversations/impl/auto-approve.test.ts new file mode 100644 index 000000000..e23064852 --- /dev/null +++ b/src/main/core/conversations/impl/auto-approve.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest'; +import { resolveAutoApproveEnabled } from './auto-approve'; + +describe('resolveAutoApproveEnabled', () => { + it('returns true when conversation auto-approve is true', () => { + expect( + resolveAutoApproveEnabled({ + conversationAutoApprove: true, + autoApproveByDefault: false, + }) + ).toBe(true); + }); + + it('returns true when global auto-approve default is true', () => { + expect( + resolveAutoApproveEnabled({ + conversationAutoApprove: undefined, + autoApproveByDefault: true, + }) + ).toBe(true); + }); + + it('returns true when both are true', () => { + expect( + resolveAutoApproveEnabled({ + conversationAutoApprove: true, + autoApproveByDefault: true, + }) + ).toBe(true); + }); + + it('returns false when both are false/undefined', () => { + expect( + resolveAutoApproveEnabled({ + conversationAutoApprove: undefined, + autoApproveByDefault: false, + }) + ).toBe(false); + }); + + it('allows explicit false to override global auto-approve default', () => { + expect( + resolveAutoApproveEnabled({ + conversationAutoApprove: false, + autoApproveByDefault: true, + }) + ).toBe(false); + }); +}); diff --git a/src/main/core/conversations/impl/auto-approve.ts b/src/main/core/conversations/impl/auto-approve.ts new file mode 100644 index 000000000..9f1be7e15 --- /dev/null +++ b/src/main/core/conversations/impl/auto-approve.ts @@ -0,0 +1,9 @@ +export function resolveAutoApproveEnabled({ + conversationAutoApprove, + autoApproveByDefault, +}: { + conversationAutoApprove?: boolean; + autoApproveByDefault: boolean; +}): boolean { + return conversationAutoApprove !== undefined ? conversationAutoApprove : autoApproveByDefault; +} diff --git a/src/main/core/conversations/impl/local-conversation.ts b/src/main/core/conversations/impl/local-conversation.ts index 7553b5fc6..e4943fa72 100644 --- a/src/main/core/conversations/impl/local-conversation.ts +++ b/src/main/core/conversations/impl/local-conversation.ts @@ -15,10 +15,13 @@ import { buildAgentEnv } from '@main/core/pty/pty-env'; import { ptySessionRegistry } from '@main/core/pty/pty-session-registry'; import { resolveSpawnParams } from '@main/core/pty/spawn-utils'; import { killTmuxSession, makeTmuxSessionName } from '@main/core/pty/tmux-session-name'; +import { appSettingsService } from '@main/core/settings/settings-service'; import type { ExecFn } from '@main/core/utils/exec'; import { events } from '@main/lib/events'; import { log } from '@main/lib/logger'; import { buildAgentCommand } from './agent-command'; +import { resolveAutoApproveEnabled } from './auto-approve'; +import { SessionRespawnTracker } from './session-respawn'; const DEFAULT_COLS = 80; const DEFAULT_ROWS = 24; @@ -26,7 +29,7 @@ const MAX_RESPAWNS = 2; export class LocalConversationProvider implements ConversationProvider { private sessions = new Map(); - private respawnCounts = new Map(); + private readonly respawnTracker: SessionRespawnTracker; private readonly projectId: string; private readonly taskPath: string; private readonly taskId: string; @@ -59,6 +62,10 @@ export class LocalConversationProvider implements ConversationProvider { this.shellSetup = shellSetup; this.exec = exec; this.taskEnvVars = taskEnvVars; + this.respawnTracker = new SessionRespawnTracker({ + maxRespawns: MAX_RESPAWNS, + providerName: 'LocalConversationProvider', + }); } async startSession( @@ -80,9 +87,15 @@ export class LocalConversationProvider implements ConversationProvider { homedir: homedir(), }); + const taskSettings = await appSettingsService.get('tasks'); + const autoApprove = resolveAutoApproveEnabled({ + conversationAutoApprove: conversation.autoApprove, + autoApproveByDefault: taskSettings.autoApproveByDefault, + }); + const { command, args } = await buildAgentCommand({ providerId: conversation.providerId, - autoApprove: conversation.autoApprove, + autoApprove, sessionId: conversation.id, isResuming, initialPrompt, @@ -99,7 +112,7 @@ export class LocalConversationProvider implements ConversationProvider { cwd: this.taskPath, shellSetup: this.shellSetup, tmuxSessionName, - autoApprove: conversation.autoApprove ?? false, + autoApprove, resume: isResuming, }; @@ -148,30 +161,22 @@ export class LocalConversationProvider implements ConversationProvider { taskId: conversation.taskId, exitCode, }); - if (shouldRespawn && !this.tmux) { - const count = (this.respawnCounts.get(sessionId) ?? 0) + 1; - this.respawnCounts.set(sessionId, count); - - if (count > MAX_RESPAWNS && !isResuming) { - log.error('LocalConversationProvider: respawn limit reached, giving up', { - conversationId: conversation.id, - }); - this.respawnCounts.delete(sessionId); - return; - } - - const resumeNext = isResuming && count <= MAX_RESPAWNS; - if (count > MAX_RESPAWNS) this.respawnCounts.set(sessionId, 0); - - setTimeout(() => { - this.startSession(conversation, initialSize, resumeNext, initialPrompt).catch((e) => { + + if (!shouldRespawn || this.tmux) return; + + const decision = this.respawnTracker.evaluate(sessionId, isResuming); + if (!decision.shouldRespawn) return; + + setTimeout(() => { + this.startSession(conversation, initialSize, decision.resumeNext, initialPrompt).catch( + (e) => { log.error('LocalConversationProvider: respawn failed', { conversationId: conversation.id, error: String(e), }); - }); - }, 500); - } + } + ); + }, 500); }); ptySessionRegistry.register(sessionId, pty); diff --git a/src/main/core/conversations/impl/session-respawn.ts b/src/main/core/conversations/impl/session-respawn.ts new file mode 100644 index 000000000..46cd26dcc --- /dev/null +++ b/src/main/core/conversations/impl/session-respawn.ts @@ -0,0 +1,66 @@ +import { log } from '@main/lib/logger'; + +export interface RespawnState { + count: number; + maxRespawns: number; +} + +export interface RespawnDecision { + shouldRespawn: boolean; + resumeNext: boolean; + resetCount: boolean; +} + +export function evaluateRespawn(state: RespawnState, isResuming: boolean): RespawnDecision { + const nextCount = state.count + 1; + + if (nextCount > state.maxRespawns && !isResuming) { + return { shouldRespawn: false, resumeNext: false, resetCount: true }; + } + + const resumeNext = isResuming && nextCount <= state.maxRespawns; + const resetCount = nextCount > state.maxRespawns; + + return { shouldRespawn: true, resumeNext, resetCount }; +} + +export class SessionRespawnTracker { + private counts = new Map(); + private readonly maxRespawns: number; + private readonly providerName: string; + + constructor(options: { maxRespawns: number; providerName: string }) { + this.maxRespawns = options.maxRespawns; + this.providerName = options.providerName; + } + + increment(sessionId: string): RespawnState { + const count = (this.counts.get(sessionId) ?? 0) + 1; + this.counts.set(sessionId, count); + return { count, maxRespawns: this.maxRespawns }; + } + + evaluate(sessionId: string, isResuming: boolean): RespawnDecision { + const state = this.increment(sessionId); + const decision = evaluateRespawn(state, isResuming); + + if (!decision.shouldRespawn) { + log.error(`${this.providerName}: respawn limit reached, giving up`, { + sessionId, + }); + this.counts.delete(sessionId); + } else if (decision.resetCount) { + this.counts.set(sessionId, 0); + } + + return decision; + } + + delete(sessionId: string): void { + this.counts.delete(sessionId); + } + + clear(): void { + this.counts.clear(); + } +} diff --git a/src/main/core/conversations/impl/ssh-conversation.ts b/src/main/core/conversations/impl/ssh-conversation.ts index be8f07917..e47c49b0a 100644 --- a/src/main/core/conversations/impl/ssh-conversation.ts +++ b/src/main/core/conversations/impl/ssh-conversation.ts @@ -11,11 +11,14 @@ import { ptySessionRegistry } from '@main/core/pty/pty-session-registry'; import { resolveSshCommand } from '@main/core/pty/spawn-utils'; import { openSsh2Pty } from '@main/core/pty/ssh2-pty'; import { killTmuxSession, makeTmuxSessionName } from '@main/core/pty/tmux-session-name'; +import { appSettingsService } from '@main/core/settings/settings-service'; import type { SshClientProxy } from '@main/core/ssh/ssh-client-proxy'; import type { ExecFn } from '@main/core/utils/exec'; import { events } from '@main/lib/events'; import { log } from '@main/lib/logger'; import { buildAgentCommand } from './agent-command'; +import { resolveAutoApproveEnabled } from './auto-approve'; +import { SessionRespawnTracker } from './session-respawn'; const DEFAULT_COLS = 80; const DEFAULT_ROWS = 24; @@ -23,7 +26,7 @@ const MAX_RESPAWNS = 2; export class SshConversationProvider implements ConversationProvider { private sessions = new Map(); - private respawnCounts = new Map(); + private readonly respawnTracker: SessionRespawnTracker; private readonly projectId: string; private readonly taskPath: string; private readonly taskId: string; @@ -60,6 +63,10 @@ export class SshConversationProvider implements ConversationProvider { this.shellSetup = shellSetup; this.exec = exec; this.proxy = proxy; + this.respawnTracker = new SessionRespawnTracker({ + maxRespawns: MAX_RESPAWNS, + providerName: 'SshConversationProvider', + }); } async startSession( @@ -83,9 +90,15 @@ export class SshConversationProvider implements ConversationProvider { remoteFs: new SshFileSystem(this.proxy, '/'), }); + const taskSettings = await appSettingsService.get('tasks'); + const autoApprove = resolveAutoApproveEnabled({ + conversationAutoApprove: conversation.autoApprove, + autoApproveByDefault: taskSettings.autoApproveByDefault, + }); + const { command, args } = await buildAgentCommand({ providerId: conversation.providerId, - autoApprove: conversation.autoApprove, + autoApprove, sessionId: conversation.id, isResuming, initialPrompt, @@ -102,7 +115,7 @@ export class SshConversationProvider implements ConversationProvider { cwd: this.taskPath, shellSetup: this.shellSetup, tmuxSessionName, - autoApprove: conversation.autoApprove ?? false, + autoApprove, resume: isResuming, }; @@ -145,30 +158,22 @@ export class SshConversationProvider implements ConversationProvider { taskId: conversation.taskId, exitCode, }); - if (shouldRespawn && !this.tmux) { - const count = (this.respawnCounts.get(sessionId) ?? 0) + 1; - this.respawnCounts.set(sessionId, count); - - if (count > MAX_RESPAWNS && !isResuming) { - log.error('SshConversationProvider: respawn limit reached, giving up', { - conversationId: conversation.id, - }); - this.respawnCounts.delete(sessionId); - return; - } - - const resumeNext = isResuming && count <= MAX_RESPAWNS; - if (count > MAX_RESPAWNS) this.respawnCounts.set(sessionId, 0); - - setTimeout(() => { - this.startSession(conversation, initialSize, resumeNext, initialPrompt).catch((e) => { + + if (!shouldRespawn || this.tmux) return; + + const decision = this.respawnTracker.evaluate(sessionId, isResuming); + if (!decision.shouldRespawn) return; + + setTimeout(() => { + this.startSession(conversation, initialSize, decision.resumeNext, initialPrompt).catch( + (e) => { log.error('SshConversationProvider: respawn failed', { conversationId: conversation.id, error: String(e), }); - }); - }, 500); - } + } + ); + }, 500); }); ptySessionRegistry.register(sessionId, pty); diff --git a/src/main/core/pty/pty-env.ts b/src/main/core/pty/pty-env.ts index bb544d565..2d942d27e 100644 --- a/src/main/core/pty/pty-env.ts +++ b/src/main/core/pty/pty-env.ts @@ -15,6 +15,7 @@ export const AGENT_ENV_VARS = [ 'AZURE_OPENAI_API_ENDPOINT', 'AZURE_OPENAI_API_KEY', 'AZURE_OPENAI_KEY', + 'CLAUDE_CONFIG_DIR', 'CODEBUFF_API_KEY', 'COPILOT_CLI_TOKEN', 'CURSOR_API_KEY', @@ -56,29 +57,37 @@ function getDisplayEnv(): Record { return env; } +function envOrDefault(key: string, defaultValue: string): string { + return process.env[key] || defaultValue; +} + function getWindowsEssentialEnv(resolvedPath: string): Record { const home = os.homedir(); + const temp = process.env.TEMP || process.env.TMP || ''; + return { PATH: resolvedPath, - PATHEXT: process.env.PATHEXT || '.COM;.EXE;.BAT;.CMD;.VBS;.VBE;.JS;.JSE;.WSF;.WSH;.MSC', - SystemRoot: process.env.SystemRoot || 'C:\\Windows', - ComSpec: process.env.ComSpec || 'C:\\Windows\\System32\\cmd.exe', - TEMP: process.env.TEMP || process.env.TMP || '', - TMP: process.env.TMP || process.env.TEMP || '', - USERPROFILE: process.env.USERPROFILE || home, - APPDATA: process.env.APPDATA || '', - LOCALAPPDATA: process.env.LOCALAPPDATA || '', - HOMEDRIVE: process.env.HOMEDRIVE || '', - HOMEPATH: process.env.HOMEPATH || '', + PATHEXT: envOrDefault('PATHEXT', '.COM;.EXE;.BAT;.CMD;.VBS;.VBE;.JS;.JSE;.WSF;.WSH;.MSC'), + SystemRoot: envOrDefault('SystemRoot', 'C:\\Windows'), + ComSpec: envOrDefault('ComSpec', 'C:\\Windows\\System32\\cmd.exe'), + TEMP: temp, + TMP: temp, + USERPROFILE: envOrDefault('USERPROFILE', home), + APPDATA: envOrDefault('APPDATA', ''), + LOCALAPPDATA: envOrDefault('LOCALAPPDATA', ''), + HOMEDRIVE: envOrDefault('HOMEDRIVE', ''), + HOMEPATH: envOrDefault('HOMEPATH', ''), USERNAME: process.env.USERNAME || os.userInfo().username, - ProgramFiles: process.env.ProgramFiles || 'C:\\Program Files', - 'ProgramFiles(x86)': process.env['ProgramFiles(x86)'] || 'C:\\Program Files (x86)', - ProgramData: process.env.ProgramData || 'C:\\ProgramData', - CommonProgramFiles: process.env.CommonProgramFiles || 'C:\\Program Files\\Common Files', - 'CommonProgramFiles(x86)': - process.env['CommonProgramFiles(x86)'] || 'C:\\Program Files (x86)\\Common Files', - ProgramW6432: process.env.ProgramW6432 || 'C:\\Program Files', - CommonProgramW6432: process.env.CommonProgramW6432 || 'C:\\Program Files\\Common Files', + ProgramFiles: envOrDefault('ProgramFiles', 'C:\\Program Files'), + 'ProgramFiles(x86)': envOrDefault('ProgramFiles(x86)', 'C:\\Program Files (x86)'), + ProgramData: envOrDefault('ProgramData', 'C:\\ProgramData'), + CommonProgramFiles: envOrDefault('CommonProgramFiles', 'C:\\Program Files\\Common Files'), + 'CommonProgramFiles(x86)': envOrDefault( + 'CommonProgramFiles(x86)', + 'C:\\Program Files (x86)\\Common Files' + ), + ProgramW6432: envOrDefault('ProgramW6432', 'C:\\Program Files'), + CommonProgramW6432: envOrDefault('CommonProgramW6432', 'C:\\Program Files\\Common Files'), }; } From a26d1269da1a0ce63aaacc3fa9dba901c5e8f24b Mon Sep 17 00:00:00 2001 From: Jan Burzinski <156842394+janburzinski@users.noreply.github.com> Date: Wed, 8 Apr 2026 10:02:28 +0200 Subject: [PATCH 02/14] Revert "fix: resolve auto-approve sandbox conflict" This reverts commit 34f682a1e89a0f6b9dba09ce64372686cb48add0. --- .../conversations/impl/agent-command.test.ts | 77 ------------------- .../core/conversations/impl/agent-command.ts | 12 +-- .../conversations/impl/auto-approve.test.ts | 49 ------------ .../core/conversations/impl/auto-approve.ts | 9 --- .../conversations/impl/local-conversation.ts | 51 ++++++------ .../conversations/impl/session-respawn.ts | 66 ---------------- .../conversations/impl/ssh-conversation.ts | 51 ++++++------ src/main/core/pty/pty-env.ts | 45 +++++------ 8 files changed, 67 insertions(+), 293 deletions(-) delete mode 100644 src/main/core/conversations/impl/agent-command.test.ts delete mode 100644 src/main/core/conversations/impl/auto-approve.test.ts delete mode 100644 src/main/core/conversations/impl/auto-approve.ts delete mode 100644 src/main/core/conversations/impl/session-respawn.ts diff --git a/src/main/core/conversations/impl/agent-command.test.ts b/src/main/core/conversations/impl/agent-command.test.ts deleted file mode 100644 index 42b4664bd..000000000 --- a/src/main/core/conversations/impl/agent-command.test.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { providerOverrideSettings } from '@main/core/settings/provider-settings-service'; -import { buildAgentCommand } from './agent-command'; - -vi.mock('@main/core/settings/provider-settings-service', () => ({ - providerOverrideSettings: { - getItem: vi.fn(), - }, -})); - -describe('buildAgentCommand', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it('forces Claude bypassPermissions when auto-approve is enabled', async () => { - vi.mocked(providerOverrideSettings.getItem).mockResolvedValue({ - cli: 'claude', - resumeFlag: '--resume', - autoApproveFlag: '--dangerously-skip-permissions', - defaultArgs: [], - }); - - const result = await buildAgentCommand({ - providerId: 'claude', - autoApprove: true, - sessionId: 'session-1', - isResuming: false, - }); - - expect(result.command).toBe('claude'); - expect(result.args).toContain('--permission-mode'); - expect(result.args).toContain('bypassPermissions'); - expect(result.args).not.toContain('--dangerously-skip-permissions'); - }); - - it('forces Claude default mode when auto-approve is disabled', async () => { - vi.mocked(providerOverrideSettings.getItem).mockResolvedValue({ - cli: 'claude', - resumeFlag: '--resume', - autoApproveFlag: '--dangerously-skip-permissions', - defaultArgs: [], - }); - - const result = await buildAgentCommand({ - providerId: 'claude', - autoApprove: false, - sessionId: 'session-1', - isResuming: false, - }); - - expect(result.args).toContain('--permission-mode'); - expect(result.args).toContain('default'); - }); - - it('splits resumeFlag with spaces when resuming', async () => { - vi.mocked(providerOverrideSettings.getItem).mockResolvedValue({ - cli: 'opencode', - resumeFlag: '--resume --force', - sessionIdFlag: '--session-id', - autoApproveFlag: '--yes', - defaultArgs: [], - }); - - const result = await buildAgentCommand({ - providerId: 'opencode', - autoApprove: false, - sessionId: 'session-123', - isResuming: true, - }); - - expect(result.args).toContain('--resume'); - expect(result.args).toContain('--force'); - // When resuming, only the session ID is added (without the flag) - expect(result.args).toContain('session-123'); - }); -}); diff --git a/src/main/core/conversations/impl/agent-command.ts b/src/main/core/conversations/impl/agent-command.ts index a98e00a4a..d14ec73cd 100644 --- a/src/main/core/conversations/impl/agent-command.ts +++ b/src/main/core/conversations/impl/agent-command.ts @@ -1,10 +1,6 @@ import { AgentProviderId, getProvider } from '@shared/agent-provider-registry'; import { providerOverrideSettings } from '@main/core/settings/provider-settings-service'; -function splitFlagArgs(flag: string): string[] { - return flag.split(' ').filter(Boolean); -} - export async function buildAgentCommand({ providerId, autoApprove, @@ -25,7 +21,7 @@ export async function buildAgentCommand({ const args: string[] = []; if (isResuming && providerConfig?.resumeFlag) { - args.push(...splitFlagArgs(providerConfig.resumeFlag)); + args.push(...providerConfig.resumeFlag.split(' ')); if (providerConfig?.sessionIdFlag) { args.push(sessionId); } @@ -33,10 +29,8 @@ export async function buildAgentCommand({ args.push(providerConfig.sessionIdFlag, sessionId); } - if (providerId === 'claude') { - args.push('--permission-mode', autoApprove ? 'bypassPermissions' : 'default'); - } else if (autoApprove && providerConfig?.autoApproveFlag) { - args.push(...splitFlagArgs(providerConfig.autoApproveFlag)); + if (autoApprove && providerConfig?.autoApproveFlag) { + args.push(providerConfig.autoApproveFlag); } if (!isResuming && initialPrompt && !providerDef?.useKeystrokeInjection) { diff --git a/src/main/core/conversations/impl/auto-approve.test.ts b/src/main/core/conversations/impl/auto-approve.test.ts deleted file mode 100644 index e23064852..000000000 --- a/src/main/core/conversations/impl/auto-approve.test.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { resolveAutoApproveEnabled } from './auto-approve'; - -describe('resolveAutoApproveEnabled', () => { - it('returns true when conversation auto-approve is true', () => { - expect( - resolveAutoApproveEnabled({ - conversationAutoApprove: true, - autoApproveByDefault: false, - }) - ).toBe(true); - }); - - it('returns true when global auto-approve default is true', () => { - expect( - resolveAutoApproveEnabled({ - conversationAutoApprove: undefined, - autoApproveByDefault: true, - }) - ).toBe(true); - }); - - it('returns true when both are true', () => { - expect( - resolveAutoApproveEnabled({ - conversationAutoApprove: true, - autoApproveByDefault: true, - }) - ).toBe(true); - }); - - it('returns false when both are false/undefined', () => { - expect( - resolveAutoApproveEnabled({ - conversationAutoApprove: undefined, - autoApproveByDefault: false, - }) - ).toBe(false); - }); - - it('allows explicit false to override global auto-approve default', () => { - expect( - resolveAutoApproveEnabled({ - conversationAutoApprove: false, - autoApproveByDefault: true, - }) - ).toBe(false); - }); -}); diff --git a/src/main/core/conversations/impl/auto-approve.ts b/src/main/core/conversations/impl/auto-approve.ts deleted file mode 100644 index 9f1be7e15..000000000 --- a/src/main/core/conversations/impl/auto-approve.ts +++ /dev/null @@ -1,9 +0,0 @@ -export function resolveAutoApproveEnabled({ - conversationAutoApprove, - autoApproveByDefault, -}: { - conversationAutoApprove?: boolean; - autoApproveByDefault: boolean; -}): boolean { - return conversationAutoApprove !== undefined ? conversationAutoApprove : autoApproveByDefault; -} diff --git a/src/main/core/conversations/impl/local-conversation.ts b/src/main/core/conversations/impl/local-conversation.ts index e4943fa72..7553b5fc6 100644 --- a/src/main/core/conversations/impl/local-conversation.ts +++ b/src/main/core/conversations/impl/local-conversation.ts @@ -15,13 +15,10 @@ import { buildAgentEnv } from '@main/core/pty/pty-env'; import { ptySessionRegistry } from '@main/core/pty/pty-session-registry'; import { resolveSpawnParams } from '@main/core/pty/spawn-utils'; import { killTmuxSession, makeTmuxSessionName } from '@main/core/pty/tmux-session-name'; -import { appSettingsService } from '@main/core/settings/settings-service'; import type { ExecFn } from '@main/core/utils/exec'; import { events } from '@main/lib/events'; import { log } from '@main/lib/logger'; import { buildAgentCommand } from './agent-command'; -import { resolveAutoApproveEnabled } from './auto-approve'; -import { SessionRespawnTracker } from './session-respawn'; const DEFAULT_COLS = 80; const DEFAULT_ROWS = 24; @@ -29,7 +26,7 @@ const MAX_RESPAWNS = 2; export class LocalConversationProvider implements ConversationProvider { private sessions = new Map(); - private readonly respawnTracker: SessionRespawnTracker; + private respawnCounts = new Map(); private readonly projectId: string; private readonly taskPath: string; private readonly taskId: string; @@ -62,10 +59,6 @@ export class LocalConversationProvider implements ConversationProvider { this.shellSetup = shellSetup; this.exec = exec; this.taskEnvVars = taskEnvVars; - this.respawnTracker = new SessionRespawnTracker({ - maxRespawns: MAX_RESPAWNS, - providerName: 'LocalConversationProvider', - }); } async startSession( @@ -87,15 +80,9 @@ export class LocalConversationProvider implements ConversationProvider { homedir: homedir(), }); - const taskSettings = await appSettingsService.get('tasks'); - const autoApprove = resolveAutoApproveEnabled({ - conversationAutoApprove: conversation.autoApprove, - autoApproveByDefault: taskSettings.autoApproveByDefault, - }); - const { command, args } = await buildAgentCommand({ providerId: conversation.providerId, - autoApprove, + autoApprove: conversation.autoApprove, sessionId: conversation.id, isResuming, initialPrompt, @@ -112,7 +99,7 @@ export class LocalConversationProvider implements ConversationProvider { cwd: this.taskPath, shellSetup: this.shellSetup, tmuxSessionName, - autoApprove, + autoApprove: conversation.autoApprove ?? false, resume: isResuming, }; @@ -161,22 +148,30 @@ export class LocalConversationProvider implements ConversationProvider { taskId: conversation.taskId, exitCode, }); - - if (!shouldRespawn || this.tmux) return; - - const decision = this.respawnTracker.evaluate(sessionId, isResuming); - if (!decision.shouldRespawn) return; - - setTimeout(() => { - this.startSession(conversation, initialSize, decision.resumeNext, initialPrompt).catch( - (e) => { + if (shouldRespawn && !this.tmux) { + const count = (this.respawnCounts.get(sessionId) ?? 0) + 1; + this.respawnCounts.set(sessionId, count); + + if (count > MAX_RESPAWNS && !isResuming) { + log.error('LocalConversationProvider: respawn limit reached, giving up', { + conversationId: conversation.id, + }); + this.respawnCounts.delete(sessionId); + return; + } + + const resumeNext = isResuming && count <= MAX_RESPAWNS; + if (count > MAX_RESPAWNS) this.respawnCounts.set(sessionId, 0); + + setTimeout(() => { + this.startSession(conversation, initialSize, resumeNext, initialPrompt).catch((e) => { log.error('LocalConversationProvider: respawn failed', { conversationId: conversation.id, error: String(e), }); - } - ); - }, 500); + }); + }, 500); + } }); ptySessionRegistry.register(sessionId, pty); diff --git a/src/main/core/conversations/impl/session-respawn.ts b/src/main/core/conversations/impl/session-respawn.ts deleted file mode 100644 index 46cd26dcc..000000000 --- a/src/main/core/conversations/impl/session-respawn.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { log } from '@main/lib/logger'; - -export interface RespawnState { - count: number; - maxRespawns: number; -} - -export interface RespawnDecision { - shouldRespawn: boolean; - resumeNext: boolean; - resetCount: boolean; -} - -export function evaluateRespawn(state: RespawnState, isResuming: boolean): RespawnDecision { - const nextCount = state.count + 1; - - if (nextCount > state.maxRespawns && !isResuming) { - return { shouldRespawn: false, resumeNext: false, resetCount: true }; - } - - const resumeNext = isResuming && nextCount <= state.maxRespawns; - const resetCount = nextCount > state.maxRespawns; - - return { shouldRespawn: true, resumeNext, resetCount }; -} - -export class SessionRespawnTracker { - private counts = new Map(); - private readonly maxRespawns: number; - private readonly providerName: string; - - constructor(options: { maxRespawns: number; providerName: string }) { - this.maxRespawns = options.maxRespawns; - this.providerName = options.providerName; - } - - increment(sessionId: string): RespawnState { - const count = (this.counts.get(sessionId) ?? 0) + 1; - this.counts.set(sessionId, count); - return { count, maxRespawns: this.maxRespawns }; - } - - evaluate(sessionId: string, isResuming: boolean): RespawnDecision { - const state = this.increment(sessionId); - const decision = evaluateRespawn(state, isResuming); - - if (!decision.shouldRespawn) { - log.error(`${this.providerName}: respawn limit reached, giving up`, { - sessionId, - }); - this.counts.delete(sessionId); - } else if (decision.resetCount) { - this.counts.set(sessionId, 0); - } - - return decision; - } - - delete(sessionId: string): void { - this.counts.delete(sessionId); - } - - clear(): void { - this.counts.clear(); - } -} diff --git a/src/main/core/conversations/impl/ssh-conversation.ts b/src/main/core/conversations/impl/ssh-conversation.ts index e47c49b0a..be8f07917 100644 --- a/src/main/core/conversations/impl/ssh-conversation.ts +++ b/src/main/core/conversations/impl/ssh-conversation.ts @@ -11,14 +11,11 @@ import { ptySessionRegistry } from '@main/core/pty/pty-session-registry'; import { resolveSshCommand } from '@main/core/pty/spawn-utils'; import { openSsh2Pty } from '@main/core/pty/ssh2-pty'; import { killTmuxSession, makeTmuxSessionName } from '@main/core/pty/tmux-session-name'; -import { appSettingsService } from '@main/core/settings/settings-service'; import type { SshClientProxy } from '@main/core/ssh/ssh-client-proxy'; import type { ExecFn } from '@main/core/utils/exec'; import { events } from '@main/lib/events'; import { log } from '@main/lib/logger'; import { buildAgentCommand } from './agent-command'; -import { resolveAutoApproveEnabled } from './auto-approve'; -import { SessionRespawnTracker } from './session-respawn'; const DEFAULT_COLS = 80; const DEFAULT_ROWS = 24; @@ -26,7 +23,7 @@ const MAX_RESPAWNS = 2; export class SshConversationProvider implements ConversationProvider { private sessions = new Map(); - private readonly respawnTracker: SessionRespawnTracker; + private respawnCounts = new Map(); private readonly projectId: string; private readonly taskPath: string; private readonly taskId: string; @@ -63,10 +60,6 @@ export class SshConversationProvider implements ConversationProvider { this.shellSetup = shellSetup; this.exec = exec; this.proxy = proxy; - this.respawnTracker = new SessionRespawnTracker({ - maxRespawns: MAX_RESPAWNS, - providerName: 'SshConversationProvider', - }); } async startSession( @@ -90,15 +83,9 @@ export class SshConversationProvider implements ConversationProvider { remoteFs: new SshFileSystem(this.proxy, '/'), }); - const taskSettings = await appSettingsService.get('tasks'); - const autoApprove = resolveAutoApproveEnabled({ - conversationAutoApprove: conversation.autoApprove, - autoApproveByDefault: taskSettings.autoApproveByDefault, - }); - const { command, args } = await buildAgentCommand({ providerId: conversation.providerId, - autoApprove, + autoApprove: conversation.autoApprove, sessionId: conversation.id, isResuming, initialPrompt, @@ -115,7 +102,7 @@ export class SshConversationProvider implements ConversationProvider { cwd: this.taskPath, shellSetup: this.shellSetup, tmuxSessionName, - autoApprove, + autoApprove: conversation.autoApprove ?? false, resume: isResuming, }; @@ -158,22 +145,30 @@ export class SshConversationProvider implements ConversationProvider { taskId: conversation.taskId, exitCode, }); - - if (!shouldRespawn || this.tmux) return; - - const decision = this.respawnTracker.evaluate(sessionId, isResuming); - if (!decision.shouldRespawn) return; - - setTimeout(() => { - this.startSession(conversation, initialSize, decision.resumeNext, initialPrompt).catch( - (e) => { + if (shouldRespawn && !this.tmux) { + const count = (this.respawnCounts.get(sessionId) ?? 0) + 1; + this.respawnCounts.set(sessionId, count); + + if (count > MAX_RESPAWNS && !isResuming) { + log.error('SshConversationProvider: respawn limit reached, giving up', { + conversationId: conversation.id, + }); + this.respawnCounts.delete(sessionId); + return; + } + + const resumeNext = isResuming && count <= MAX_RESPAWNS; + if (count > MAX_RESPAWNS) this.respawnCounts.set(sessionId, 0); + + setTimeout(() => { + this.startSession(conversation, initialSize, resumeNext, initialPrompt).catch((e) => { log.error('SshConversationProvider: respawn failed', { conversationId: conversation.id, error: String(e), }); - } - ); - }, 500); + }); + }, 500); + } }); ptySessionRegistry.register(sessionId, pty); diff --git a/src/main/core/pty/pty-env.ts b/src/main/core/pty/pty-env.ts index 2d942d27e..bb544d565 100644 --- a/src/main/core/pty/pty-env.ts +++ b/src/main/core/pty/pty-env.ts @@ -15,7 +15,6 @@ export const AGENT_ENV_VARS = [ 'AZURE_OPENAI_API_ENDPOINT', 'AZURE_OPENAI_API_KEY', 'AZURE_OPENAI_KEY', - 'CLAUDE_CONFIG_DIR', 'CODEBUFF_API_KEY', 'COPILOT_CLI_TOKEN', 'CURSOR_API_KEY', @@ -57,37 +56,29 @@ function getDisplayEnv(): Record { return env; } -function envOrDefault(key: string, defaultValue: string): string { - return process.env[key] || defaultValue; -} - function getWindowsEssentialEnv(resolvedPath: string): Record { const home = os.homedir(); - const temp = process.env.TEMP || process.env.TMP || ''; - return { PATH: resolvedPath, - PATHEXT: envOrDefault('PATHEXT', '.COM;.EXE;.BAT;.CMD;.VBS;.VBE;.JS;.JSE;.WSF;.WSH;.MSC'), - SystemRoot: envOrDefault('SystemRoot', 'C:\\Windows'), - ComSpec: envOrDefault('ComSpec', 'C:\\Windows\\System32\\cmd.exe'), - TEMP: temp, - TMP: temp, - USERPROFILE: envOrDefault('USERPROFILE', home), - APPDATA: envOrDefault('APPDATA', ''), - LOCALAPPDATA: envOrDefault('LOCALAPPDATA', ''), - HOMEDRIVE: envOrDefault('HOMEDRIVE', ''), - HOMEPATH: envOrDefault('HOMEPATH', ''), + PATHEXT: process.env.PATHEXT || '.COM;.EXE;.BAT;.CMD;.VBS;.VBE;.JS;.JSE;.WSF;.WSH;.MSC', + SystemRoot: process.env.SystemRoot || 'C:\\Windows', + ComSpec: process.env.ComSpec || 'C:\\Windows\\System32\\cmd.exe', + TEMP: process.env.TEMP || process.env.TMP || '', + TMP: process.env.TMP || process.env.TEMP || '', + USERPROFILE: process.env.USERPROFILE || home, + APPDATA: process.env.APPDATA || '', + LOCALAPPDATA: process.env.LOCALAPPDATA || '', + HOMEDRIVE: process.env.HOMEDRIVE || '', + HOMEPATH: process.env.HOMEPATH || '', USERNAME: process.env.USERNAME || os.userInfo().username, - ProgramFiles: envOrDefault('ProgramFiles', 'C:\\Program Files'), - 'ProgramFiles(x86)': envOrDefault('ProgramFiles(x86)', 'C:\\Program Files (x86)'), - ProgramData: envOrDefault('ProgramData', 'C:\\ProgramData'), - CommonProgramFiles: envOrDefault('CommonProgramFiles', 'C:\\Program Files\\Common Files'), - 'CommonProgramFiles(x86)': envOrDefault( - 'CommonProgramFiles(x86)', - 'C:\\Program Files (x86)\\Common Files' - ), - ProgramW6432: envOrDefault('ProgramW6432', 'C:\\Program Files'), - CommonProgramW6432: envOrDefault('CommonProgramW6432', 'C:\\Program Files\\Common Files'), + ProgramFiles: process.env.ProgramFiles || 'C:\\Program Files', + 'ProgramFiles(x86)': process.env['ProgramFiles(x86)'] || 'C:\\Program Files (x86)', + ProgramData: process.env.ProgramData || 'C:\\ProgramData', + CommonProgramFiles: process.env.CommonProgramFiles || 'C:\\Program Files\\Common Files', + 'CommonProgramFiles(x86)': + process.env['CommonProgramFiles(x86)'] || 'C:\\Program Files (x86)\\Common Files', + ProgramW6432: process.env.ProgramW6432 || 'C:\\Program Files', + CommonProgramW6432: process.env.CommonProgramW6432 || 'C:\\Program Files\\Common Files', }; } From af5abef3f6063a0b483f3edff29189ac2d5b626c Mon Sep 17 00:00:00 2001 From: Jan Burzinski <156842394+janburzinski@users.noreply.github.com> Date: Wed, 8 Apr 2026 16:15:19 +0200 Subject: [PATCH 03/14] feat: add automations and integrations infrastructure - Add database schema for automations and integrations - Add GitLab service for repository integration - Add new renderer views for automations - Add hooks for automation triggers and integration status - Add preload bindings for new IPC methods - Update sidebar and app routing for new features --- src/assets/images/Sentry.svg | 1 + src/main/core/agent-hooks/hook-config.ts | 1 - .../core/automations/automations-service.ts | 718 ++++++++++++++++++ src/main/core/automations/controller.ts | 112 +++ src/main/core/gitlab/gitlab-service.ts | 94 +++ src/main/core/integrations/controller.ts | 31 + src/main/db/initialize.ts | 33 + src/main/db/schema.ts | 82 ++ src/main/index.ts | 4 + src/main/rpc.ts | 4 + src/preload/index.ts | 20 + src/renderer/App.tsx | 4 +- src/renderer/components/AgentLogo.tsx | 46 ++ .../components/TaskStatusIndicator.tsx | 13 + .../automations/AutomationInlineCreate.tsx | 623 +++++++++++++++ .../components/automations/AutomationRow.tsx | 234 ++++++ .../automations/AutomationRunningTasks.tsx | 3 + .../automations/AutomationsView.tsx | 306 ++++++++ .../automations/ExampleAutomations.tsx | 364 +++++++++ .../components/automations/RunLogsModal.tsx | 135 ++++ .../components/automations/useAutomations.ts | 163 ++++ .../automations/useRunningAutomations.ts | 123 +++ src/renderer/components/automations/utils.ts | 147 ++++ .../components/sidebar/left-sidebar.tsx | 11 +- .../contexts/ProjectManagementProvider.tsx | 16 + .../contexts/TaskManagementContext.tsx | 13 + src/renderer/core/view/registry.ts | 2 + src/renderer/hooks/useAutomationTrigger.ts | 3 + src/renderer/hooks/useIntegrationStatusMap.ts | 27 + src/renderer/hooks/useTaskStatus.ts | 8 + src/renderer/lib/rpc.ts | 1 + src/renderer/types/app.ts | 13 + src/renderer/types/chat.ts | 11 + src/renderer/types/electron-api.d.ts | 18 + src/renderer/views/automations-view.tsx | 15 + src/shared/automations/types.ts | 133 ++++ src/shared/integrations/types.ts | 26 + src/shared/providers/registry.ts | 10 + 38 files changed, 3564 insertions(+), 4 deletions(-) create mode 100644 src/assets/images/Sentry.svg create mode 100644 src/main/core/automations/automations-service.ts create mode 100644 src/main/core/automations/controller.ts create mode 100644 src/main/core/integrations/controller.ts create mode 100644 src/renderer/components/AgentLogo.tsx create mode 100644 src/renderer/components/TaskStatusIndicator.tsx create mode 100644 src/renderer/components/automations/AutomationInlineCreate.tsx create mode 100644 src/renderer/components/automations/AutomationRow.tsx create mode 100644 src/renderer/components/automations/AutomationRunningTasks.tsx create mode 100644 src/renderer/components/automations/AutomationsView.tsx create mode 100644 src/renderer/components/automations/ExampleAutomations.tsx create mode 100644 src/renderer/components/automations/RunLogsModal.tsx create mode 100644 src/renderer/components/automations/useAutomations.ts create mode 100644 src/renderer/components/automations/useRunningAutomations.ts create mode 100644 src/renderer/components/automations/utils.ts create mode 100644 src/renderer/contexts/ProjectManagementProvider.tsx create mode 100644 src/renderer/contexts/TaskManagementContext.tsx create mode 100644 src/renderer/hooks/useAutomationTrigger.ts create mode 100644 src/renderer/hooks/useIntegrationStatusMap.ts create mode 100644 src/renderer/hooks/useTaskStatus.ts create mode 100644 src/renderer/lib/rpc.ts create mode 100644 src/renderer/types/app.ts create mode 100644 src/renderer/types/chat.ts create mode 100644 src/renderer/views/automations-view.tsx create mode 100644 src/shared/automations/types.ts create mode 100644 src/shared/integrations/types.ts create mode 100644 src/shared/providers/registry.ts diff --git a/src/assets/images/Sentry.svg b/src/assets/images/Sentry.svg new file mode 100644 index 000000000..9b1b7fe0d --- /dev/null +++ b/src/assets/images/Sentry.svg @@ -0,0 +1 @@ +Sentry diff --git a/src/main/core/agent-hooks/hook-config.ts b/src/main/core/agent-hooks/hook-config.ts index 25b1336d3..c0f3a0a03 100644 --- a/src/main/core/agent-hooks/hook-config.ts +++ b/src/main/core/agent-hooks/hook-config.ts @@ -3,7 +3,6 @@ import { resolveCommandPath } from '@main/core/dependencies/probe'; import type { FileSystemProvider } from '@main/core/fs/types'; import type { ExecFn } from '@main/core/utils/exec'; import { log } from '@main/lib/logger'; -import { ExecFn } from '../utils/exec'; const EMDASH_MARKER = 'EMDASH_HOOK_PORT'; diff --git a/src/main/core/automations/automations-service.ts b/src/main/core/automations/automations-service.ts new file mode 100644 index 000000000..6bc108499 --- /dev/null +++ b/src/main/core/automations/automations-service.ts @@ -0,0 +1,718 @@ +import crypto from 'node:crypto'; +import { and, desc, eq, lte } from 'drizzle-orm'; +import { isValidProviderId, type AgentProviderId } from '@shared/agent-provider-registry'; +import type { + Automation, + AutomationRunLog, + AutomationSchedule, + CreateAutomationInput, + DayOfWeek, + TriggerType, + UpdateAutomationInput, +} from '@shared/automations/types'; +import { forgejoService } from '@main/core/forgejo/forgejo-service'; +import { issueService } from '@main/core/github/services/issue-service'; +import { gitlabService } from '@main/core/gitlab/gitlab-service'; +import JiraService from '@main/core/jira/JiraService'; +import { linearService } from '@main/core/linear/LinearService'; +import { plainService } from '@main/core/plain/plain-service'; +import { prService } from '@main/core/pull-requests/pr-service'; +import { createTask } from '@main/core/tasks/createTask'; +import { db, sqlite } from '@main/db/client'; +import { automationRunLogs, automations, projects } from '@main/db/schema'; +import { log } from '@main/lib/logger'; + +type RawEvent = { + id: string; + title: string; + url?: string; + labels?: string[]; + assignee?: string; + branch?: string; +}; + +const DAY_ORDER: DayOfWeek[] = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat']; + +function computeNextRun(schedule: AutomationSchedule, fromDate = new Date()): string { + const now = new Date(fromDate); + const next = new Date(now); + const hour = schedule.hour ?? 0; + const minute = schedule.minute ?? 0; + + switch (schedule.type) { + case 'hourly': + next.setMinutes(minute, 0, 0); + if (next <= now) next.setHours(next.getHours() + 1); + break; + case 'daily': + next.setHours(hour, minute, 0, 0); + if (next <= now) next.setDate(next.getDate() + 1); + break; + case 'weekly': { + const target = DAY_ORDER.indexOf(schedule.dayOfWeek ?? 'mon'); + const current = next.getDay(); + let delta = target - current; + if (delta < 0) delta += 7; + next.setDate(next.getDate() + delta); + next.setHours(hour, minute, 0, 0); + if (next <= now) next.setDate(next.getDate() + 7); + break; + } + case 'monthly': { + const desiredDay = schedule.dayOfMonth ?? 1; + const monthDays = new Date(next.getFullYear(), next.getMonth() + 1, 0).getDate(); + next.setDate(Math.min(desiredDay, monthDays)); + next.setHours(hour, minute, 0, 0); + if (next <= now) { + next.setMonth(next.getMonth() + 1); + const nextMonthDays = new Date(next.getFullYear(), next.getMonth() + 1, 0).getDate(); + next.setDate(Math.min(desiredDay, nextMonthDays)); + } + break; + } + } + + return next.toISOString(); +} + +function parseNameWithOwner(remote?: string | null): string | null { + if (!remote) return null; + const ssh = /^git@[^:]+:(.+?)(?:\.git)?$/.exec(remote); + if (ssh?.[1]) return ssh[1]; + const https = /^https?:\/\/[^/]+\/(.+?)(?:\.git)?$/.exec(remote); + if (https?.[1]) return https[1]; + return null; +} + +function mapAutomation(row: typeof automations.$inferSelect): Automation { + return { + id: row.id, + projectId: row.projectId, + projectName: row.projectName, + name: row.name, + prompt: row.prompt, + agentId: row.agentId, + mode: row.mode === 'trigger' ? 'trigger' : 'schedule', + schedule: JSON.parse(row.schedule) as AutomationSchedule, + triggerType: (row.triggerType as TriggerType | null) ?? null, + triggerConfig: row.triggerConfig ? JSON.parse(row.triggerConfig) : null, + useWorktree: row.useWorktree === 1, + status: row.status as Automation['status'], + lastRunAt: row.lastRunAt, + nextRunAt: row.nextRunAt, + runCount: row.runCount, + lastRunResult: (row.lastRunResult as 'success' | 'failure' | null) ?? null, + lastRunError: row.lastRunError, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }; +} + +function mapRun(row: typeof automationRunLogs.$inferSelect): AutomationRunLog { + return { + id: row.id, + automationId: row.automationId, + startedAt: row.startedAt, + finishedAt: row.finishedAt, + status: row.status as AutomationRunLog['status'], + error: row.error, + taskId: row.taskId, + }; +} + +function slug(value: string): string { + return value + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/(^-|-$)/g, '') + .slice(0, 30); +} + +export class AutomationsService { + private scheduleTimer: NodeJS.Timeout | null = null; + private triggerTimer: NodeJS.Timeout | null = null; + private started = false; + private initialized = false; + private inFlight = new Set(); + private seenEvents = new Map>(); + + private async ensureTables(): Promise { + if (this.initialized) return; + sqlite.exec(` + CREATE TABLE IF NOT EXISTS automations ( + id text PRIMARY KEY NOT NULL, + project_id text NOT NULL, + project_name text DEFAULT '' NOT NULL, + name text NOT NULL, + prompt text NOT NULL, + agent_id text NOT NULL, + mode text DEFAULT 'schedule' NOT NULL, + schedule text NOT NULL, + trigger_type text, + trigger_config text, + use_worktree integer DEFAULT 1 NOT NULL, + status text DEFAULT 'active' NOT NULL, + last_run_at text, + next_run_at text, + run_count integer DEFAULT 0 NOT NULL, + last_run_result text, + last_run_error text, + created_at text DEFAULT CURRENT_TIMESTAMP NOT NULL, + updated_at text DEFAULT CURRENT_TIMESTAMP NOT NULL, + FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS idx_automations_project_id ON automations (project_id); + CREATE INDEX IF NOT EXISTS idx_automations_status_next_run ON automations (status, next_run_at); + + CREATE TABLE IF NOT EXISTS automation_run_logs ( + id text PRIMARY KEY NOT NULL, + automation_id text NOT NULL, + started_at text NOT NULL, + finished_at text, + status text NOT NULL, + error text, + task_id text, + FOREIGN KEY (automation_id) REFERENCES automations(id) ON DELETE CASCADE, + FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE SET NULL + ); + CREATE INDEX IF NOT EXISTS idx_automation_run_logs_automation_started ON automation_run_logs (automation_id, started_at); + `); + + // Backfill schema for users with older local tables. + const hasColumn = (table: string, column: string): boolean => { + const rows = sqlite.prepare(`PRAGMA table_info(${table})`).all() as Array<{ name: string }>; + return rows.some((r) => r.name === column); + }; + + const addColumnIfMissing = (table: string, column: string, ddl: string): void => { + if (!hasColumn(table, column)) { + sqlite.exec(`ALTER TABLE ${table} ADD COLUMN ${ddl}`); + } + }; + + addColumnIfMissing('automations', 'mode', `mode text DEFAULT 'schedule' NOT NULL`); + addColumnIfMissing('automations', 'trigger_type', 'trigger_type text'); + addColumnIfMissing('automations', 'trigger_config', 'trigger_config text'); + addColumnIfMissing('automations', 'use_worktree', 'use_worktree integer DEFAULT 1 NOT NULL'); + addColumnIfMissing('automations', 'last_run_result', 'last_run_result text'); + addColumnIfMissing('automations', 'last_run_error', 'last_run_error text'); + + this.initialized = true; + } + + async list(): Promise { + await this.ensureTables(); + const rows = await db.select().from(automations).orderBy(desc(automations.updatedAt)); + return rows.map(mapAutomation); + } + + async get(id: string): Promise { + await this.ensureTables(); + const rows = await db.select().from(automations).where(eq(automations.id, id)).limit(1); + return rows[0] ? mapAutomation(rows[0]) : null; + } + + async create(input: CreateAutomationInput): Promise { + await this.ensureTables(); + const now = new Date().toISOString(); + const id = `auto_${crypto.randomUUID().replace(/-/g, '').slice(0, 16)}`; + const mode = input.mode ?? 'schedule'; + const nextRunAt = mode === 'schedule' ? computeNextRun(input.schedule) : null; + + await db.insert(automations).values({ + id, + name: input.name, + projectId: input.projectId, + projectName: input.projectName ?? '', + prompt: input.prompt, + agentId: input.agentId, + mode, + schedule: JSON.stringify(input.schedule), + triggerType: input.triggerType ?? null, + triggerConfig: input.triggerConfig ? JSON.stringify(input.triggerConfig) : null, + useWorktree: input.useWorktree === false ? 0 : 1, + status: 'active', + nextRunAt, + createdAt: now, + updatedAt: now, + }); + + const created = await this.get(id); + if (!created) throw new Error('Failed to load created automation'); + + if (created.mode === 'trigger') { + const events = await this.fetchRawEvents(created); + this.seenEvents.set(created.id, new Set(events.map((e) => e.id))); + } + + return created; + } + + async update(input: UpdateAutomationInput): Promise { + await this.ensureTables(); + const existing = await this.get(input.id); + if (!existing) throw new Error('Automation not found'); + + const mode = input.mode ?? existing.mode; + const schedule = input.schedule ?? existing.schedule; + const nextRunAt = mode === 'schedule' ? computeNextRun(schedule) : null; + + await db + .update(automations) + .set({ + name: input.name ?? existing.name, + projectId: input.projectId ?? existing.projectId, + projectName: input.projectName ?? existing.projectName, + prompt: input.prompt ?? existing.prompt, + agentId: input.agentId ?? existing.agentId, + mode, + schedule: JSON.stringify(schedule), + triggerType: + input.triggerType === undefined ? existing.triggerType : (input.triggerType ?? null), + triggerConfig: + input.triggerConfig === undefined + ? existing.triggerConfig + ? JSON.stringify(existing.triggerConfig) + : null + : input.triggerConfig + ? JSON.stringify(input.triggerConfig) + : null, + status: input.status ?? existing.status, + useWorktree: + input.useWorktree === undefined + ? existing.useWorktree + ? 1 + : 0 + : input.useWorktree + ? 1 + : 0, + nextRunAt, + updatedAt: new Date().toISOString(), + }) + .where(eq(automations.id, input.id)); + + const updated = await this.get(input.id); + if (!updated) throw new Error('Failed to load updated automation'); + return updated; + } + + async delete(id: string): Promise { + await this.ensureTables(); + await db.delete(automations).where(eq(automations.id, id)); + this.seenEvents.delete(id); + return true; + } + + async toggleStatus(id: string): Promise { + const existing = await this.get(id); + if (!existing) throw new Error('Automation not found'); + return this.update({ id, status: existing.status === 'active' ? 'paused' : 'active' }); + } + + async getRunLogs(automationId: string, limit = 100): Promise { + await this.ensureTables(); + const rows = await db + .select() + .from(automationRunLogs) + .where(eq(automationRunLogs.automationId, automationId)) + .orderBy(desc(automationRunLogs.startedAt)) + .limit(Math.min(Math.max(limit, 1), 500)); + return rows.map(mapRun); + } + + async updateRunLog( + runId: string, + update: Partial> + ): Promise { + await this.ensureTables(); + await db + .update(automationRunLogs) + .set({ + status: update.status, + error: update.error, + finishedAt: update.finishedAt, + taskId: update.taskId, + }) + .where(eq(automationRunLogs.id, runId)); + } + + async setLastRunResult( + automationId: string, + result: 'success' | 'failure', + error?: string + ): Promise { + await db + .update(automations) + .set({ + lastRunResult: result, + lastRunError: error ?? null, + updatedAt: new Date().toISOString(), + }) + .where(eq(automations.id, automationId)); + } + + async createManualRunLog(automationId: string): Promise { + const runLogId = `run_${crypto.randomUUID().replace(/-/g, '').slice(0, 16)}`; + const now = new Date().toISOString(); + await db.insert(automationRunLogs).values({ + id: runLogId, + automationId, + startedAt: now, + status: 'running', + taskId: null, + error: null, + finishedAt: null, + }); + return runLogId; + } + + async triggerNow(id: string): Promise { + const automation = await this.get(id); + if (!automation) throw new Error('Automation not found'); + if (automation.mode === 'trigger') { + throw new Error('Run now is only available for schedule automations'); + } + await this.runAutomation(automation); + } + + async reconcileMissedRuns(): Promise { + await this.ensureTables(); + const nowIso = new Date().toISOString(); + await db + .update(automationRunLogs) + .set({ status: 'failure', error: 'Interrupted (app closed)', finishedAt: nowIso }) + .where(eq(automationRunLogs.status, 'running')); + } + + start(): void { + if (this.started) return; + this.started = true; + this.scheduleTimer = setInterval(() => { + void this.processScheduledAutomations().catch((error) => { + log.error('[Automations] Scheduled cycle failed:', error); + }); + }, 30_000); + this.triggerTimer = setInterval(() => { + void this.processTriggerAutomations().catch((error) => { + log.error('[Automations] Trigger cycle failed:', error); + }); + }, 60_000); + void this.processScheduledAutomations().catch((error) => { + log.error('[Automations] Initial scheduled cycle failed:', error); + }); + void this.processTriggerAutomations().catch((error) => { + log.error('[Automations] Initial trigger cycle failed:', error); + }); + } + + stop(): void { + this.started = false; + if (this.scheduleTimer) clearInterval(this.scheduleTimer); + if (this.triggerTimer) clearInterval(this.triggerTimer); + this.scheduleTimer = null; + this.triggerTimer = null; + } + + private async processScheduledAutomations(): Promise { + await this.ensureTables(); + const now = new Date(); + const nowIso = now.toISOString(); + const due = await db + .select() + .from(automations) + .where( + and( + eq(automations.status, 'active'), + eq(automations.mode, 'schedule'), + lte(automations.nextRunAt, nowIso) + ) + ); + + for (const row of due) { + const automation = mapAutomation(row); + if (this.inFlight.has(automation.id)) continue; + void this.runAutomation(automation); + } + } + + private async processTriggerAutomations(): Promise { + await this.ensureTables(); + const rows = await db + .select() + .from(automations) + .where(and(eq(automations.status, 'active'), eq(automations.mode, 'trigger'))); + + for (const row of rows) { + const automation = mapAutomation(row); + const events = await this.fetchRawEvents(automation); + const seen = this.seenEvents.get(automation.id) ?? new Set(); + if (!this.seenEvents.has(automation.id)) this.seenEvents.set(automation.id, seen); + + const fresh = events.filter( + (event) => !seen.has(event.id) && this.matchesConfig(automation, event) + ); + for (const event of fresh.slice(0, 3)) { + seen.add(event.id); + if (!this.inFlight.has(automation.id)) { + void this.runAutomation(automation, event); + } + } + for (const event of events) seen.add(event.id); + } + } + + private matchesConfig(automation: Automation, event: RawEvent): boolean { + const config = automation.triggerConfig; + if (!config) return true; + + if (config.labelFilter?.length) { + const labels = event.labels ?? []; + const match = config.labelFilter.some((wanted) => + labels.some((candidate) => candidate.toLowerCase() === wanted.toLowerCase()) + ); + if (!match) return false; + } + + if (config.assigneeFilter) { + if (!event.assignee) return false; + if (event.assignee.toLowerCase() !== config.assigneeFilter.toLowerCase()) return false; + } + + if (config.branchFilter) { + if (!event.branch) return false; + if (config.branchFilter.includes('*')) { + const escaped = config.branchFilter + .replace(/[.+?^${}()|[\]\\]/g, '\\$&') + .replace(/\*/g, '.*'); + if (!new RegExp(`^${escaped}$`).test(event.branch)) return false; + } else if (event.branch !== config.branchFilter) { + return false; + } + } + + return true; + } + + private async fetchRawEvents(automation: Automation): Promise { + if (!automation.triggerType) return []; + + const project = await db.query.projects.findFirst({ + where: eq(projects.id, automation.projectId), + }); + if (!project) return []; + + switch (automation.triggerType) { + case 'github_issue': + return this.fetchGitHubIssues(project.gitRemote); + case 'github_pr': + return this.fetchGitHubPullRequests(project.id, project.gitRemote); + case 'linear_issue': + return this.fetchLinearIssues(); + case 'jira_issue': + return this.fetchJiraIssues(); + case 'gitlab_issue': + return this.fetchGitLabIssues(project.path); + case 'gitlab_mr': + return this.fetchGitLabMergeRequests(project.path); + case 'forgejo_issue': + return this.fetchForgejoIssues(project.path); + case 'plain_thread': + return this.fetchPlainThreads(); + case 'sentry_issue': + return []; + default: + return []; + } + } + + private async fetchGitHubIssues(remote: string | null): Promise { + const nameWithOwner = parseNameWithOwner(remote); + if (!nameWithOwner) return []; + const issues = await issueService.listIssues(nameWithOwner, 30); + return issues.map((issue) => ({ + id: `gh-issue-${issue.number}`, + title: issue.title, + url: issue.url, + labels: issue.labels.map((l) => l.name), + assignee: issue.assignees[0]?.login, + })); + } + + private async fetchGitHubPullRequests( + projectId: string, + remote: string | null + ): Promise { + const nameWithOwner = parseNameWithOwner(remote); + if (!nameWithOwner) return []; + const { prs } = await prService.listPullRequests(projectId, nameWithOwner); + return prs.slice(0, 30).map((pr) => ({ + id: `gh-pr-${pr.id}`, + title: pr.title, + url: pr.url, + labels: pr.labels.map((l) => l.name), + assignee: pr.assignees[0]?.userName, + branch: pr.metadata.headRefName, + })); + } + + private async fetchLinearIssues(): Promise { + const status = await linearService.checkConnection(); + if (!status.connected) return []; + const issues = await linearService.initialFetch(30); + return issues.map((issue) => ({ + id: `linear-${issue.id}`, + title: issue.title, + url: issue.url, + assignee: issue.assignee?.name ?? issue.assignee?.displayName ?? undefined, + })); + } + + private async fetchJiraIssues(): Promise { + const jira = new JiraService(); + const status = await jira.checkConnection(); + if (!status.connected) return []; + const issues = await jira.initialFetch(30); + return issues.map((issue) => ({ + id: `jira-${issue.id}`, + title: issue.summary, + url: issue.url, + assignee: issue.assignee?.name, + })); + } + + private async fetchGitLabIssues(projectPath: string): Promise { + const status = await gitlabService.checkConnection(); + if (!status.connected) return []; + const issues = await gitlabService.initialFetch(projectPath, 30); + return issues.map((issue) => ({ + id: `gitlab-issue-${issue.id}`, + title: issue.title, + url: issue.webUrl ?? undefined, + labels: issue.labels, + assignee: issue.assignee?.username, + })); + } + + private async fetchGitLabMergeRequests(projectPath: string): Promise { + const status = await gitlabService.checkConnection(); + if (!status.connected) return []; + const mrs = await gitlabService.initialFetchMergeRequests(projectPath, 30); + return mrs.map((mr) => ({ + id: `gitlab-mr-${mr.id}`, + title: mr.title, + url: mr.webUrl ?? undefined, + labels: mr.labels, + branch: mr.sourceBranch ?? undefined, + assignee: mr.assignee?.username, + })); + } + + private async fetchForgejoIssues(projectPath: string): Promise { + const status = await forgejoService.checkConnection(); + if (!status.connected) return []; + const issues = await forgejoService.initialFetch(projectPath, 30); + return issues.map((issue) => ({ + id: `forgejo-${issue.id}`, + title: issue.title, + url: issue.htmlUrl ?? undefined, + labels: issue.labels, + assignee: issue.assignee?.username, + })); + } + + private async fetchPlainThreads(): Promise { + const status = await plainService.checkConnection(); + if (!status.connected) return []; + const threads = await plainService.initialFetch(30); + return threads.map((thread) => ({ + id: `plain-${thread.id}`, + title: thread.title, + url: thread.url ?? undefined, + })); + } + + async runAutomation(automation: Automation, triggerEvent?: RawEvent): Promise { + if (this.inFlight.has(automation.id)) return; + this.inFlight.add(automation.id); + + const runId = await this.createManualRunLog(automation.id); + const nowIso = new Date().toISOString(); + + try { + const project = await db.query.projects.findFirst({ + where: eq(projects.id, automation.projectId), + }); + if (!project) throw new Error(`Project not found: ${automation.projectId}`); + if (!isValidProviderId(automation.agentId)) + throw new Error(`Invalid agent: ${automation.agentId}`); + + const remote = project.gitRemote ? 'origin' : 'origin'; + const baseBranch = project.baseRef || 'main'; + const branchBase = `${slug(automation.name)}-${new Date().toISOString().slice(0, 10)}`; + + const taskId = crypto.randomUUID(); + const taskResult = await createTask({ + id: taskId, + projectId: automation.projectId, + name: triggerEvent ? `${automation.name}: ${triggerEvent.title}` : automation.name, + sourceBranch: { branch: baseBranch, remote }, + strategy: automation.useWorktree + ? { kind: 'new-branch', taskBranch: branchBase } + : { kind: 'no-worktree' }, + initialConversation: { + id: crypto.randomUUID(), + projectId: automation.projectId, + taskId, + provider: automation.agentId as AgentProviderId, + title: automation.name, + initialPrompt: triggerEvent + ? `${automation.prompt}\n\nTrigger context:\n- ${triggerEvent.title}\n${triggerEvent.url ?? ''}` + : automation.prompt, + }, + }); + + if (!taskResult.success) { + throw new Error(taskResult.error.type); + } + + await this.updateRunLog(runId, { + status: 'success', + finishedAt: new Date().toISOString(), + taskId: taskResult.data.id, + }); + + await db + .update(automations) + .set({ + runCount: automation.runCount + 1, + lastRunAt: nowIso, + nextRunAt: automation.mode === 'schedule' ? computeNextRun(automation.schedule) : null, + lastRunResult: 'success', + lastRunError: null, + updatedAt: nowIso, + }) + .where(eq(automations.id, automation.id)); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + log.error(`[Automations] Run failed (${automation.id}):`, error); + await this.updateRunLog(runId, { + status: 'failure', + finishedAt: new Date().toISOString(), + error: message, + }); + await db + .update(automations) + .set({ + nextRunAt: automation.mode === 'schedule' ? computeNextRun(automation.schedule) : null, + lastRunResult: 'failure', + lastRunError: message, + updatedAt: new Date().toISOString(), + }) + .where(eq(automations.id, automation.id)); + } finally { + this.inFlight.delete(automation.id); + } + } +} + +export const automationsService = new AutomationsService(); diff --git a/src/main/core/automations/controller.ts b/src/main/core/automations/controller.ts new file mode 100644 index 000000000..8114ed1cd --- /dev/null +++ b/src/main/core/automations/controller.ts @@ -0,0 +1,112 @@ +import { eq } from 'drizzle-orm'; +import type { CreateAutomationInput, UpdateAutomationInput } from '@shared/automations/types'; +import { createRPCController } from '@shared/ipc/rpc'; +import { db } from '@main/db/client'; +import { projects } from '@main/db/schema'; +import { log } from '@main/lib/logger'; +import { automationsService } from './automations-service'; + +function formatError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +export async function startAutomationsRuntime(): Promise { + try { + await automationsService.reconcileMissedRuns(); + automationsService.start(); + } catch (error) { + log.error('Failed to start automations runtime:', error); + } +} + +export function stopAutomationsRuntime(): void { + automationsService.stop(); +} + +export const automationsController = createRPCController({ + list: async () => { + try { + return { success: true, data: await automationsService.list() }; + } catch (error) { + return { success: false, error: formatError(error) }; + } + }, + + get: async (id: string) => { + try { + return { success: true, data: await automationsService.get(id) }; + } catch (error) { + return { success: false, error: formatError(error) }; + } + }, + + create: async (input: CreateAutomationInput) => { + try { + const project = await db.query.projects.findFirst({ + where: (p, { eq }) => eq(p.id, input.projectId), + }); + if (!project) return { success: false, error: `Unknown projectId: ${input.projectId}` }; + + const created = await automationsService.create({ ...input, projectName: project.name }); + return { success: true, data: created }; + } catch (error) { + return { success: false, error: formatError(error) }; + } + }, + + update: async (input: UpdateAutomationInput) => { + try { + let projectName = input.projectName; + if (input.projectId) { + const project = await db + .select({ name: projects.name }) + .from(projects) + .where(eq(projects.id, input.projectId)) + .limit(1); + if (!project[0]) return { success: false, error: `Unknown projectId: ${input.projectId}` }; + projectName = project[0].name; + } + + const updated = await automationsService.update({ ...input, projectName }); + return { success: true, data: updated }; + } catch (error) { + return { success: false, error: formatError(error) }; + } + }, + + delete: async (id: string) => { + try { + return { success: true, data: await automationsService.delete(id) }; + } catch (error) { + return { success: false, error: formatError(error) }; + } + }, + + toggle: async (id: string) => { + try { + return { success: true, data: await automationsService.toggleStatus(id) }; + } catch (error) { + return { success: false, error: formatError(error) }; + } + }, + + runLogs: async (automationId: string, limit?: number) => { + try { + return { + success: true, + data: await automationsService.getRunLogs(automationId, limit ?? 100), + }; + } catch (error) { + return { success: false, error: formatError(error) }; + } + }, + + triggerNow: async (id: string) => { + try { + await automationsService.triggerNow(id); + return { success: true }; + } catch (error) { + return { success: false, error: formatError(error) }; + } + }, +}); diff --git a/src/main/core/gitlab/gitlab-service.ts b/src/main/core/gitlab/gitlab-service.ts index 43b2e39e9..929d50f54 100644 --- a/src/main/core/gitlab/gitlab-service.ts +++ b/src/main/core/gitlab/gitlab-service.ts @@ -35,6 +35,19 @@ export interface GitLabIssueSummary { updatedAt: string | null; } +export interface GitLabMergeRequestSummary { + id: number; + iid: number; + title: string; + description: string | null; + webUrl: string | null; + state: string | null; + sourceBranch: string | null; + labels: string[]; + assignee: { name: string; username: string } | null; + updatedAt: string | null; +} + const gitlabKV = new KV('gitlab'); export class GitlabService { @@ -180,6 +193,34 @@ export class GitlabService { } } + async initialFetchMergeRequests( + projectPath: string, + limit = 50 + ): Promise { + const path = projectPath.trim(); + if (!path) { + throw new Error('Project path is required.'); + } + + const perPage = Number.isFinite(limit) ? Math.max(1, Math.min(limit, 100)) : 50; + const { client, projectId } = await this.resolveProject(path); + + try { + const mergeRequests = (await client.MergeRequests.all({ + projectId, + state: 'opened', + orderBy: 'updated_at', + sort: 'desc', + perPage, + maxPages: 1, + })) as unknown[]; + + return this.normalizeMergeRequests(mergeRequests); + } catch (error) { + throw new Error(this.toErrorMessage(error, 'Failed to fetch GitLab merge requests.')); + } + } + private async requireAuth(): Promise<{ instanceUrl: string; client: Gitlab }> { const connection = await this.readConnection(); if (!connection) { @@ -300,6 +341,13 @@ export class GitlabService { .filter((issue): issue is GitLabIssueSummary => issue !== null); } + private normalizeMergeRequests(rawMergeRequests: unknown[]): GitLabMergeRequestSummary[] { + const mergeRequests = Array.isArray(rawMergeRequests) ? rawMergeRequests : []; + return mergeRequests + .map((item) => this.mapMergeRequest(item)) + .filter((mergeRequest): mergeRequest is GitLabMergeRequestSummary => mergeRequest !== null); + } + private mapIssue(raw: unknown, projectName: string | null): GitLabIssueSummary | null { const item = this.asRecord(raw); if (!item) return null; @@ -353,6 +401,52 @@ export class GitlabService { }; } + private mapMergeRequest(raw: unknown): GitLabMergeRequestSummary | null { + const item = this.asRecord(raw); + if (!item) return null; + + const id = this.readNumber(item.id); + const iid = this.readNumber(item.iid); + if (id === null || iid === null) return null; + + const assigneeRecord = + this.asRecord(item.assignee) ?? + (Array.isArray(item.assignees) ? this.asRecord(item.assignees[0]) : null); + const assigneeName = + this.readString(assigneeRecord?.name) ?? this.readString(assigneeRecord?.username); + const assigneeUsername = + this.readString(assigneeRecord?.username) ?? this.readString(assigneeRecord?.name); + + const labels = Array.isArray(item.labels) + ? item.labels + .map((label) => { + if (typeof label === 'string') return label; + const labelObj = this.asRecord(label); + return this.readString(labelObj?.name); + }) + .filter((label): label is string => Boolean(label)) + : []; + + return { + id, + iid, + title: this.readString(item.title) ?? '', + description: this.readString(item.description), + webUrl: this.readString(item.web_url) ?? this.readString(item.webUrl), + state: this.readString(item.state), + sourceBranch: this.readString(item.source_branch) ?? this.readString(item.sourceBranch), + labels, + assignee: + assigneeName || assigneeUsername + ? { + name: assigneeName ?? assigneeUsername ?? '', + username: assigneeUsername ?? assigneeName ?? '', + } + : null, + updatedAt: this.readString(item.updated_at) ?? this.readString(item.updatedAt), + }; + } + private getClient(instanceUrl: string, token: string): Gitlab { const key = `${instanceUrl}|${token}`; if (!this.client || this.clientKey !== key) { diff --git a/src/main/core/integrations/controller.ts b/src/main/core/integrations/controller.ts new file mode 100644 index 000000000..757d7e244 --- /dev/null +++ b/src/main/core/integrations/controller.ts @@ -0,0 +1,31 @@ +import type { IntegrationStatusMap } from '@shared/integrations/types'; +import { createRPCController } from '@shared/ipc/rpc'; +import { forgejoService } from '@main/core/forgejo/forgejo-service'; +import { githubAuthService } from '@main/core/github/services/github-auth-service'; +import { gitlabService } from '@main/core/gitlab/gitlab-service'; +import { jiraService } from '@main/core/jira/JiraService'; +import { linearService } from '@main/core/linear/LinearService'; +import { plainService } from '@main/core/plain/plain-service'; + +export const integrationsController = createRPCController({ + statusMap: async (): Promise => { + const [github, linear, jira, gitlab, plain, forgejo] = await Promise.all([ + githubAuthService.isAuthenticated(), + linearService.checkConnection(), + jiraService.checkConnection(), + gitlabService.checkConnection(), + plainService.checkConnection(), + forgejoService.checkConnection(), + ]); + + return { + github, + linear: linear.connected, + jira: jira.connected, + gitlab: gitlab.connected, + plain: plain.connected, + forgejo: forgejo.connected, + sentry: false, + }; + }, +}); diff --git a/src/main/db/initialize.ts b/src/main/db/initialize.ts index a713df77a..3783e7df9 100644 --- a/src/main/db/initialize.ts +++ b/src/main/db/initialize.ts @@ -13,6 +13,38 @@ const sqlFiles = import.meta.glob('@root/drizzle/*.sql', { type JournalEntry = { idx: number; when: number; tag: string; breakpoints: boolean }; +function ensureAutomationColumns(connection: BetterSqlite3.Database): void { + const tableExists = connection + .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='automations'") + .get() as { name: string } | undefined; + + if (!tableExists) return; + + const columns = connection.prepare('PRAGMA table_info(automations)').all() as Array<{ + name: string; + }>; + const has = (name: string) => columns.some((c) => c.name === name); + + if (!has('use_worktree')) { + connection.exec('ALTER TABLE automations ADD COLUMN use_worktree integer DEFAULT 1 NOT NULL'); + } + if (!has('mode')) { + connection.exec("ALTER TABLE automations ADD COLUMN mode text DEFAULT 'schedule' NOT NULL"); + } + if (!has('trigger_type')) { + connection.exec('ALTER TABLE automations ADD COLUMN trigger_type text'); + } + if (!has('trigger_config')) { + connection.exec('ALTER TABLE automations ADD COLUMN trigger_config text'); + } + if (!has('last_run_result')) { + connection.exec('ALTER TABLE automations ADD COLUMN last_run_result text'); + } + if (!has('last_run_error')) { + connection.exec('ALTER TABLE automations ADD COLUMN last_run_error text'); + } +} + function runBundledMigrations(connection: BetterSqlite3.Database): void { connection.exec(` CREATE TABLE IF NOT EXISTS __drizzle_migrations ( @@ -60,5 +92,6 @@ function runBundledMigrations(connection: BetterSqlite3.Database): void { */ export async function initializeDatabase(): Promise { runBundledMigrations(sqlite); + ensureAutomationColumns(sqlite); return sqlite; } diff --git a/src/main/db/schema.ts b/src/main/db/schema.ts index 064227f09..61453bc46 100644 --- a/src/main/db/schema.ts +++ b/src/main/db/schema.ts @@ -323,6 +323,64 @@ export const kv = sqliteTable( }) ); +export const automations = sqliteTable( + 'automations', + { + id: text('id').primaryKey(), + projectId: text('project_id') + .notNull() + .references(() => projects.id, { onDelete: 'cascade' }), + projectName: text('project_name').notNull().default(''), + name: text('name').notNull(), + prompt: text('prompt').notNull(), + agentId: text('agent_id').notNull(), + mode: text('mode').notNull().default('schedule'), + schedule: text('schedule').notNull(), + triggerType: text('trigger_type'), + triggerConfig: text('trigger_config'), + useWorktree: integer('use_worktree').notNull().default(1), + status: text('status').notNull().default('active'), + lastRunAt: text('last_run_at'), + nextRunAt: text('next_run_at'), + runCount: integer('run_count').notNull().default(0), + lastRunResult: text('last_run_result'), + lastRunError: text('last_run_error'), + createdAt: text('created_at') + .notNull() + .default(sql`CURRENT_TIMESTAMP`), + updatedAt: text('updated_at') + .notNull() + .default(sql`CURRENT_TIMESTAMP`), + }, + (table) => ({ + projectIdIdx: index('idx_automations_project_id').on(table.projectId), + statusNextRunIdx: index('idx_automations_status_next_run').on(table.status, table.nextRunAt), + updatedAtIdx: index('idx_automations_updated_at').on(table.updatedAt), + }) +); + +export const automationRunLogs = sqliteTable( + 'automation_run_logs', + { + id: text('id').primaryKey(), + automationId: text('automation_id') + .notNull() + .references(() => automations.id, { onDelete: 'cascade' }), + startedAt: text('started_at').notNull(), + finishedAt: text('finished_at'), + status: text('status').notNull(), + error: text('error'), + taskId: text('task_id').references(() => tasks.id, { onDelete: 'set null' }), + }, + (table) => ({ + automationStartedIdx: index('idx_automation_run_logs_automation_started').on( + table.automationId, + table.startedAt + ), + statusIdx: index('idx_automation_run_logs_status').on(table.status), + }) +); + export type KvRow = typeof kv.$inferSelect; export type KvInsert = typeof kv.$inferInsert; @@ -345,6 +403,7 @@ export const tasksRelations = relations(tasks, ({ one, many }) => ({ }), conversations: many(conversations), lineComments: many(lineComments), + automationRunLogs: many(automationRunLogs), })); export const conversationsRelations = relations(conversations, ({ one, many }) => ({ @@ -369,6 +428,25 @@ export const lineCommentsRelations = relations(lineComments, ({ one }) => ({ }), })); +export const automationsRelations = relations(automations, ({ one, many }) => ({ + project: one(projects, { + fields: [automations.projectId], + references: [projects.id], + }), + runLogs: many(automationRunLogs), +})); + +export const automationRunLogsRelations = relations(automationRunLogs, ({ one }) => ({ + automation: one(automations, { + fields: [automationRunLogs.automationId], + references: [automations.id], + }), + task: one(tasks, { + fields: [automationRunLogs.taskId], + references: [tasks.id], + }), +})); + export type SshConnectionRow = typeof sshConnections.$inferSelect; export type SshConnectionInsert = typeof sshConnections.$inferInsert; export type ProjectRow = typeof projects.$inferSelect; @@ -380,3 +458,7 @@ export type LineCommentRow = typeof lineComments.$inferSelect; export type LineCommentInsert = typeof lineComments.$inferInsert; export type EditorBufferRow = typeof editorBuffers.$inferSelect; export type EditorBufferInsert = typeof editorBuffers.$inferInsert; +export type AutomationRow = typeof automations.$inferSelect; +export type AutomationInsert = typeof automations.$inferInsert; +export type AutomationRunLogRow = typeof automationRunLogs.$inferSelect; +export type AutomationRunLogInsert = typeof automationRunLogs.$inferInsert; diff --git a/src/main/index.ts b/src/main/index.ts index 31f21c57d..08b9594b9 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -10,6 +10,7 @@ import { providerTokenRegistry } from './core/account/provider-token-registry'; import { emdashAccountService } from './core/account/services/emdash-account-service'; import { agentHookService } from './core/agent-hooks/agent-hook-service'; import { appService } from './core/app/service'; +import { startAutomationsRuntime, stopAutomationsRuntime } from './core/automations/controller'; import { localDependencyManager } from './core/dependencies/dependency-manager'; import { editorBufferService } from './core/editor/editor-buffer-service'; import { githubAuthService } from './core/github/services/github-auth-service'; @@ -106,6 +107,8 @@ app.whenReady().then(async () => { registerRPCRouter(rpcRouter, ipcMain); + await startAutomationsRuntime(); + localDependencyManager.probeAll().catch((e) => { log.error('Failed to probe dependencies:', e); }); @@ -130,6 +133,7 @@ app.on('before-quit', () => { agentHookService.stop(); updateService.shutdown(); + stopAutomationsRuntime(); projectManager.shutdown().catch((e) => { log.error('Failed to shutdown project manager:', e); }); diff --git a/src/main/rpc.ts b/src/main/rpc.ts index 3a3cadc7d..8c5fc0a54 100644 --- a/src/main/rpc.ts +++ b/src/main/rpc.ts @@ -1,6 +1,7 @@ import { createRPCRouter } from '../shared/ipc/rpc'; import { accountController } from './core/account/controller'; import { appController } from './core/app/controller'; +import { automationsController } from './core/automations/controller'; import { conversationController } from './core/conversations/controller'; import { dependenciesController } from './core/dependencies/controller'; import { editorBufferController } from './core/editor/controller'; @@ -9,6 +10,7 @@ import { filesController } from './core/fs/controller'; import { gitController } from './core/git/controller'; import { githubController } from './core/github/controller'; import { gitlabController } from './core/gitlab/controller'; +import { integrationsController } from './core/integrations/controller'; import { jiraController } from './core/jira/controller'; import { lineCommentsController } from './core/line-comments'; import { linearController } from './core/linear/controller'; @@ -30,7 +32,9 @@ import { viewStateController } from './core/view-state/controller'; export const rpcRouter = createRPCRouter({ account: accountController, app: appController, + automations: automationsController, appSettings: appSettingsController, + integrations: integrationsController, providerSettings: providerSettingsController, repository: repositoryController, fs: filesController, diff --git a/src/preload/index.ts b/src/preload/index.ts index 9fb51bc28..aebbd9a1d 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -11,4 +11,24 @@ contextBridge.exposeInMainWorld('electronAPI', { ipcRenderer.on(channel, wrapped); return () => ipcRenderer.removeListener(channel, wrapped); }, + + // Automations compatibility helpers + automationsList: () => ipcRenderer.invoke('automations.list'), + automationsGet: (args: { id: string }) => ipcRenderer.invoke('automations.get', args.id), + automationsCreate: (args: unknown) => ipcRenderer.invoke('automations.create', args), + automationsUpdate: (args: unknown) => ipcRenderer.invoke('automations.update', args), + automationsDelete: (args: { id: string }) => ipcRenderer.invoke('automations.delete', args.id), + automationsToggle: (args: { id: string }) => ipcRenderer.invoke('automations.toggle', args.id), + automationsRunLogs: (args: { automationId: string; limit?: number }) => + ipcRenderer.invoke('automations.runLogs', args.automationId, args.limit), + automationsTriggerNow: (args: { id: string }) => + ipcRenderer.invoke('automations.triggerNow', args.id), + automationsCompleteRun: (_args: unknown) => Promise.resolve({ success: true }), + automationsDrainTriggers: () => Promise.resolve({ success: true, data: [] }), + onAutomationTriggerAvailable: (_cb: () => void) => () => {}, + + worktreeCreate: (_projectId: string, _taskId: string, _branch?: string) => + Promise.resolve({ success: true }), + worktreeRemove: (_projectId: string, _taskId: string) => Promise.resolve({ success: true }), + onPtyExit: (_id: string, _cb: (payload: { exitCode: number }) => void) => () => {}, }); diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index d589087e6..54c22288e 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -11,8 +11,8 @@ import { SshConnectionProvider } from './core/ssh/ssh-connection-provider'; import { WorkspaceLayoutContextProvider } from './core/view/layout-provider'; import { WorkspaceViewProvider } from './core/view/provider'; import { useLocalStorage } from './hooks/useLocalStorage'; -import { WelcomeScreen } from './views/welcome'; -import { Workspace } from './views/workspace'; +import { WelcomeScreen } from './views/Welcome'; +import { Workspace } from './views/Workspace'; export const FIRST_LAUNCH_KEY = 'emdash:first-launch:v1'; diff --git a/src/renderer/components/AgentLogo.tsx b/src/renderer/components/AgentLogo.tsx new file mode 100644 index 000000000..11114bd4b --- /dev/null +++ b/src/renderer/components/AgentLogo.tsx @@ -0,0 +1,46 @@ +import { agentConfig } from '@renderer/lib/agentConfig'; +import { cn } from '@renderer/lib/utils'; +import type { Agent } from '@renderer/types'; + +type LegacyProps = { + logo?: string; + alt?: string; + isSvg?: boolean; + invertInDark?: boolean; +}; + +export default function AgentLogo({ + provider, + className, + logo, + alt, + isSvg, +}: { + provider?: Agent; + className?: string; +} & LegacyProps) { + const info = provider ? agentConfig[provider] : undefined; + const effectiveLogo = logo ?? info?.logo; + const effectiveAlt = alt ?? info?.alt ?? 'agent'; + const effectiveIsSvg = isSvg ?? info?.isSvg; + if (!effectiveLogo) { + return
; + } + + if (effectiveIsSvg) { + return ( + + ); + } + + return ( + {effectiveAlt} + ); +} diff --git a/src/renderer/components/TaskStatusIndicator.tsx b/src/renderer/components/TaskStatusIndicator.tsx new file mode 100644 index 000000000..0cc963e82 --- /dev/null +++ b/src/renderer/components/TaskStatusIndicator.tsx @@ -0,0 +1,13 @@ +import type { TaskLifecycleStatus } from '@shared/tasks'; + +export function TaskStatusIndicator({ status }: { status: TaskLifecycleStatus }) { + const color = + status === 'done' + ? 'bg-emerald-500' + : status === 'in_progress' + ? 'bg-blue-500' + : status === 'review' + ? 'bg-amber-500' + : 'bg-zinc-500'; + return ; +} diff --git a/src/renderer/components/automations/AutomationInlineCreate.tsx b/src/renderer/components/automations/AutomationInlineCreate.tsx new file mode 100644 index 000000000..aa7ff4c0d --- /dev/null +++ b/src/renderer/components/automations/AutomationInlineCreate.tsx @@ -0,0 +1,623 @@ +import { Check, Clock, FolderGit2, FolderOpen, GitBranch, Github, Zap } from 'lucide-react'; +import { AnimatePresence, motion } from 'motion/react'; +import React, { useEffect, useRef, useState } from 'react'; +import type { AgentProviderId } from '@shared/agent-provider-registry'; +import type { + Automation, + AutomationMode, + CreateAutomationInput, + ScheduleType, + TriggerType, + UpdateAutomationInput, +} from '@shared/automations/types'; +import { INTEGRATION_LABELS } from '@shared/integrations/types'; +import { agentConfig } from '@renderer/lib/agentConfig'; +import { useIntegrationStatusMap } from '../../hooks/useIntegrationStatusMap'; +import type { Agent } from '../../types'; +import type { Project } from '../../types/app'; +import { AgentSelector } from '../agent-selector'; +import AgentLogo from '../AgentLogo'; +import { Button } from '../ui/button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '../ui/dropdown-menu'; +import { Input } from '../ui/input'; +import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover'; +import { Textarea } from '../ui/textarea'; +import { + buildSchedule, + DAYS_OF_MONTH, + DAYS_OF_WEEK, + formatScheduleLabel, + formatTriggerLabel, + HOURS, + MINUTES, + SCHEDULE_TYPES, + TRIGGER_INTEGRATION_MAP, + TRIGGER_TYPES, +} from './utils'; + +interface AutomationInlineCreateProps { + projects: Project[]; + prefill?: { + name: string; + prompt: string; + mode?: AutomationMode; + triggerType?: TriggerType; + } | null; + editingAutomation?: Automation | null; + onSave: (input: CreateAutomationInput) => Promise; + onUpdate?: (input: UpdateAutomationInput) => Promise; + onCancel: () => void; +} + +const AutomationInlineCreate: React.FC = ({ + projects, + prefill, + editingAutomation, + onSave, + onUpdate, + onCancel, +}) => { + const isEditing = !!editingAutomation; + const { statuses: integrationStatuses } = useIntegrationStatusMap(); + + const [name, setName] = useState(editingAutomation?.name ?? prefill?.name ?? ''); + const [projectId, setProjectId] = useState(editingAutomation?.projectId ?? projects[0]?.id ?? ''); + const [prompt, setPrompt] = useState(editingAutomation?.prompt ?? prefill?.prompt ?? ''); + const [agentId, setAgentId] = useState(editingAutomation?.agentId ?? 'claude'); + const [mode, setMode] = useState( + editingAutomation?.mode ?? prefill?.mode ?? 'schedule' + ); + const [triggerType, setTriggerType] = useState( + editingAutomation?.triggerType ?? prefill?.triggerType ?? 'github_pr' + ); + const [branchFilter, setBranchFilter] = useState( + editingAutomation?.triggerConfig?.branchFilter ?? '' + ); + const [labelFilter, setLabelFilter] = useState( + editingAutomation?.triggerConfig?.labelFilter?.join(', ') ?? '' + ); + const [scheduleType, setScheduleType] = useState( + editingAutomation?.schedule.type ?? 'daily' + ); + const [hour, setHour] = useState(editingAutomation?.schedule.hour ?? 9); + const [minute, setMinute] = useState(editingAutomation?.schedule.minute ?? 0); + const [dayOfWeek, setDayOfWeek] = useState( + editingAutomation?.schedule.dayOfWeek ?? 'mon' + ); + const [dayOfMonth, setDayOfMonth] = useState(editingAutomation?.schedule.dayOfMonth ?? 1); + const [useWorktree, setUseWorktree] = useState(editingAutomation?.useWorktree ?? true); + const [isSaving, setIsSaving] = useState(false); + const [error, setError] = useState(null); + + const nameRef = useRef(null); + const initializedRef = useRef(false); + const userTouchedWorktreeRef = useRef(false); + + useEffect(() => { + if (prefill && !isEditing && !initializedRef.current) { + setName(prefill.name); + setPrompt(prefill.prompt); + if (prefill.mode) setMode(prefill.mode); + if (prefill.triggerType) setTriggerType(prefill.triggerType); + initializedRef.current = true; + } + }, [prefill, isEditing]); + + useEffect(() => { + return () => { + initializedRef.current = false; + }; + }, []); + + useEffect(() => { + nameRef.current?.focus(); + }, []); + + const currentSchedule = buildSchedule(scheduleType, hour, minute, dayOfWeek, dayOfMonth); + const schedulePreview = formatScheduleLabel(currentSchedule); + + let buttonLabel: string; + if (isSaving) { + buttonLabel = isEditing ? 'Saving…' : 'Creating…'; + } else { + buttonLabel = isEditing ? 'Save' : 'Create'; + } + + const handleSubmit = async () => { + setError(null); + if (!name.trim()) { + setError('Name is required'); + return; + } + if (!projectId) { + setError('Select a project'); + return; + } + if (!prompt.trim()) { + setError('Prompt is required'); + return; + } + + setIsSaving(true); + try { + if (isEditing && !onUpdate) { + throw new Error('onUpdate handler is required when editing an automation'); + } + const parsedLabels = labelFilter + .split(',') + .map((s) => s.trim()) + .filter(Boolean); + const triggerCfg = + mode === 'trigger' + ? { + branchFilter: branchFilter.trim() || undefined, + labelFilter: parsedLabels.length > 0 ? parsedLabels : undefined, + } + : undefined; + + if (isEditing && editingAutomation && onUpdate) { + await onUpdate({ + id: editingAutomation.id, + name: name.trim(), + projectId, + prompt: prompt.trim(), + agentId, + mode, + schedule: currentSchedule, + triggerType: mode === 'trigger' ? triggerType : null, + triggerConfig: triggerCfg ?? null, + useWorktree, + }); + } else { + await onSave({ + name: name.trim(), + projectId, + prompt: prompt.trim(), + agentId, + mode, + schedule: currentSchedule, + triggerType: mode === 'trigger' ? triggerType : undefined, + triggerConfig: triggerCfg, + useWorktree, + }); + } + } catch (err) { + if (err instanceof Error) { + setError(err.message); + } else { + setError(isEditing ? 'Failed to save' : 'Failed to create'); + } + } finally { + setIsSaving(false); + } + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Escape') { + e.preventDefault(); + onCancel(); + } + if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { + e.preventDefault(); + void handleSubmit(); + } + }; + + const selectedProject = projects.find((p) => p.id === projectId); + const selectedAgent = agentConfig[agentId as Agent]; + const hasGithub = + selectedProject?.githubInfo?.connected && selectedProject?.githubInfo?.repository; + + return ( +
+ {/* Title row */} +
+ setName(e.target.value)} + placeholder="Automation title" + className="border-0 bg-transparent px-0 text-sm font-medium placeholder:text-muted-foreground/40 focus-visible:ring-0 focus-visible:ring-offset-0" + /> +
+ + {/* Prompt textarea */} +
+