From f6cc881ee0b067507dae353752a8e32fc69caf8d Mon Sep 17 00:00:00 2001
From: Annika <14750837+annikaschmid@users.noreply.github.com>
Date: Wed, 25 Mar 2026 17:08:15 +0000
Subject: [PATCH 1/4] codex version that works locally
---
.claude/worktrees/gifted-haibt | 1 +
.../src/renderer/components/MainLayout.tsx | 5 +
.../components/AutomationsView.tsx | 616 ++++++++++++++++++
.../hooks/useAutomationScheduler.ts | 168 +++++
.../automations/stores/automationStore.ts | 252 +++++++
.../features/automations/templates.ts | 34 +
.../features/automations/utils/schedule.ts | 156 +++++
.../sidebar/components/SidebarMenu.tsx | 19 +
.../components/items/AutomationsItem.tsx | 40 ++
.../features/sidebar/hooks/useSidebarData.ts | 6 +-
.../src/renderer/stores/navigationStore.ts | 11 +-
apps/code/src/shared/types/automations.ts | 43 ++
12 files changed, 1349 insertions(+), 2 deletions(-)
create mode 160000 .claude/worktrees/gifted-haibt
create mode 100644 apps/code/src/renderer/features/automations/components/AutomationsView.tsx
create mode 100644 apps/code/src/renderer/features/automations/hooks/useAutomationScheduler.ts
create mode 100644 apps/code/src/renderer/features/automations/stores/automationStore.ts
create mode 100644 apps/code/src/renderer/features/automations/templates.ts
create mode 100644 apps/code/src/renderer/features/automations/utils/schedule.ts
create mode 100644 apps/code/src/renderer/features/sidebar/components/items/AutomationsItem.tsx
create mode 100644 apps/code/src/shared/types/automations.ts
diff --git a/.claude/worktrees/gifted-haibt b/.claude/worktrees/gifted-haibt
new file mode 160000
index 000000000..53ed7f4e2
--- /dev/null
+++ b/.claude/worktrees/gifted-haibt
@@ -0,0 +1 @@
+Subproject commit 53ed7f4e23ebe2afb197fb2e9adfdb26afb25afb
diff --git a/apps/code/src/renderer/components/MainLayout.tsx b/apps/code/src/renderer/components/MainLayout.tsx
index 698574438..78b85cab3 100644
--- a/apps/code/src/renderer/components/MainLayout.tsx
+++ b/apps/code/src/renderer/components/MainLayout.tsx
@@ -4,6 +4,8 @@ import { HedgehogMode } from "@components/HedgehogMode";
import { KeyboardShortcutsSheet } from "@components/KeyboardShortcutsSheet";
import { ArchivedTasksView } from "@features/archive/components/ArchivedTasksView";
+import { AutomationsView } from "@features/automations/components/AutomationsView";
+import { useAutomationScheduler } from "@features/automations/hooks/useAutomationScheduler";
import { CommandMenu } from "@features/command/components/CommandMenu";
import { CommandCenterView } from "@features/command-center/components/CommandCenterView";
import { InboxView } from "@features/inbox/components/InboxView";
@@ -42,6 +44,7 @@ export function MainLayout() {
useIntegrations();
useTaskDeepLink();
+ useAutomationScheduler();
useEffect(() => {
if (tasks) {
@@ -80,6 +83,8 @@ export function MainLayout() {
{view.type === "command-center" && }
+ {view.type === "automations" && }
+
{view.type === "skills" && }
diff --git a/apps/code/src/renderer/features/automations/components/AutomationsView.tsx b/apps/code/src/renderer/features/automations/components/AutomationsView.tsx
new file mode 100644
index 000000000..b42796cfc
--- /dev/null
+++ b/apps/code/src/renderer/features/automations/components/AutomationsView.tsx
@@ -0,0 +1,616 @@
+import { FolderPicker } from "@features/folder-picker/components/FolderPicker";
+import { GitHubRepoPicker } from "@features/folder-picker/components/GitHubRepoPicker";
+import { useRepositoryIntegration } from "@hooks/useIntegrations";
+import { useSetHeaderContent } from "@hooks/useSetHeaderContent";
+import {
+ ClockCounterClockwise,
+ FloppyDisk,
+ Play,
+ Plus,
+ Robot,
+ Trash,
+} from "@phosphor-icons/react";
+import {
+ Badge,
+ Box,
+ Button,
+ Flex,
+ ScrollArea,
+ Separator,
+ Switch,
+ Text,
+ TextArea,
+ TextField,
+} from "@radix-ui/themes";
+import type { Automation } from "@shared/types/automations";
+import { useEffect, useMemo, useState } from "react";
+import { runAutomationNow } from "../hooks/useAutomationScheduler";
+import { useAutomationStore } from "../stores/automationStore";
+import { AUTOMATION_TEMPLATES } from "../templates";
+import { formatAutomationDateTime, getLocalTimezone } from "../utils/schedule";
+
+interface AutomationDraft {
+ name: string;
+ prompt: string;
+ repoPath: string;
+ repository: string | null;
+ githubIntegrationId: number | null;
+ scheduleTime: string;
+ templateId: string | null;
+}
+
+function toDraft(
+ automation?: Automation | null,
+ githubIntegrationId?: number | null,
+): AutomationDraft {
+ if (!automation) {
+ return {
+ name: "",
+ prompt: "",
+ repoPath: "",
+ repository: null,
+ githubIntegrationId: githubIntegrationId ?? null,
+ scheduleTime: "09:00",
+ templateId: null,
+ };
+ }
+
+ return {
+ name: automation.name,
+ prompt: automation.prompt,
+ repoPath: automation.repoPath,
+ repository: automation.repository ?? null,
+ githubIntegrationId:
+ automation.githubIntegrationId ?? githubIntegrationId ?? null,
+ scheduleTime: automation.scheduleTime,
+ templateId: automation.templateId ?? null,
+ };
+}
+
+function AutomationStatusBadge({ automation }: { automation: Automation }) {
+ if (!automation.enabled) {
+ return (
+
+ Paused
+
+ );
+ }
+
+ if (automation.lastRunStatus === "failed") {
+ return (
+
+ Failed
+
+ );
+ }
+
+ if (automation.lastRunStatus === "success") {
+ return (
+
+ Healthy
+
+ );
+ }
+
+ return (
+
+ Active
+
+ );
+}
+
+export function AutomationsView() {
+ const automations = useAutomationStore((state) => state.automations);
+ const selectedAutomationId = useAutomationStore(
+ (state) => state.selectedAutomationId,
+ );
+ const runningAutomationIds = useAutomationStore(
+ (state) => state.runningAutomationIds,
+ );
+ const setSelectedAutomationId = useAutomationStore(
+ (state) => state.setSelectedAutomationId,
+ );
+ const createAutomation = useAutomationStore(
+ (state) => state.createAutomation,
+ );
+ const updateAutomation = useAutomationStore(
+ (state) => state.updateAutomation,
+ );
+ const deleteAutomation = useAutomationStore(
+ (state) => state.deleteAutomation,
+ );
+ const toggleAutomation = useAutomationStore(
+ (state) => state.toggleAutomation,
+ );
+
+ const { githubIntegration, repositories, isLoadingRepos } =
+ useRepositoryIntegration();
+
+ const selectedAutomation = useMemo(
+ () =>
+ automations.find(
+ (automation) => automation.id === selectedAutomationId,
+ ) ?? null,
+ [automations, selectedAutomationId],
+ );
+
+ const [isCreating, setIsCreating] = useState(automations.length === 0);
+ const [draft, setDraft] = useState(() =>
+ toDraft(null, githubIntegration?.id),
+ );
+
+ useEffect(() => {
+ if (!isCreating && selectedAutomation) {
+ setDraft(toDraft(selectedAutomation, githubIntegration?.id));
+ }
+ }, [isCreating, selectedAutomation, githubIntegration?.id]);
+
+ const headerContent = useMemo(
+ () => (
+
+
+
+ Automations
+
+
+ ),
+ [],
+ );
+
+ useSetHeaderContent(headerContent);
+
+ const openCreate = () => {
+ setIsCreating(true);
+ setSelectedAutomationId(null);
+ setDraft(toDraft(null, githubIntegration?.id));
+ };
+
+ const openExisting = (automation: Automation) => {
+ setIsCreating(false);
+ setSelectedAutomationId(automation.id);
+ setDraft(toDraft(automation, githubIntegration?.id));
+ };
+
+ const applyTemplate = (templateId: string) => {
+ const template = AUTOMATION_TEMPLATES.find(
+ (item) => item.id === templateId,
+ );
+ if (!template) {
+ return;
+ }
+
+ setDraft((current) => ({
+ ...current,
+ name: current.name || template.name,
+ prompt: template.prompt,
+ templateId: template.id,
+ }));
+ };
+
+ const handleSave = () => {
+ if (!draft.name.trim() || !draft.prompt.trim() || !draft.repoPath.trim()) {
+ return;
+ }
+
+ if (isCreating || !selectedAutomation) {
+ const automationId = createAutomation({
+ name: draft.name,
+ prompt: draft.prompt,
+ repoPath: draft.repoPath,
+ repository: draft.repository,
+ githubIntegrationId: draft.githubIntegrationId,
+ scheduleTime: draft.scheduleTime,
+ templateId: draft.templateId,
+ });
+ const created = useAutomationStore
+ .getState()
+ .automations.find((item) => item.id === automationId);
+ if (created) {
+ openExisting(created);
+ }
+ return;
+ }
+
+ updateAutomation(selectedAutomation.id, {
+ name: draft.name,
+ prompt: draft.prompt,
+ repoPath: draft.repoPath,
+ repository: draft.repository,
+ githubIntegrationId: draft.githubIntegrationId,
+ scheduleTime: draft.scheduleTime,
+ templateId: draft.templateId,
+ });
+ };
+
+ const handleDelete = () => {
+ if (!selectedAutomation) {
+ return;
+ }
+ deleteAutomation(selectedAutomation.id);
+ openCreate();
+ };
+
+ const enabledCount = automations.filter(
+ (automation) => automation.enabled,
+ ).length;
+ const timezone = getLocalTimezone();
+
+ return (
+
+
+
+ {automations.length} automation{automations.length === 1 ? "" : "s"}
+
+
+ {enabledCount} enabled
+
+
+
+
+
+
+
+
+
+ {automations.length === 0 ? (
+
+
+
+ No automations yet
+
+
+ ) : (
+ automations.map((automation) => {
+ const isSelected =
+ !isCreating && selectedAutomation?.id === automation.id;
+
+ return (
+
+ );
+ })
+ )}
+
+
+
+
+
+
+
+
+
+ {isCreating
+ ? "New automation"
+ : (selectedAutomation?.name ?? "Automation")}
+
+
+ Runs locally on this app while it is open. Missed runs are
+ skipped.
+
+
+
+
+
+ Template library
+
+
+ {AUTOMATION_TEMPLATES.map((template) => (
+
+
+
+
+ {template.name}
+
+
+ {template.category}
+
+
+
+ {template.description}
+
+
+
+
+ ))}
+
+
+
+
+
+
+
+
+ Name
+
+
+ setDraft((current) => ({
+ ...current,
+ name: event.target.value,
+ }))
+ }
+ placeholder="Morning repo check"
+ />
+
+
+
+
+ Prompt
+
+
+
+
+
+ Local context
+
+
+ setDraft((current) => ({ ...current, repoPath }))
+ }
+ placeholder="Select repository..."
+ size="2"
+ />
+
+
+
+
+ GitHub repository
+
+
+ setDraft((current) => ({
+ ...current,
+ repository,
+ githubIntegrationId: githubIntegration?.id ?? null,
+ }))
+ }
+ repositories={repositories}
+ isLoading={isLoadingRepos}
+ placeholder="Optional"
+ size="2"
+ />
+
+
+
+
+
+ Daily time
+
+
+ setDraft((current) => ({
+ ...current,
+ scheduleTime: event.target.value,
+ }))
+ }
+ />
+
+
+
+
+ Timezone
+
+
+
+ {timezone}
+
+
+
+
+
+ {!isCreating && selectedAutomation ? (
+
+
+ Run status
+
+
+
+ Next run:{" "}
+ {selectedAutomation.enabled
+ ? formatAutomationDateTime(
+ selectedAutomation.nextRunAt,
+ selectedAutomation.timezone,
+ )
+ : "Paused"}
+
+
+ Last run:{" "}
+ {formatAutomationDateTime(
+ selectedAutomation.lastRunAt,
+ selectedAutomation.timezone,
+ )}
+
+ {selectedAutomation.lastError ? (
+
+ {selectedAutomation.lastError}
+
+ ) : null}
+
+
+ ) : null}
+
+
+
+ {!isCreating && selectedAutomation ? (
+ <>
+
+ toggleAutomation(selectedAutomation.id)
+ }
+ />
+
+ {selectedAutomation.enabled ? "Enabled" : "Paused"}
+
+ >
+ ) : null}
+
+ {!isCreating && selectedAutomation ? (
+
+ ) : null}
+ {!isCreating && selectedAutomation ? (
+
+ ) : null}
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/code/src/renderer/features/automations/hooks/useAutomationScheduler.ts b/apps/code/src/renderer/features/automations/hooks/useAutomationScheduler.ts
new file mode 100644
index 000000000..bbc195ff0
--- /dev/null
+++ b/apps/code/src/renderer/features/automations/hooks/useAutomationScheduler.ts
@@ -0,0 +1,168 @@
+import { useAuthStore } from "@features/auth/stores/authStore";
+import type { TaskService } from "@features/task-detail/service/service";
+import { get } from "@renderer/di/container";
+import { RENDERER_TOKENS } from "@renderer/di/tokens";
+import { queryClient } from "@utils/queryClient";
+import { useEffect, useRef } from "react";
+import { toast } from "sonner";
+import { useAutomationStore } from "../stores/automationStore";
+
+const SCHEDULER_INTERVAL_MS = 30_000;
+
+export function useAutomationScheduler(): void {
+ const hasHydrated = useAutomationStore((state) => state.hasHydrated);
+ const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
+ const initializedRef = useRef(false);
+ const runningIdsRef = useRef(new Set());
+
+ useEffect(() => {
+ if (!hasHydrated) {
+ return;
+ }
+
+ if (!initializedRef.current) {
+ useAutomationStore.getState().normalizeSchedules(new Date());
+ initializedRef.current = true;
+ }
+
+ const tick = async () => {
+ if (!isAuthenticated) {
+ return;
+ }
+
+ const now = new Date();
+ const state = useAutomationStore.getState();
+ const dueAutomations = state.automations.filter(
+ (automation) =>
+ automation.enabled &&
+ !!automation.nextRunAt &&
+ new Date(automation.nextRunAt).getTime() <= now.getTime() &&
+ !runningIdsRef.current.has(automation.id),
+ );
+
+ if (dueAutomations.length === 0) {
+ return;
+ }
+
+ const taskService = get(RENDERER_TOKENS.TaskService);
+
+ for (const automation of dueAutomations) {
+ runningIdsRef.current.add(automation.id);
+ state.markRunning(automation.id, true);
+
+ try {
+ const result = await taskService.createTask({
+ content: automation.prompt,
+ repoPath: automation.repoPath,
+ repository: automation.repository ?? undefined,
+ githubIntegrationId: automation.githubIntegrationId ?? undefined,
+ workspaceMode: "local",
+ });
+
+ if (!result.success) {
+ useAutomationStore.getState().recordRunResult({
+ automationId: automation.id,
+ status: "failed",
+ error: result.error ?? "Failed to create task",
+ advanceSchedule: true,
+ });
+ continue;
+ }
+
+ void queryClient.invalidateQueries({ queryKey: ["tasks"] });
+ useAutomationStore.getState().recordRunResult({
+ automationId: automation.id,
+ status: "success",
+ taskId: result.data.task.id,
+ advanceSchedule: true,
+ });
+ } catch (error) {
+ useAutomationStore.getState().recordRunResult({
+ automationId: automation.id,
+ status: "failed",
+ error: error instanceof Error ? error.message : "Unknown error",
+ advanceSchedule: true,
+ });
+ } finally {
+ runningIdsRef.current.delete(automation.id);
+ useAutomationStore.getState().markRunning(automation.id, false);
+ }
+ }
+ };
+
+ void tick();
+ const intervalId = window.setInterval(() => {
+ void tick();
+ }, SCHEDULER_INTERVAL_MS);
+
+ return () => {
+ window.clearInterval(intervalId);
+ };
+ }, [hasHydrated, isAuthenticated]);
+}
+
+export async function runAutomationNow(automationId: string): Promise {
+ const state = useAutomationStore.getState();
+ const automation = state.automations.find((item) => item.id === automationId);
+
+ if (!automation) {
+ toast.error("Automation not found");
+ return false;
+ }
+
+ if (state.runningAutomationIds.includes(automationId)) {
+ return false;
+ }
+
+ const isAuthenticated = useAuthStore.getState().isAuthenticated;
+ if (!isAuthenticated) {
+ toast.error("Sign in to run automations");
+ return false;
+ }
+
+ state.markRunning(automationId, true);
+
+ try {
+ const taskService = get(RENDERER_TOKENS.TaskService);
+ const result = await taskService.createTask({
+ content: automation.prompt,
+ repoPath: automation.repoPath,
+ repository: automation.repository ?? undefined,
+ githubIntegrationId: automation.githubIntegrationId ?? undefined,
+ workspaceMode: "local",
+ });
+
+ if (!result.success) {
+ state.recordRunResult({
+ automationId,
+ status: "failed",
+ error: result.error ?? "Failed to create task",
+ advanceSchedule: false,
+ });
+ toast.error(result.error ?? "Failed to run automation");
+ return false;
+ }
+
+ void queryClient.invalidateQueries({ queryKey: ["tasks"] });
+ state.recordRunResult({
+ automationId,
+ status: "success",
+ taskId: result.data.task.id,
+ advanceSchedule: false,
+ });
+ toast.success("Automation started");
+ return true;
+ } catch (error) {
+ const message = error instanceof Error ? error.message : "Unknown error";
+ state.recordRunResult({
+ automationId,
+ status: "failed",
+ error: message,
+ advanceSchedule: false,
+ });
+ toast.error(message);
+ return false;
+ } finally {
+ state.markRunning(automationId, false);
+ }
+}
diff --git a/apps/code/src/renderer/features/automations/stores/automationStore.ts b/apps/code/src/renderer/features/automations/stores/automationStore.ts
new file mode 100644
index 000000000..e632942f0
--- /dev/null
+++ b/apps/code/src/renderer/features/automations/stores/automationStore.ts
@@ -0,0 +1,252 @@
+import { electronStorage } from "@renderer/utils/electronStorage";
+import type { Automation } from "@shared/types/automations";
+import { create } from "zustand";
+import { persist } from "zustand/middleware";
+import { computeNextRunAt, getLocalTimezone } from "../utils/schedule";
+
+interface CreateAutomationInput {
+ name: string;
+ prompt: string;
+ repoPath: string;
+ repository?: string | null;
+ githubIntegrationId?: number | null;
+ scheduleTime: string;
+ templateId?: string | null;
+}
+
+interface UpdateAutomationInput extends Partial {
+ enabled?: boolean;
+}
+
+interface RunResultInput {
+ automationId: string;
+ status: Automation["lastRunStatus"];
+ taskId?: string | null;
+ error?: string | null;
+ ranAt?: string;
+ advanceSchedule?: boolean;
+}
+
+interface AutomationStoreState {
+ automations: Automation[];
+ selectedAutomationId: string | null;
+ runningAutomationIds: string[];
+ hasHydrated: boolean;
+}
+
+interface AutomationStoreActions {
+ setHasHydrated: (hasHydrated: boolean) => void;
+ setSelectedAutomationId: (automationId: string | null) => void;
+ createAutomation: (input: CreateAutomationInput) => string;
+ updateAutomation: (
+ automationId: string,
+ updates: UpdateAutomationInput,
+ ) => void;
+ deleteAutomation: (automationId: string) => void;
+ toggleAutomation: (automationId: string) => void;
+ markRunning: (automationId: string, isRunning: boolean) => void;
+ recordRunResult: (input: RunResultInput) => void;
+ normalizeSchedules: (now?: Date) => void;
+}
+
+type AutomationStore = AutomationStoreState & AutomationStoreActions;
+
+function sortAutomations(automations: Automation[]): Automation[] {
+ return [...automations].sort((a, b) => {
+ if (a.enabled !== b.enabled) {
+ return a.enabled ? -1 : 1;
+ }
+
+ return a.name.localeCompare(b.name);
+ });
+}
+
+function buildAutomation(input: CreateAutomationInput): Automation {
+ const nowIso = new Date().toISOString();
+ const timezone = getLocalTimezone();
+
+ return {
+ id: crypto.randomUUID(),
+ name: input.name.trim(),
+ prompt: input.prompt.trim(),
+ repoPath: input.repoPath,
+ repository: input.repository ?? null,
+ githubIntegrationId: input.githubIntegrationId ?? null,
+ scheduleTime: input.scheduleTime,
+ timezone,
+ enabled: true,
+ templateId: input.templateId ?? null,
+ createdAt: nowIso,
+ updatedAt: nowIso,
+ nextRunAt: computeNextRunAt(input.scheduleTime, timezone),
+ lastRunAt: null,
+ lastRunStatus: null,
+ lastTaskId: null,
+ lastError: null,
+ };
+}
+
+function updateNextRunAt(automation: Automation, now = new Date()): Automation {
+ return {
+ ...automation,
+ nextRunAt: automation.enabled
+ ? computeNextRunAt(automation.scheduleTime, automation.timezone, now)
+ : null,
+ };
+}
+
+export const useAutomationStore = create()(
+ persist(
+ (set) => ({
+ automations: [],
+ selectedAutomationId: null,
+ runningAutomationIds: [],
+ hasHydrated: false,
+
+ setHasHydrated: (hasHydrated) => set({ hasHydrated }),
+
+ setSelectedAutomationId: (automationId) =>
+ set({ selectedAutomationId: automationId }),
+
+ createAutomation: (input) => {
+ const automation = buildAutomation(input);
+ set((state) => ({
+ automations: sortAutomations([...state.automations, automation]),
+ selectedAutomationId: automation.id,
+ }));
+ return automation.id;
+ },
+
+ updateAutomation: (automationId, updates) =>
+ set((state) => ({
+ automations: sortAutomations(
+ state.automations.map((automation) => {
+ if (automation.id !== automationId) {
+ return automation;
+ }
+
+ const next: Automation = {
+ ...automation,
+ ...updates,
+ name: updates.name?.trim() ?? automation.name,
+ prompt: updates.prompt?.trim() ?? automation.prompt,
+ updatedAt: new Date().toISOString(),
+ };
+
+ return updateNextRunAt(next);
+ }),
+ ),
+ })),
+
+ deleteAutomation: (automationId) =>
+ set((state) => ({
+ automations: state.automations.filter(
+ (item) => item.id !== automationId,
+ ),
+ selectedAutomationId:
+ state.selectedAutomationId === automationId
+ ? null
+ : state.selectedAutomationId,
+ runningAutomationIds: state.runningAutomationIds.filter(
+ (item) => item !== automationId,
+ ),
+ })),
+
+ toggleAutomation: (automationId) =>
+ set((state) => ({
+ automations: sortAutomations(
+ state.automations.map((automation) => {
+ if (automation.id !== automationId) {
+ return automation;
+ }
+
+ const next = {
+ ...automation,
+ enabled: !automation.enabled,
+ updatedAt: new Date().toISOString(),
+ };
+
+ return updateNextRunAt(next);
+ }),
+ ),
+ })),
+
+ markRunning: (automationId, isRunning) =>
+ set((state) => ({
+ runningAutomationIds: isRunning
+ ? [...new Set([...state.runningAutomationIds, automationId])]
+ : state.runningAutomationIds.filter(
+ (item) => item !== automationId,
+ ),
+ })),
+
+ recordRunResult: ({
+ automationId,
+ status,
+ taskId,
+ error,
+ ranAt,
+ advanceSchedule = false,
+ }) =>
+ set((state) => ({
+ automations: sortAutomations(
+ state.automations.map((automation) => {
+ if (automation.id !== automationId) {
+ return automation;
+ }
+
+ const completedAt = ranAt ?? new Date().toISOString();
+ const nextBase = {
+ ...automation,
+ lastRunAt: completedAt,
+ lastRunStatus: status ?? null,
+ lastTaskId: taskId ?? null,
+ lastError: error ?? null,
+ updatedAt: completedAt,
+ };
+
+ return advanceSchedule ? updateNextRunAt(nextBase) : nextBase;
+ }),
+ ),
+ })),
+
+ normalizeSchedules: (now = new Date()) =>
+ set((state) => ({
+ automations: sortAutomations(
+ state.automations.map((automation) => {
+ if (!automation.enabled) {
+ return { ...automation, nextRunAt: null };
+ }
+
+ const timezone = automation.timezone || getLocalTimezone();
+ const nextRunAt = computeNextRunAt(
+ automation.scheduleTime,
+ timezone,
+ now,
+ );
+
+ return {
+ ...automation,
+ timezone,
+ nextRunAt,
+ };
+ }),
+ ),
+ })),
+ }),
+ {
+ name: "automations-storage",
+ storage: electronStorage,
+ partialize: (state) => ({
+ automations: state.automations,
+ selectedAutomationId: state.selectedAutomationId,
+ }),
+ onRehydrateStorage: () => (state) => {
+ state?.normalizeSchedules(new Date());
+ if (state) {
+ state.setHasHydrated(true);
+ }
+ },
+ },
+ ),
+);
diff --git a/apps/code/src/renderer/features/automations/templates.ts b/apps/code/src/renderer/features/automations/templates.ts
new file mode 100644
index 000000000..05cb8967c
--- /dev/null
+++ b/apps/code/src/renderer/features/automations/templates.ts
@@ -0,0 +1,34 @@
+import type { AutomationTemplate } from "@shared/types/automations";
+
+export const AUTOMATION_TEMPLATES: AutomationTemplate[] = [
+ {
+ id: "check-metrics",
+ name: "Check My Metrics",
+ description:
+ "Review key product metrics, flag unusual movement, and summarize what needs attention.",
+ category: "Product",
+ tags: ["metrics", "product", "summary"],
+ prompt:
+ "Review the most important product and growth metrics for this codebase and surrounding context. Summarize meaningful changes, highlight anything that looks off, and suggest the highest-leverage next actions.",
+ },
+ {
+ id: "check-github-prs",
+ name: "Check My GitHub PRs",
+ description:
+ "Look through recent pull requests and summarize what needs review or follow-up.",
+ category: "Engineering",
+ tags: ["github", "prs", "review"],
+ prompt:
+ "Check the current GitHub pull requests relevant to this repository and summarize what needs my attention. Call out stalled PRs, merge blockers, risky changes, and any follow-up actions I should take today.",
+ },
+ {
+ id: "product-issues",
+ name: "Summarize Product Issues",
+ description:
+ "Scan recent issue signals and produce a concise action-oriented update.",
+ category: "Support",
+ tags: ["issues", "triage", "summary"],
+ prompt:
+ "Review the recent product issues and signals associated with this repository. Group related problems, identify anything urgent, and recommend the best next actions for the team.",
+ },
+];
diff --git a/apps/code/src/renderer/features/automations/utils/schedule.ts b/apps/code/src/renderer/features/automations/utils/schedule.ts
new file mode 100644
index 000000000..f6a887edd
--- /dev/null
+++ b/apps/code/src/renderer/features/automations/utils/schedule.ts
@@ -0,0 +1,156 @@
+import type { Automation } from "@shared/types/automations";
+
+function getFormatter(timezone: string): Intl.DateTimeFormat {
+ return new Intl.DateTimeFormat("en-GB", {
+ timeZone: timezone,
+ year: "numeric",
+ month: "2-digit",
+ day: "2-digit",
+ hour: "2-digit",
+ minute: "2-digit",
+ hour12: false,
+ });
+}
+
+function getZonedParts(
+ date: Date,
+ timezone: string,
+): {
+ year: number;
+ month: number;
+ day: number;
+ hour: number;
+ minute: number;
+} {
+ const parts = getFormatter(timezone).formatToParts(date);
+ const get = (type: string) =>
+ Number(parts.find((part) => part.type === type)?.value ?? 0);
+
+ return {
+ year: get("year"),
+ month: get("month"),
+ day: get("day"),
+ hour: get("hour"),
+ minute: get("minute"),
+ };
+}
+
+function zonedDateTimeToUtc(
+ year: number,
+ month: number,
+ day: number,
+ hour: number,
+ minute: number,
+ timezone: string,
+): Date {
+ let guess = new Date(Date.UTC(year, month - 1, day, hour, minute, 0, 0));
+
+ for (let attempt = 0; attempt < 4; attempt++) {
+ const actual = getZonedParts(guess, timezone);
+ const actualMs = Date.UTC(
+ actual.year,
+ actual.month - 1,
+ actual.day,
+ actual.hour,
+ actual.minute,
+ 0,
+ 0,
+ );
+ const intendedMs = Date.UTC(year, month - 1, day, hour, minute, 0, 0);
+ const diffMinutes = Math.round((actualMs - intendedMs) / 60_000);
+
+ if (diffMinutes === 0) {
+ return guess;
+ }
+
+ guess = new Date(guess.getTime() - diffMinutes * 60_000);
+ }
+
+ return guess;
+}
+
+function parseScheduleTime(scheduleTime: string): {
+ hour: number;
+ minute: number;
+} {
+ const [hourText, minuteText] = scheduleTime.split(":");
+ return {
+ hour: Number(hourText ?? 0),
+ minute: Number(minuteText ?? 0),
+ };
+}
+
+export function computeNextRunAt(
+ scheduleTime: string,
+ timezone: string,
+ fromDate = new Date(),
+): string {
+ const today = getZonedParts(fromDate, timezone);
+ const { hour, minute } = parseScheduleTime(scheduleTime);
+
+ const todayTarget = zonedDateTimeToUtc(
+ today.year,
+ today.month,
+ today.day,
+ hour,
+ minute,
+ timezone,
+ );
+
+ if (todayTarget.getTime() > fromDate.getTime()) {
+ return todayTarget.toISOString();
+ }
+
+ const tomorrow = new Date(
+ Date.UTC(today.year, today.month - 1, today.day + 1),
+ );
+
+ return zonedDateTimeToUtc(
+ tomorrow.getUTCFullYear(),
+ tomorrow.getUTCMonth() + 1,
+ tomorrow.getUTCDate(),
+ hour,
+ minute,
+ timezone,
+ ).toISOString();
+}
+
+export function formatAutomationDateTime(
+ isoString: string | null | undefined,
+ timezone: string,
+): string {
+ if (!isoString) {
+ return "Not scheduled";
+ }
+
+ const date = new Date(isoString);
+ if (Number.isNaN(date.getTime())) {
+ return "Invalid date";
+ }
+
+ return new Intl.DateTimeFormat(undefined, {
+ timeZone: timezone,
+ month: "short",
+ day: "numeric",
+ hour: "numeric",
+ minute: "2-digit",
+ }).format(date);
+}
+
+export function getLocalTimezone(): string {
+ return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
+}
+
+export function normalizeAutomationSchedule(
+ automation: Automation,
+ now = new Date(),
+): Automation {
+ return {
+ ...automation,
+ nextRunAt: computeNextRunAt(
+ automation.scheduleTime,
+ automation.timezone,
+ now,
+ ),
+ };
+}
diff --git a/apps/code/src/renderer/features/sidebar/components/SidebarMenu.tsx b/apps/code/src/renderer/features/sidebar/components/SidebarMenu.tsx
index a26b1eaf6..db505f9d2 100644
--- a/apps/code/src/renderer/features/sidebar/components/SidebarMenu.tsx
+++ b/apps/code/src/renderer/features/sidebar/components/SidebarMenu.tsx
@@ -1,4 +1,5 @@
import { DotsCircleSpinner } from "@components/DotsCircleSpinner";
+import { useAutomationStore } from "@features/automations/stores/automationStore";
import { useCommandCenterStore } from "@features/command-center/stores/commandCenterStore";
import { useInboxReports } from "@features/inbox/hooks/useInboxReports";
import {
@@ -20,6 +21,7 @@ import { memo, useCallback, useEffect, useRef } from "react";
import { usePinnedTasks } from "../hooks/usePinnedTasks";
import { useSidebarData } from "../hooks/useSidebarData";
import { useTaskViewed } from "../hooks/useTaskViewed";
+import { AutomationsItem } from "./items/AutomationsItem";
import { CommandCenterItem } from "./items/CommandCenterItem";
import { InboxItem, NewTaskItem } from "./items/HomeItem";
import { SkillsItem } from "./items/SkillsItem";
@@ -34,6 +36,7 @@ function SidebarMenuComponent() {
navigateToInbox,
navigateToCommandCenter,
navigateToSkills,
+ navigateToAutomations,
} = useNavigationStore();
const { data: allTasks = [] } = useTasks();
@@ -67,6 +70,10 @@ function SidebarMenuComponent() {
const commandCenterActiveCount = commandCenterCells.filter(
(taskId) => taskId != null,
).length;
+ const automationEnabledCount = useAutomationStore(
+ (state) =>
+ state.automations.filter((automation) => automation.enabled).length,
+ );
const previousTaskIdRef = useRef(null);
@@ -109,6 +116,10 @@ function SidebarMenuComponent() {
navigateToSkills();
};
+ const handleAutomationsClick = () => {
+ navigateToAutomations();
+ };
+
const handleTaskClick = (taskId: string) => {
const task = taskMap.get(taskId);
if (task) {
@@ -217,6 +228,14 @@ function SidebarMenuComponent() {
/>
+
+
+
+
void;
+ activeCount?: number;
+}
+
+export function AutomationsItem({
+ isActive,
+ onClick,
+ activeCount = 0,
+}: AutomationsItemProps) {
+ return (
+
+ }
+ label="Automations"
+ isActive={isActive}
+ onClick={onClick}
+ endContent={
+ activeCount > 0 ? (
+
+ {activeCount > 99 ? "99+" : String(activeCount)}
+
+ ) : undefined
+ }
+ />
+ );
+}
diff --git a/apps/code/src/renderer/features/sidebar/hooks/useSidebarData.ts b/apps/code/src/renderer/features/sidebar/hooks/useSidebarData.ts
index 2a774265a..f46efece0 100644
--- a/apps/code/src/renderer/features/sidebar/hooks/useSidebarData.ts
+++ b/apps/code/src/renderer/features/sidebar/hooks/useSidebarData.ts
@@ -48,6 +48,7 @@ export interface SidebarData {
isInboxActive: boolean;
isCommandCenterActive: boolean;
isSkillsActive: boolean;
+ isAutomationsActive: boolean;
isLoading: boolean;
activeTaskId: string | null;
pinnedTasks: TaskData[];
@@ -66,7 +67,8 @@ interface ViewState {
| "inbox"
| "archived"
| "command-center"
- | "skills";
+ | "skills"
+ | "automations";
data?: Task;
}
@@ -171,6 +173,7 @@ export function useSidebarData({
const isInboxActive = activeView.type === "inbox";
const isCommandCenterActive = activeView.type === "command-center";
const isSkillsActive = activeView.type === "skills";
+ const isAutomationsActive = activeView.type === "automations";
const activeTaskId =
activeView.type === "task-detail" && activeView.data
@@ -280,6 +283,7 @@ export function useSidebarData({
isInboxActive,
isCommandCenterActive,
isSkillsActive,
+ isAutomationsActive,
isLoading,
activeTaskId,
pinnedTasks,
diff --git a/apps/code/src/renderer/stores/navigationStore.ts b/apps/code/src/renderer/stores/navigationStore.ts
index 336c77a2b..f11629b6c 100644
--- a/apps/code/src/renderer/stores/navigationStore.ts
+++ b/apps/code/src/renderer/stores/navigationStore.ts
@@ -19,7 +19,8 @@ type ViewType =
| "inbox"
| "archived"
| "command-center"
- | "skills";
+ | "skills"
+ | "automations";
interface ViewState {
type: ViewType;
@@ -39,6 +40,7 @@ interface NavigationStore {
navigateToArchived: () => void;
navigateToCommandCenter: () => void;
navigateToSkills: () => void;
+ navigateToAutomations: () => void;
goBack: () => void;
goForward: () => void;
canGoBack: () => boolean;
@@ -69,6 +71,9 @@ const isSameView = (view1: ViewState, view2: ViewState): boolean => {
if (view1.type === "skills" && view2.type === "skills") {
return true;
}
+ if (view1.type === "automations" && view2.type === "automations") {
+ return true;
+ }
return false;
};
@@ -183,6 +188,10 @@ export const useNavigationStore = create()(
navigate({ type: "skills" });
},
+ navigateToAutomations: () => {
+ navigate({ type: "automations" });
+ },
+
goBack: () => {
const { history, historyIndex } = get();
if (historyIndex > 0) {
diff --git a/apps/code/src/shared/types/automations.ts b/apps/code/src/shared/types/automations.ts
new file mode 100644
index 000000000..2e7f793b9
--- /dev/null
+++ b/apps/code/src/shared/types/automations.ts
@@ -0,0 +1,43 @@
+import { z } from "zod";
+
+export const automationRunStatusSchema = z.enum([
+ "success",
+ "failed",
+ "skipped",
+ "running",
+]);
+
+export type AutomationRunStatus = z.infer;
+
+export const automationTemplateSchema = z.object({
+ id: z.string(),
+ name: z.string(),
+ description: z.string(),
+ prompt: z.string(),
+ category: z.string(),
+ tags: z.array(z.string()).default([]),
+});
+
+export type AutomationTemplate = z.infer;
+
+export const automationSchema = z.object({
+ id: z.string(),
+ name: z.string(),
+ prompt: z.string(),
+ repoPath: z.string(),
+ repository: z.string().nullable().optional(),
+ githubIntegrationId: z.number().nullable().optional(),
+ scheduleTime: z.string(),
+ timezone: z.string(),
+ enabled: z.boolean(),
+ templateId: z.string().nullable().optional(),
+ createdAt: z.string(),
+ updatedAt: z.string(),
+ nextRunAt: z.string().nullable().optional(),
+ lastRunAt: z.string().nullable().optional(),
+ lastRunStatus: automationRunStatusSchema.nullable().optional(),
+ lastTaskId: z.string().nullable().optional(),
+ lastError: z.string().nullable().optional(),
+});
+
+export type Automation = z.infer;
From a44b888f857fe44416b2cecdeb5e827a70eb4021 Mon Sep 17 00:00:00 2001
From: James Hawkins
Date: Wed, 25 Mar 2026 17:15:07 +0000
Subject: [PATCH 2/4] feat(code): add automations backend - scheduler, DB, tRPC
router
Adds the backend infrastructure for running skills on a cron schedule:
- automations + automation_runs DB tables with drizzle migration
- AutomationRepository for CRUD operations
- AutomationService with setTimeout-based scheduler, agent execution
- tRPC router with full CRUD, triggerNow, run history, subscriptions
- Shared types for automation schedules and run info
Co-Authored-By: Claude Opus 4.6 (1M context)
---
.../db/migrations/0003_legal_dark_phoenix.sql | 25 +
.../db/migrations/meta/0003_snapshot.json | 605 ++++++++++++++++++
.../src/main/db/migrations/meta/_journal.json | 9 +-
.../db/repositories/automation-repository.ts | 180 ++++++
apps/code/src/main/db/schema.ts | 45 +-
apps/code/src/main/di/container.ts | 4 +
apps/code/src/main/di/tokens.ts | 4 +
apps/code/src/main/index.ts | 2 +
.../src/main/services/automation/scheduler.ts | 102 +++
.../src/main/services/automation/service.ts | 412 ++++++++++++
apps/code/src/main/trpc/router.ts | 2 +
.../code/src/main/trpc/routers/automations.ts | 148 +++++
apps/code/src/shared/types/automations.ts | 38 ++
13 files changed, 1574 insertions(+), 2 deletions(-)
create mode 100644 apps/code/src/main/db/migrations/0003_legal_dark_phoenix.sql
create mode 100644 apps/code/src/main/db/migrations/meta/0003_snapshot.json
create mode 100644 apps/code/src/main/db/repositories/automation-repository.ts
create mode 100644 apps/code/src/main/services/automation/scheduler.ts
create mode 100644 apps/code/src/main/services/automation/service.ts
create mode 100644 apps/code/src/main/trpc/routers/automations.ts
create mode 100644 apps/code/src/shared/types/automations.ts
diff --git a/apps/code/src/main/db/migrations/0003_legal_dark_phoenix.sql b/apps/code/src/main/db/migrations/0003_legal_dark_phoenix.sql
new file mode 100644
index 000000000..957ae3999
--- /dev/null
+++ b/apps/code/src/main/db/migrations/0003_legal_dark_phoenix.sql
@@ -0,0 +1,25 @@
+CREATE TABLE `automation_runs` (
+ `id` text PRIMARY KEY NOT NULL,
+ `automation_id` text NOT NULL,
+ `status` text NOT NULL,
+ `output` text,
+ `error` text,
+ `started_at` text NOT NULL,
+ `completed_at` text,
+ `created_at` text DEFAULT (CURRENT_TIMESTAMP) NOT NULL,
+ FOREIGN KEY (`automation_id`) REFERENCES `automations`(`id`) ON UPDATE no action ON DELETE cascade
+);
+--> statement-breakpoint
+CREATE INDEX `automation_runs_automation_id_idx` ON `automation_runs` (`automation_id`);--> statement-breakpoint
+CREATE TABLE `automations` (
+ `id` text PRIMARY KEY NOT NULL,
+ `name` text NOT NULL,
+ `prompt` text NOT NULL,
+ `schedule` text NOT NULL,
+ `enabled` integer DEFAULT true NOT NULL,
+ `last_run_at` text,
+ `last_run_status` text,
+ `last_run_error` text,
+ `created_at` text DEFAULT (CURRENT_TIMESTAMP) NOT NULL,
+ `updated_at` text DEFAULT (CURRENT_TIMESTAMP) NOT NULL
+);
diff --git a/apps/code/src/main/db/migrations/meta/0003_snapshot.json b/apps/code/src/main/db/migrations/meta/0003_snapshot.json
new file mode 100644
index 000000000..1803d9efb
--- /dev/null
+++ b/apps/code/src/main/db/migrations/meta/0003_snapshot.json
@@ -0,0 +1,605 @@
+{
+ "version": "6",
+ "dialect": "sqlite",
+ "id": "2290a5fa-d3b9-4c7e-849b-b0a9eba3074e",
+ "prevId": "f5d77788-5c4e-4bfa-a114-096b8d377332",
+ "tables": {
+ "archives": {
+ "name": "archives",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "workspace_id": {
+ "name": "workspace_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "branch_name": {
+ "name": "branch_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "checkpoint_id": {
+ "name": "checkpoint_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "archived_at": {
+ "name": "archived_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(CURRENT_TIMESTAMP)"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(CURRENT_TIMESTAMP)"
+ }
+ },
+ "indexes": {
+ "archives_workspaceId_unique": {
+ "name": "archives_workspaceId_unique",
+ "columns": [
+ "workspace_id"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "archives_workspace_id_workspaces_id_fk": {
+ "name": "archives_workspace_id_workspaces_id_fk",
+ "tableFrom": "archives",
+ "tableTo": "workspaces",
+ "columnsFrom": [
+ "workspace_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "automation_runs": {
+ "name": "automation_runs",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "automation_id": {
+ "name": "automation_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "output": {
+ "name": "output",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "error": {
+ "name": "error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "started_at": {
+ "name": "started_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "completed_at": {
+ "name": "completed_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(CURRENT_TIMESTAMP)"
+ }
+ },
+ "indexes": {
+ "automation_runs_automation_id_idx": {
+ "name": "automation_runs_automation_id_idx",
+ "columns": [
+ "automation_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "automation_runs_automation_id_automations_id_fk": {
+ "name": "automation_runs_automation_id_automations_id_fk",
+ "tableFrom": "automation_runs",
+ "tableTo": "automations",
+ "columnsFrom": [
+ "automation_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "automations": {
+ "name": "automations",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "prompt": {
+ "name": "prompt",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "schedule": {
+ "name": "schedule",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "last_run_at": {
+ "name": "last_run_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "last_run_status": {
+ "name": "last_run_status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "last_run_error": {
+ "name": "last_run_error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(CURRENT_TIMESTAMP)"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(CURRENT_TIMESTAMP)"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "repositories": {
+ "name": "repositories",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "path": {
+ "name": "path",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "remote_url": {
+ "name": "remote_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "last_accessed_at": {
+ "name": "last_accessed_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(CURRENT_TIMESTAMP)"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(CURRENT_TIMESTAMP)"
+ }
+ },
+ "indexes": {
+ "repositories_path_unique": {
+ "name": "repositories_path_unique",
+ "columns": [
+ "path"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "suspensions": {
+ "name": "suspensions",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "workspace_id": {
+ "name": "workspace_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "branch_name": {
+ "name": "branch_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "checkpoint_id": {
+ "name": "checkpoint_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "suspended_at": {
+ "name": "suspended_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "reason": {
+ "name": "reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(CURRENT_TIMESTAMP)"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(CURRENT_TIMESTAMP)"
+ }
+ },
+ "indexes": {
+ "suspensions_workspaceId_unique": {
+ "name": "suspensions_workspaceId_unique",
+ "columns": [
+ "workspace_id"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "suspensions_workspace_id_workspaces_id_fk": {
+ "name": "suspensions_workspace_id_workspaces_id_fk",
+ "tableFrom": "suspensions",
+ "tableTo": "workspaces",
+ "columnsFrom": [
+ "workspace_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "workspaces": {
+ "name": "workspaces",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "task_id": {
+ "name": "task_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "repository_id": {
+ "name": "repository_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "mode": {
+ "name": "mode",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "pinned_at": {
+ "name": "pinned_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "last_viewed_at": {
+ "name": "last_viewed_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "last_activity_at": {
+ "name": "last_activity_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(CURRENT_TIMESTAMP)"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(CURRENT_TIMESTAMP)"
+ }
+ },
+ "indexes": {
+ "workspaces_taskId_unique": {
+ "name": "workspaces_taskId_unique",
+ "columns": [
+ "task_id"
+ ],
+ "isUnique": true
+ },
+ "workspaces_repository_id_idx": {
+ "name": "workspaces_repository_id_idx",
+ "columns": [
+ "repository_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "workspaces_repository_id_repositories_id_fk": {
+ "name": "workspaces_repository_id_repositories_id_fk",
+ "tableFrom": "workspaces",
+ "tableTo": "repositories",
+ "columnsFrom": [
+ "repository_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "worktrees": {
+ "name": "worktrees",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "workspace_id": {
+ "name": "workspace_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "path": {
+ "name": "path",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(CURRENT_TIMESTAMP)"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(CURRENT_TIMESTAMP)"
+ }
+ },
+ "indexes": {
+ "worktrees_workspaceId_unique": {
+ "name": "worktrees_workspaceId_unique",
+ "columns": [
+ "workspace_id"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "worktrees_workspace_id_workspaces_id_fk": {
+ "name": "worktrees_workspace_id_workspaces_id_fk",
+ "tableFrom": "worktrees",
+ "tableTo": "workspaces",
+ "columnsFrom": [
+ "workspace_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ }
+ },
+ "views": {},
+ "enums": {},
+ "_meta": {
+ "schemas": {},
+ "tables": {},
+ "columns": {}
+ },
+ "internal": {
+ "indexes": {}
+ }
+}
\ No newline at end of file
diff --git a/apps/code/src/main/db/migrations/meta/_journal.json b/apps/code/src/main/db/migrations/meta/_journal.json
index 3583bdfb3..62234b506 100644
--- a/apps/code/src/main/db/migrations/meta/_journal.json
+++ b/apps/code/src/main/db/migrations/meta/_journal.json
@@ -22,6 +22,13 @@
"when": 1773335630838,
"tag": "0002_massive_bishop",
"breakpoints": true
+ },
+ {
+ "idx": 3,
+ "version": "6",
+ "when": 1774457908529,
+ "tag": "0003_legal_dark_phoenix",
+ "breakpoints": true
}
]
-}
+}
\ No newline at end of file
diff --git a/apps/code/src/main/db/repositories/automation-repository.ts b/apps/code/src/main/db/repositories/automation-repository.ts
new file mode 100644
index 000000000..7582921c4
--- /dev/null
+++ b/apps/code/src/main/db/repositories/automation-repository.ts
@@ -0,0 +1,180 @@
+import { desc, eq } from "drizzle-orm";
+import { inject, injectable } from "inversify";
+import { MAIN_TOKENS } from "../../di/tokens";
+import { automationRuns, automations } from "../schema";
+import type { DatabaseService } from "../service";
+
+export type Automation = typeof automations.$inferSelect;
+export type NewAutomation = typeof automations.$inferInsert;
+export type AutomationRun = typeof automationRuns.$inferSelect;
+export type NewAutomationRun = typeof automationRuns.$inferInsert;
+
+export interface CreateAutomationData {
+ name: string;
+ prompt: string;
+ schedule: Automation["schedule"];
+ enabled?: boolean;
+}
+
+export interface UpdateAutomationData {
+ name?: string;
+ prompt?: string;
+ schedule?: Automation["schedule"];
+ enabled?: boolean;
+}
+
+export interface CreateRunData {
+ automationId: string;
+}
+
+const byId = (id: string) => eq(automations.id, id);
+const runByAutomationId = (automationId: string) =>
+ eq(automationRuns.automationId, automationId);
+const now = () => new Date().toISOString();
+
+@injectable()
+export class AutomationRepository {
+ constructor(
+ @inject(MAIN_TOKENS.DatabaseService)
+ private readonly databaseService: DatabaseService,
+ ) {}
+
+ private get db() {
+ return this.databaseService.db;
+ }
+
+ findById(id: string): Automation | null {
+ return this.db.select().from(automations).where(byId(id)).get() ?? null;
+ }
+
+ findAll(): Automation[] {
+ return this.db
+ .select()
+ .from(automations)
+ .orderBy(desc(automations.createdAt))
+ .all();
+ }
+
+ findEnabled(): Automation[] {
+ return this.db
+ .select()
+ .from(automations)
+ .where(eq(automations.enabled, true))
+ .all();
+ }
+
+ create(data: CreateAutomationData): Automation {
+ const timestamp = now();
+ const id = crypto.randomUUID();
+ const row: NewAutomation = {
+ id,
+ name: data.name,
+ prompt: data.prompt,
+ schedule: data.schedule,
+ enabled: data.enabled ?? true,
+ createdAt: timestamp,
+ updatedAt: timestamp,
+ };
+ this.db.insert(automations).values(row).run();
+ const created = this.findById(id);
+ if (!created) {
+ throw new Error(`Failed to create automation with id ${id}`);
+ }
+ return created;
+ }
+
+ update(id: string, data: UpdateAutomationData): Automation {
+ const updates: Partial = {
+ updatedAt: now(),
+ };
+ if (data.name !== undefined) updates.name = data.name;
+ if (data.prompt !== undefined) updates.prompt = data.prompt;
+ if (data.schedule !== undefined) updates.schedule = data.schedule;
+ if (data.enabled !== undefined) updates.enabled = data.enabled;
+
+ this.db.update(automations).set(updates).where(byId(id)).run();
+ const updated = this.findById(id);
+ if (!updated) {
+ throw new Error(`Automation not found: ${id}`);
+ }
+ return updated;
+ }
+
+ updateLastRun(
+ id: string,
+ status: "success" | "error" | "running",
+ error?: string,
+ ): void {
+ this.db
+ .update(automations)
+ .set({
+ lastRunAt: now(),
+ lastRunStatus: status,
+ lastRunError: error ?? null,
+ updatedAt: now(),
+ })
+ .where(byId(id))
+ .run();
+ }
+
+ deleteById(id: string): void {
+ this.db.delete(automations).where(byId(id)).run();
+ }
+
+ // --- Runs ---
+
+ createRun(data: CreateRunData): AutomationRun {
+ const timestamp = now();
+ const id = crypto.randomUUID();
+ const row: NewAutomationRun = {
+ id,
+ automationId: data.automationId,
+ status: "running",
+ startedAt: timestamp,
+ createdAt: timestamp,
+ };
+ this.db.insert(automationRuns).values(row).run();
+ return this.db
+ .select()
+ .from(automationRuns)
+ .where(eq(automationRuns.id, id))
+ .get()!;
+ }
+
+ completeRun(
+ runId: string,
+ status: "success" | "error",
+ output?: string,
+ error?: string,
+ ): void {
+ this.db
+ .update(automationRuns)
+ .set({
+ status,
+ output: output ?? null,
+ error: error ?? null,
+ completedAt: now(),
+ })
+ .where(eq(automationRuns.id, runId))
+ .run();
+ }
+
+ findRunsByAutomationId(automationId: string, limit = 20): AutomationRun[] {
+ return this.db
+ .select()
+ .from(automationRuns)
+ .where(runByAutomationId(automationId))
+ .orderBy(desc(automationRuns.startedAt))
+ .limit(limit)
+ .all();
+ }
+
+ findRecentRuns(limit = 50): AutomationRun[] {
+ return this.db
+ .select()
+ .from(automationRuns)
+ .orderBy(desc(automationRuns.startedAt))
+ .limit(limit)
+ .all();
+ }
+}
diff --git a/apps/code/src/main/db/schema.ts b/apps/code/src/main/db/schema.ts
index 677932b39..d04b84b0d 100644
--- a/apps/code/src/main/db/schema.ts
+++ b/apps/code/src/main/db/schema.ts
@@ -1,5 +1,5 @@
import { sql } from "drizzle-orm";
-import { index, sqliteTable, text } from "drizzle-orm/sqlite-core";
+import { index, integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
const id = () =>
text()
@@ -76,3 +76,46 @@ export const suspensions = sqliteTable("suspensions", {
createdAt: createdAt(),
updatedAt: updatedAt(),
});
+
+export const AUTOMATION_SCHEDULES = [
+ "every_15_minutes",
+ "every_hour",
+ "every_4_hours",
+ "daily_9am",
+ "daily_12pm",
+ "daily_6pm",
+ "weekday_mornings",
+ "weekly_monday_9am",
+] as const;
+
+export const automations = sqliteTable("automations", {
+ id: id(),
+ name: text().notNull(),
+ prompt: text().notNull(),
+ schedule: text({
+ enum: AUTOMATION_SCHEDULES,
+ }).notNull(),
+ enabled: integer({ mode: "boolean" }).notNull().default(true),
+ lastRunAt: text(),
+ lastRunStatus: text({ enum: ["success", "error", "running"] }),
+ lastRunError: text(),
+ createdAt: createdAt(),
+ updatedAt: updatedAt(),
+});
+
+export const automationRuns = sqliteTable(
+ "automation_runs",
+ {
+ id: id(),
+ automationId: text()
+ .notNull()
+ .references(() => automations.id, { onDelete: "cascade" }),
+ status: text({ enum: ["running", "success", "error"] }).notNull(),
+ output: text(),
+ error: text(),
+ startedAt: text().notNull(),
+ completedAt: text(),
+ createdAt: createdAt(),
+ },
+ (t) => [index("automation_runs_automation_id_idx").on(t.automationId)],
+);
diff --git a/apps/code/src/main/di/container.ts b/apps/code/src/main/di/container.ts
index 4f94a1ca0..0a8df3cbd 100644
--- a/apps/code/src/main/di/container.ts
+++ b/apps/code/src/main/di/container.ts
@@ -2,6 +2,7 @@ import "reflect-metadata";
import { Container } from "inversify";
import { ArchiveRepository } from "../db/repositories/archive-repository";
+import { AutomationRepository } from "../db/repositories/automation-repository";
import { RepositoryRepository } from "../db/repositories/repository-repository";
import { SuspensionRepositoryImpl } from "../db/repositories/suspension-repository";
import { WorkspaceRepository } from "../db/repositories/workspace-repository";
@@ -11,6 +12,7 @@ import { AgentService } from "../services/agent/service";
import { AppLifecycleService } from "../services/app-lifecycle/service";
import { ArchiveService } from "../services/archive/service";
import { AuthProxyService } from "../services/auth-proxy/service";
+import { AutomationService } from "../services/automation/service";
import { CloudTaskService } from "../services/cloud-task/service";
import { ConnectivityService } from "../services/connectivity/service";
import { ContextMenuService } from "../services/context-menu/service";
@@ -53,8 +55,10 @@ container.bind(MAIN_TOKENS.RepositoryRepository).to(RepositoryRepository);
container.bind(MAIN_TOKENS.WorkspaceRepository).to(WorkspaceRepository);
container.bind(MAIN_TOKENS.WorktreeRepository).to(WorktreeRepository);
container.bind(MAIN_TOKENS.ArchiveRepository).to(ArchiveRepository);
+container.bind(MAIN_TOKENS.AutomationRepository).to(AutomationRepository);
container.bind(MAIN_TOKENS.SuspensionRepository).to(SuspensionRepositoryImpl);
container.bind(MAIN_TOKENS.AgentService).to(AgentService);
+container.bind(MAIN_TOKENS.AutomationService).to(AutomationService);
container.bind(MAIN_TOKENS.AuthProxyService).to(AuthProxyService);
container.bind(MAIN_TOKENS.ArchiveService).to(ArchiveService);
container.bind(MAIN_TOKENS.SuspensionService).to(SuspensionService);
diff --git a/apps/code/src/main/di/tokens.ts b/apps/code/src/main/di/tokens.ts
index a11400b67..8e2e6affc 100644
--- a/apps/code/src/main/di/tokens.ts
+++ b/apps/code/src/main/di/tokens.ts
@@ -16,8 +16,12 @@ export const MAIN_TOKENS = Object.freeze({
ArchiveRepository: Symbol.for("Main.ArchiveRepository"),
SuspensionRepository: Symbol.for("Main.SuspensionRepository"),
+ // Repositories
+ AutomationRepository: Symbol.for("Main.AutomationRepository"),
+
// Services
AgentService: Symbol.for("Main.AgentService"),
+ AutomationService: Symbol.for("Main.AutomationService"),
AuthProxyService: Symbol.for("Main.AuthProxyService"),
ArchiveService: Symbol.for("Main.ArchiveService"),
SuspensionService: Symbol.for("Main.SuspensionService"),
diff --git a/apps/code/src/main/index.ts b/apps/code/src/main/index.ts
index 09c683ef1..b94df2209 100644
--- a/apps/code/src/main/index.ts
+++ b/apps/code/src/main/index.ts
@@ -11,6 +11,7 @@ import { container } from "./di/container";
import { MAIN_TOKENS } from "./di/tokens";
import { registerMcpSandboxProtocol } from "./protocols/mcp-sandbox";
import type { AppLifecycleService } from "./services/app-lifecycle/service";
+import type { AutomationService } from "./services/automation/service";
import type { ExternalAppsService } from "./services/external-apps/service";
import type { NotificationService } from "./services/notification/service";
import type { OAuthService } from "./services/oauth/service";
@@ -43,6 +44,7 @@ function initializeServices(): void {
container.get(MAIN_TOKENS.TaskLinkService);
container.get(MAIN_TOKENS.ExternalAppsService);
container.get(MAIN_TOKENS.PosthogPluginService);
+ container.get(MAIN_TOKENS.AutomationService);
// Initialize workspace branch watcher for live branch rename detection
const workspaceService = container.get(
diff --git a/apps/code/src/main/services/automation/scheduler.ts b/apps/code/src/main/services/automation/scheduler.ts
new file mode 100644
index 000000000..d8ca1c6f6
--- /dev/null
+++ b/apps/code/src/main/services/automation/scheduler.ts
@@ -0,0 +1,102 @@
+import type { AutomationSchedule } from "@shared/types/automations";
+
+/**
+ * Calculate the next run time for a given schedule from a reference point.
+ * Returns a Date representing when the automation should next fire.
+ */
+export function getNextRunTime(
+ schedule: AutomationSchedule,
+ from: Date = new Date(),
+): Date {
+ const next = new Date(from);
+
+ switch (schedule) {
+ case "every_15_minutes": {
+ next.setMinutes(next.getMinutes() + 15);
+ next.setSeconds(0, 0);
+ return next;
+ }
+ case "every_hour": {
+ next.setHours(next.getHours() + 1);
+ next.setMinutes(0, 0, 0);
+ return next;
+ }
+ case "every_4_hours": {
+ next.setHours(next.getHours() + 4);
+ next.setMinutes(0, 0, 0);
+ return next;
+ }
+ case "daily_9am": {
+ return getNextTimeOfDay(next, 9, 0);
+ }
+ case "daily_12pm": {
+ return getNextTimeOfDay(next, 12, 0);
+ }
+ case "daily_6pm": {
+ return getNextTimeOfDay(next, 18, 0);
+ }
+ case "weekday_mornings": {
+ return getNextWeekdayAt(next, 9, 0);
+ }
+ case "weekly_monday_9am": {
+ return getNextDayOfWeekAt(next, 1, 9, 0);
+ }
+ default: {
+ // Fallback: 1 hour from now
+ next.setHours(next.getHours() + 1);
+ return next;
+ }
+ }
+}
+
+function getNextTimeOfDay(from: Date, hour: number, minute: number): Date {
+ const next = new Date(from);
+ next.setSeconds(0, 0);
+ // If past today's time, schedule for tomorrow
+ if (
+ next.getHours() > hour ||
+ (next.getHours() === hour && next.getMinutes() >= minute)
+ ) {
+ next.setDate(next.getDate() + 1);
+ }
+ next.setHours(hour, minute, 0, 0);
+ return next;
+}
+
+function getNextWeekdayAt(from: Date, hour: number, minute: number): Date {
+ const next = getNextTimeOfDay(from, hour, minute);
+ // Skip weekends
+ while (next.getDay() === 0 || next.getDay() === 6) {
+ next.setDate(next.getDate() + 1);
+ }
+ return next;
+}
+
+function getNextDayOfWeekAt(
+ from: Date,
+ dayOfWeek: number,
+ hour: number,
+ minute: number,
+): Date {
+ const next = new Date(from);
+ next.setHours(hour, minute, 0, 0);
+ // Find next occurrence of the target day
+ const currentDay = next.getDay();
+ let daysUntil = dayOfWeek - currentDay;
+ if (daysUntil < 0) daysUntil += 7;
+ if (daysUntil === 0 && from >= next) daysUntil = 7;
+ next.setDate(next.getDate() + daysUntil);
+ return next;
+}
+
+/**
+ * Get the delay in ms until the next run time.
+ * Returns at least 1000ms to prevent tight loops.
+ */
+export function getDelayMs(
+ schedule: AutomationSchedule,
+ from: Date = new Date(),
+): number {
+ const nextRun = getNextRunTime(schedule, from);
+ return Math.max(1000, nextRun.getTime() - from.getTime());
+}
diff --git a/apps/code/src/main/services/automation/service.ts b/apps/code/src/main/services/automation/service.ts
new file mode 100644
index 000000000..405ec957e
--- /dev/null
+++ b/apps/code/src/main/services/automation/service.ts
@@ -0,0 +1,412 @@
+import { tmpdir } from "node:os";
+import type {
+ AutomationInfo,
+ AutomationRunInfo,
+ AutomationSchedule,
+} from "@shared/types/automations";
+import { powerMonitor } from "electron";
+import { inject, injectable, postConstruct, preDestroy } from "inversify";
+import type {
+ Automation,
+ AutomationRepository,
+ AutomationRun,
+} from "../../db/repositories/automation-repository";
+import { MAIN_TOKENS } from "../../di/tokens";
+import { logger } from "../../utils/logger";
+import { TypedEventEmitter } from "../../utils/typed-event-emitter";
+import type { AgentService } from "../agent/service";
+import { getDelayMs, getNextRunTime } from "./scheduler";
+
+const log = logger.scope("automation-service");
+
+export const AutomationServiceEvent = {
+ AutomationCreated: "automation-created",
+ AutomationUpdated: "automation-updated",
+ AutomationDeleted: "automation-deleted",
+ RunStarted: "run-started",
+ RunCompleted: "run-completed",
+} as const;
+
+export interface AutomationServiceEvents {
+ [AutomationServiceEvent.AutomationCreated]: AutomationInfo;
+ [AutomationServiceEvent.AutomationUpdated]: AutomationInfo;
+ [AutomationServiceEvent.AutomationDeleted]: { id: string };
+ [AutomationServiceEvent.RunStarted]: AutomationRunInfo;
+ [AutomationServiceEvent.RunCompleted]: AutomationRunInfo;
+}
+
+interface ScheduledJob {
+ automationId: string;
+ timer: ReturnType;
+ nextRunAt: Date;
+}
+
+/** Credentials needed to start an agent session for automations */
+export interface AutomationCredentials {
+ apiKey: string;
+ apiHost: string;
+ projectId: number;
+}
+
+@injectable()
+export class AutomationService extends TypedEventEmitter {
+ private jobs = new Map();
+ private runningAutomations = new Set();
+ private credentials: AutomationCredentials | null = null;
+
+ constructor(
+ @inject(MAIN_TOKENS.AutomationRepository)
+ private readonly repo: AutomationRepository,
+ @inject(MAIN_TOKENS.AgentService)
+ private readonly agentService: AgentService,
+ ) {
+ super();
+ }
+
+ @postConstruct()
+ init(): void {
+ log.info("Initializing automation service");
+
+ // Reschedule timers after system wake
+ powerMonitor.on("resume", () => {
+ log.info("System resumed, rescheduling automations");
+ this.rescheduleAll();
+ });
+ }
+
+ /**
+ * Store credentials for running automations.
+ * Called from the renderer when auth state changes.
+ */
+ setCredentials(creds: AutomationCredentials): void {
+ this.credentials = creds;
+
+ // If we have credentials and no jobs scheduled, load from DB
+ if (this.jobs.size === 0) {
+ this.loadAndScheduleAll();
+ }
+ }
+
+ clearCredentials(): void {
+ this.credentials = null;
+ this.cancelAllJobs();
+ }
+
+ @preDestroy()
+ shutdown(): void {
+ log.info("Shutting down automation service");
+ this.cancelAllJobs();
+ }
+
+ // --- CRUD ---
+
+ create(data: {
+ name: string;
+ prompt: string;
+ schedule: AutomationSchedule;
+ }): AutomationInfo {
+ const automation = this.repo.create({
+ name: data.name,
+ prompt: data.prompt,
+ schedule: data.schedule,
+ enabled: true,
+ });
+ const info = this.toAutomationInfo(automation);
+ this.scheduleJob(automation);
+ this.emit(AutomationServiceEvent.AutomationCreated, info);
+ log.info("Created automation", { id: automation.id, name: data.name });
+ return info;
+ }
+
+ update(
+ id: string,
+ data: {
+ name?: string;
+ prompt?: string;
+ schedule?: AutomationSchedule;
+ enabled?: boolean;
+ },
+ ): AutomationInfo {
+ const automation = this.repo.update(id, data);
+ const info = this.toAutomationInfo(automation);
+
+ // Reschedule the job
+ this.cancelJob(id);
+ if (automation.enabled) {
+ this.scheduleJob(automation);
+ }
+
+ this.emit(AutomationServiceEvent.AutomationUpdated, info);
+ log.info("Updated automation", { id, ...data });
+ return info;
+ }
+
+ delete(id: string): void {
+ this.cancelJob(id);
+ this.repo.deleteById(id);
+ this.emit(AutomationServiceEvent.AutomationDeleted, { id });
+ log.info("Deleted automation", { id });
+ }
+
+ list(): AutomationInfo[] {
+ return this.repo.findAll().map((a) => this.toAutomationInfo(a));
+ }
+
+ getById(id: string): AutomationInfo | null {
+ const automation = this.repo.findById(id);
+ return automation ? this.toAutomationInfo(automation) : null;
+ }
+
+ getRuns(automationId: string, limit = 20): AutomationRunInfo[] {
+ return this.repo
+ .findRunsByAutomationId(automationId, limit)
+ .map(this.toRunInfo);
+ }
+
+ getRecentRuns(limit = 50): AutomationRunInfo[] {
+ return this.repo.findRecentRuns(limit).map(this.toRunInfo);
+ }
+
+ /** Manually trigger an automation right now */
+ async triggerNow(id: string): Promise {
+ const automation = this.repo.findById(id);
+ if (!automation) {
+ throw new Error(`Automation not found: ${id}`);
+ }
+ return this.executeAutomation(automation);
+ }
+
+ // --- Scheduling ---
+
+ private loadAndScheduleAll(): void {
+ const automations = this.repo.findEnabled();
+ log.info("Loading automations", { count: automations.length });
+ for (const automation of automations) {
+ this.scheduleJob(automation);
+ }
+ }
+
+ private rescheduleAll(): void {
+ this.cancelAllJobs();
+ if (this.credentials) {
+ this.loadAndScheduleAll();
+ }
+ }
+
+ private scheduleJob(automation: Automation): void {
+ if (!automation.enabled) return;
+
+ const nextRunAt = getNextRunTime(automation.schedule as AutomationSchedule);
+ const delayMs = getDelayMs(automation.schedule as AutomationSchedule);
+
+ log.info("Scheduling automation", {
+ id: automation.id,
+ name: automation.name,
+ schedule: automation.schedule,
+ nextRunAt: nextRunAt.toISOString(),
+ delayMs,
+ });
+
+ const timer = setTimeout(() => {
+ this.onJobFired(automation.id);
+ }, delayMs);
+
+ // Prevent the timer from keeping the process alive
+ timer.unref();
+
+ this.jobs.set(automation.id, {
+ automationId: automation.id,
+ timer,
+ nextRunAt,
+ });
+ }
+
+ private cancelJob(id: string): void {
+ const job = this.jobs.get(id);
+ if (job) {
+ clearTimeout(job.timer);
+ this.jobs.delete(id);
+ }
+ }
+
+ private cancelAllJobs(): void {
+ for (const [id, job] of this.jobs) {
+ clearTimeout(job.timer);
+ this.jobs.delete(id);
+ }
+ }
+
+ private async onJobFired(automationId: string): Promise {
+ // Remove the expired job entry
+ this.jobs.delete(automationId);
+
+ // Re-read from DB in case it was updated/deleted
+ const automation = this.repo.findById(automationId);
+ if (!automation || !automation.enabled) {
+ log.info("Automation disabled or deleted, skipping", { automationId });
+ return;
+ }
+
+ // Skip if already running
+ if (this.runningAutomations.has(automationId)) {
+ log.warn("Automation already running, skipping", { automationId });
+ this.scheduleJob(automation);
+ return;
+ }
+
+ try {
+ await this.executeAutomation(automation);
+ } catch (err) {
+ log.error("Failed to execute automation", {
+ automationId,
+ error: err instanceof Error ? err.message : String(err),
+ });
+ }
+
+ // Reschedule for the next run (re-read from DB in case it changed)
+ const current = this.repo.findById(automationId);
+ if (current?.enabled) {
+ this.scheduleJob(current);
+ }
+ }
+
+ // --- Execution ---
+
+ private async executeAutomation(
+ automation: Automation,
+ ): Promise {
+ if (!this.credentials) {
+ throw new Error("No credentials available for automation execution");
+ }
+
+ this.runningAutomations.add(automation.id);
+ this.repo.updateLastRun(automation.id, "running");
+
+ const run = this.repo.createRun({ automationId: automation.id });
+ const runInfo = this.toRunInfo(run);
+ this.emit(AutomationServiceEvent.RunStarted, runInfo);
+
+ log.info("Executing automation", {
+ automationId: automation.id,
+ name: automation.name,
+ runId: run.id,
+ });
+
+ try {
+ const output = await this.runAgent(automation.prompt);
+
+ this.repo.completeRun(run.id, "success", output);
+ this.repo.updateLastRun(automation.id, "success");
+
+ const completedRun = this.toRunInfo({
+ ...run,
+ status: "success",
+ output,
+ completedAt: new Date().toISOString(),
+ });
+ this.emit(AutomationServiceEvent.RunCompleted, completedRun);
+
+ log.info("Automation completed successfully", {
+ automationId: automation.id,
+ runId: run.id,
+ });
+ return completedRun;
+ } catch (err) {
+ const errorMsg = err instanceof Error ? err.message : String(err);
+ this.repo.completeRun(run.id, "error", undefined, errorMsg);
+ this.repo.updateLastRun(automation.id, "error", errorMsg);
+
+ const failedRun = this.toRunInfo({
+ ...run,
+ status: "error",
+ error: errorMsg,
+ completedAt: new Date().toISOString(),
+ });
+ this.emit(AutomationServiceEvent.RunCompleted, failedRun);
+
+ log.error("Automation failed", {
+ automationId: automation.id,
+ runId: run.id,
+ error: errorMsg,
+ });
+ return failedRun;
+ } finally {
+ this.runningAutomations.delete(automation.id);
+ }
+ }
+
+ private async runAgent(prompt: string): Promise {
+ if (!this.credentials) {
+ throw new Error("No credentials available");
+ }
+
+ const taskId = `automation-${crypto.randomUUID()}`;
+ const taskRunId = `${taskId}:run`;
+
+ try {
+ // Start a new agent session with bypassPermissions mode
+ // so it runs fully autonomously
+ const session = await this.agentService.startSession({
+ taskId,
+ taskRunId,
+ repoPath: tmpdir(),
+ apiKey: this.credentials.apiKey,
+ apiHost: this.credentials.apiHost,
+ projectId: this.credentials.projectId,
+ permissionMode: "bypassPermissions",
+ adapter: "claude",
+ });
+
+ // Send the automation prompt
+ const result = await this.agentService.prompt(session.sessionId, [
+ {
+ type: "text",
+ text: prompt,
+ },
+ ]);
+
+ // Collect response text from session events
+ // For now, return the stop reason as a simple status
+ return `Completed with stop reason: ${result.stopReason}`;
+ } finally {
+ // Clean up the session
+ try {
+ await this.agentService.cancelSession(taskRunId);
+ } catch {
+ // Session may already be cleaned up
+ }
+ }
+ }
+
+ // --- Conversion helpers ---
+
+ private toAutomationInfo(automation: Automation): AutomationInfo {
+ const job = this.jobs.get(automation.id);
+ return {
+ id: automation.id,
+ name: automation.name,
+ prompt: automation.prompt,
+ schedule: automation.schedule as AutomationSchedule,
+ enabled: automation.enabled,
+ lastRunAt: automation.lastRunAt,
+ lastRunStatus:
+ automation.lastRunStatus as AutomationInfo["lastRunStatus"],
+ lastRunError: automation.lastRunError,
+ nextRunAt: job ? job.nextRunAt.toISOString() : null,
+ createdAt: automation.createdAt,
+ updatedAt: automation.updatedAt,
+ };
+ }
+
+ private toRunInfo(run: AutomationRun): AutomationRunInfo {
+ return {
+ id: run.id,
+ automationId: run.automationId,
+ status: run.status,
+ output: run.output,
+ error: run.error,
+ startedAt: run.startedAt,
+ completedAt: run.completedAt,
+ };
+ }
+}
diff --git a/apps/code/src/main/trpc/router.ts b/apps/code/src/main/trpc/router.ts
index 73a960569..c026d2fdc 100644
--- a/apps/code/src/main/trpc/router.ts
+++ b/apps/code/src/main/trpc/router.ts
@@ -1,6 +1,7 @@
import { agentRouter } from "./routers/agent";
import { analyticsRouter } from "./routers/analytics";
import { archiveRouter } from "./routers/archive";
+import { automationsRouter } from "./routers/automations";
import { cloudTaskRouter } from "./routers/cloud-task";
import { connectivityRouter } from "./routers/connectivity";
import { contextMenuRouter } from "./routers/context-menu";
@@ -38,6 +39,7 @@ export const trpcRouter = router({
agent: agentRouter,
analytics: analyticsRouter,
archive: archiveRouter,
+ automations: automationsRouter,
cloudTask: cloudTaskRouter,
connectivity: connectivityRouter,
contextMenu: contextMenuRouter,
diff --git a/apps/code/src/main/trpc/routers/automations.ts b/apps/code/src/main/trpc/routers/automations.ts
new file mode 100644
index 000000000..900c8260a
--- /dev/null
+++ b/apps/code/src/main/trpc/routers/automations.ts
@@ -0,0 +1,148 @@
+import { z } from "zod";
+import { AUTOMATION_SCHEDULES } from "../../db/schema";
+import { container } from "../../di/container";
+import { MAIN_TOKENS } from "../../di/tokens";
+import {
+ type AutomationService,
+ AutomationServiceEvent,
+} from "../../services/automation/service";
+import { publicProcedure, router } from "../trpc";
+
+const getService = () =>
+ container.get(MAIN_TOKENS.AutomationService);
+
+const automationScheduleSchema = z.enum(AUTOMATION_SCHEDULES);
+
+export const automationsRouter = router({
+ list: publicProcedure.query(() => {
+ return getService().list();
+ }),
+
+ getById: publicProcedure
+ .input(z.object({ id: z.string() }))
+ .query(({ input }) => {
+ return getService().getById(input.id);
+ }),
+
+ create: publicProcedure
+ .input(
+ z.object({
+ name: z.string().min(1).max(200),
+ prompt: z.string().min(1).max(10000),
+ schedule: automationScheduleSchema,
+ }),
+ )
+ .mutation(({ input }) => {
+ return getService().create(input);
+ }),
+
+ update: publicProcedure
+ .input(
+ z.object({
+ id: z.string(),
+ name: z.string().min(1).max(200).optional(),
+ prompt: z.string().min(1).max(10000).optional(),
+ schedule: automationScheduleSchema.optional(),
+ enabled: z.boolean().optional(),
+ }),
+ )
+ .mutation(({ input }) => {
+ const { id, ...data } = input;
+ return getService().update(id, data);
+ }),
+
+ delete: publicProcedure
+ .input(z.object({ id: z.string() }))
+ .mutation(({ input }) => {
+ getService().delete(input.id);
+ return { success: true };
+ }),
+
+ triggerNow: publicProcedure
+ .input(z.object({ id: z.string() }))
+ .mutation(async ({ input }) => {
+ return getService().triggerNow(input.id);
+ }),
+
+ getRuns: publicProcedure
+ .input(
+ z.object({
+ automationId: z.string(),
+ limit: z.number().min(1).max(100).optional(),
+ }),
+ )
+ .query(({ input }) => {
+ return getService().getRuns(input.automationId, input.limit);
+ }),
+
+ getRecentRuns: publicProcedure
+ .input(z.object({ limit: z.number().min(1).max(100).optional() }))
+ .query(({ input }) => {
+ return getService().getRecentRuns(input.limit);
+ }),
+
+ setCredentials: publicProcedure
+ .input(
+ z.object({
+ apiKey: z.string(),
+ apiHost: z.string(),
+ projectId: z.number(),
+ }),
+ )
+ .mutation(({ input }) => {
+ getService().setCredentials(input);
+ return { success: true };
+ }),
+
+ // --- Subscriptions ---
+
+ onAutomationCreated: publicProcedure.subscription(async function* (opts) {
+ const service = getService();
+ for await (const data of service.toIterable(
+ AutomationServiceEvent.AutomationCreated,
+ { signal: opts.signal },
+ )) {
+ yield data;
+ }
+ }),
+
+ onAutomationUpdated: publicProcedure.subscription(async function* (opts) {
+ const service = getService();
+ for await (const data of service.toIterable(
+ AutomationServiceEvent.AutomationUpdated,
+ { signal: opts.signal },
+ )) {
+ yield data;
+ }
+ }),
+
+ onAutomationDeleted: publicProcedure.subscription(async function* (opts) {
+ const service = getService();
+ for await (const data of service.toIterable(
+ AutomationServiceEvent.AutomationDeleted,
+ { signal: opts.signal },
+ )) {
+ yield data;
+ }
+ }),
+
+ onRunStarted: publicProcedure.subscription(async function* (opts) {
+ const service = getService();
+ for await (const data of service.toIterable(
+ AutomationServiceEvent.RunStarted,
+ { signal: opts.signal },
+ )) {
+ yield data;
+ }
+ }),
+
+ onRunCompleted: publicProcedure.subscription(async function* (opts) {
+ const service = getService();
+ for await (const data of service.toIterable(
+ AutomationServiceEvent.RunCompleted,
+ { signal: opts.signal },
+ )) {
+ yield data;
+ }
+ }),
+});
diff --git a/apps/code/src/shared/types/automations.ts b/apps/code/src/shared/types/automations.ts
new file mode 100644
index 000000000..6ca30abb6
--- /dev/null
+++ b/apps/code/src/shared/types/automations.ts
@@ -0,0 +1,38 @@
+import type { AUTOMATION_SCHEDULES } from "../../main/db/schema";
+
+export type AutomationSchedule = (typeof AUTOMATION_SCHEDULES)[number];
+
+export interface AutomationInfo {
+ id: string;
+ name: string;
+ prompt: string;
+ schedule: AutomationSchedule;
+ enabled: boolean;
+ lastRunAt: string | null;
+ lastRunStatus: "success" | "error" | "running" | null;
+ lastRunError: string | null;
+ nextRunAt: string | null;
+ createdAt: string;
+ updatedAt: string;
+}
+
+export interface AutomationRunInfo {
+ id: string;
+ automationId: string;
+ status: "running" | "success" | "error";
+ output: string | null;
+ error: string | null;
+ startedAt: string;
+ completedAt: string | null;
+}
+
+export const SCHEDULE_LABELS: Record = {
+ every_15_minutes: "Every 15 minutes",
+ every_hour: "Every hour",
+ every_4_hours: "Every 4 hours",
+ daily_9am: "Daily at 9:00 AM",
+ daily_12pm: "Daily at 12:00 PM",
+ daily_6pm: "Daily at 6:00 PM",
+ weekday_mornings: "Weekday mornings at 9:00 AM",
+ weekly_monday_9am: "Weekly on Monday at 9:00 AM",
+};
From b10d5b93d493ef1a6e89db16707ce0c1715de4ad Mon Sep 17 00:00:00 2001
From: James Hawkins
Date: Wed, 25 Mar 2026 17:47:12 +0000
Subject: [PATCH 3/4] feat(code): run automations in cloud sandbox instead of
locally
Switch workspaceMode from "local" to "cloud" so automation tasks
execute in PostHog cloud sandboxes rather than local agent sessions.
Works even after laptop closes - missed runs catch up on app startup.
Co-Authored-By: Claude Opus 4.6 (1M context)
---
.../features/automations/hooks/useAutomationScheduler.ts | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/apps/code/src/renderer/features/automations/hooks/useAutomationScheduler.ts b/apps/code/src/renderer/features/automations/hooks/useAutomationScheduler.ts
index bbc195ff0..adf7516f3 100644
--- a/apps/code/src/renderer/features/automations/hooks/useAutomationScheduler.ts
+++ b/apps/code/src/renderer/features/automations/hooks/useAutomationScheduler.ts
@@ -56,7 +56,7 @@ export function useAutomationScheduler(): void {
repoPath: automation.repoPath,
repository: automation.repository ?? undefined,
githubIntegrationId: automation.githubIntegrationId ?? undefined,
- workspaceMode: "local",
+ workspaceMode: "cloud",
});
if (!result.success) {
@@ -129,7 +129,7 @@ export async function runAutomationNow(automationId: string): Promise {
repoPath: automation.repoPath,
repository: automation.repository ?? undefined,
githubIntegrationId: automation.githubIntegrationId ?? undefined,
- workspaceMode: "local",
+ workspaceMode: "cloud",
});
if (!result.success) {
From ef50a2744c7397156f36caf4f903459e8d5eca33 Mon Sep 17 00:00:00 2001
From: Annika <14750837+annikaschmid@users.noreply.github.com>
Date: Mon, 30 Mar 2026 18:38:35 +0100
Subject: [PATCH 4/4] feat(code): move automation scheduling from local to
cloud API
Remove local automation backend (DB repository, scheduler, service,
tRPC router) and replace with cloud API calls via PostHog client.
Automations are now managed server-side with CRUD hooks and cloud
task visibility in sidebar/command center.
Co-Authored-By: Claude Opus 4.6 (1M context)
---
.../db/repositories/automation-repository.ts | 229 ----------
apps/code/src/main/di/container.ts | 4 -
apps/code/src/main/di/tokens.ts | 4 -
apps/code/src/main/index.ts | 2 -
.../src/main/services/automation/scheduler.ts | 112 -----
.../src/main/services/automation/service.ts | 394 ------------------
apps/code/src/main/trpc/router.ts | 2 -
.../code/src/main/trpc/routers/automations.ts | 155 -------
apps/code/src/renderer/api/fetcher.ts | 26 +-
apps/code/src/renderer/api/posthogClient.ts | 164 +++++++-
.../src/renderer/components/MainLayout.tsx | 2 -
.../components/AutomationsView.tsx | 347 ++++++++++-----
.../hooks/useAutomationScheduler.ts | 168 --------
.../automations/hooks/useAutomations.ts | 136 ++++++
.../automations/stores/automationStore.ts | 252 -----------
.../command-center/hooks/useAvailableTasks.ts | 3 +-
.../sidebar/components/SidebarMenu.tsx | 10 +-
.../features/sidebar/hooks/useSidebarData.ts | 5 +-
.../task-detail/components/TaskLogsPanel.tsx | 249 ++++++++++-
19 files changed, 792 insertions(+), 1472 deletions(-)
delete mode 100644 apps/code/src/main/db/repositories/automation-repository.ts
delete mode 100644 apps/code/src/main/services/automation/scheduler.ts
delete mode 100644 apps/code/src/main/services/automation/service.ts
delete mode 100644 apps/code/src/main/trpc/routers/automations.ts
delete mode 100644 apps/code/src/renderer/features/automations/hooks/useAutomationScheduler.ts
create mode 100644 apps/code/src/renderer/features/automations/hooks/useAutomations.ts
delete mode 100644 apps/code/src/renderer/features/automations/stores/automationStore.ts
diff --git a/apps/code/src/main/db/repositories/automation-repository.ts b/apps/code/src/main/db/repositories/automation-repository.ts
deleted file mode 100644
index a35c6242b..000000000
--- a/apps/code/src/main/db/repositories/automation-repository.ts
+++ /dev/null
@@ -1,229 +0,0 @@
-import type {
- AutomationRunStatus,
- Automation as AutomationType,
-} from "@shared/types/automations";
-import { desc, eq } from "drizzle-orm";
-import { inject, injectable } from "inversify";
-import { MAIN_TOKENS } from "../../di/tokens";
-import { automationRuns, automations } from "../schema";
-import type { DatabaseService } from "../service";
-
-export type AutomationRow = typeof automations.$inferSelect;
-export type NewAutomationRow = typeof automations.$inferInsert;
-export type AutomationRunRow = typeof automationRuns.$inferSelect;
-export type NewAutomationRunRow = typeof automationRuns.$inferInsert;
-
-export interface CreateAutomationData {
- name: string;
- prompt: string;
- repoPath: string;
- repository?: string | null;
- githubIntegrationId?: number | null;
- scheduleTime: string;
- timezone: string;
- templateId?: string | null;
- enabled?: boolean;
-}
-
-export interface UpdateAutomationData {
- name?: string;
- prompt?: string;
- repoPath?: string;
- repository?: string | null;
- githubIntegrationId?: number | null;
- scheduleTime?: string;
- timezone?: string;
- templateId?: string | null;
- enabled?: boolean;
- nextRunAt?: string | null;
-}
-
-const byId = (id: string) => eq(automations.id, id);
-const runByAutomationId = (automationId: string) =>
- eq(automationRuns.automationId, automationId);
-const now = () => new Date().toISOString();
-
-@injectable()
-export class AutomationRepository {
- constructor(
- @inject(MAIN_TOKENS.DatabaseService)
- private readonly databaseService: DatabaseService,
- ) {}
-
- private get db() {
- return this.databaseService.db;
- }
-
- findById(id: string): AutomationRow | null {
- return this.db.select().from(automations).where(byId(id)).get() ?? null;
- }
-
- findAll(): AutomationRow[] {
- return this.db
- .select()
- .from(automations)
- .orderBy(desc(automations.createdAt))
- .all();
- }
-
- findEnabled(): AutomationRow[] {
- return this.db
- .select()
- .from(automations)
- .where(eq(automations.enabled, true))
- .all();
- }
-
- create(data: CreateAutomationData): AutomationRow {
- const timestamp = now();
- const id = crypto.randomUUID();
- const row: NewAutomationRow = {
- id,
- name: data.name,
- prompt: data.prompt,
- repoPath: data.repoPath,
- repository: data.repository ?? null,
- githubIntegrationId: data.githubIntegrationId ?? null,
- scheduleTime: data.scheduleTime,
- timezone: data.timezone,
- templateId: data.templateId ?? null,
- enabled: data.enabled ?? true,
- createdAt: timestamp,
- updatedAt: timestamp,
- };
- this.db.insert(automations).values(row).run();
- const created = this.findById(id);
- if (!created) {
- throw new Error(`Failed to create automation with id ${id}`);
- }
- return created;
- }
-
- update(id: string, data: UpdateAutomationData): AutomationRow {
- const updates: Partial = {
- updatedAt: now(),
- };
- if (data.name !== undefined) updates.name = data.name;
- if (data.prompt !== undefined) updates.prompt = data.prompt;
- if (data.repoPath !== undefined) updates.repoPath = data.repoPath;
- if (data.repository !== undefined) updates.repository = data.repository;
- if (data.githubIntegrationId !== undefined)
- updates.githubIntegrationId = data.githubIntegrationId;
- if (data.scheduleTime !== undefined)
- updates.scheduleTime = data.scheduleTime;
- if (data.timezone !== undefined) updates.timezone = data.timezone;
- if (data.templateId !== undefined) updates.templateId = data.templateId;
- if (data.enabled !== undefined) updates.enabled = data.enabled;
- if (data.nextRunAt !== undefined) updates.nextRunAt = data.nextRunAt;
-
- this.db.update(automations).set(updates).where(byId(id)).run();
- const updated = this.findById(id);
- if (!updated) {
- throw new Error(`Automation not found: ${id}`);
- }
- return updated;
- }
-
- updateLastRun(
- id: string,
- status: AutomationRunStatus,
- opts?: { error?: string; taskId?: string; nextRunAt?: string },
- ): void {
- this.db
- .update(automations)
- .set({
- lastRunAt: now(),
- lastRunStatus: status,
- lastError: opts?.error ?? null,
- lastTaskId: opts?.taskId ?? null,
- nextRunAt: opts?.nextRunAt ?? null,
- updatedAt: now(),
- })
- .where(byId(id))
- .run();
- }
-
- deleteById(id: string): void {
- this.db.delete(automations).where(byId(id)).run();
- }
-
- // --- Runs ---
-
- createRun(automationId: string): AutomationRunRow {
- const timestamp = now();
- const id = crypto.randomUUID();
- const row: NewAutomationRunRow = {
- id,
- automationId,
- status: "running",
- startedAt: timestamp,
- createdAt: timestamp,
- };
- this.db.insert(automationRuns).values(row).run();
- return this.db
- .select()
- .from(automationRuns)
- .where(eq(automationRuns.id, id))
- .get()!;
- }
-
- completeRun(
- runId: string,
- status: "success" | "failed",
- output?: string,
- error?: string,
- ): void {
- this.db
- .update(automationRuns)
- .set({
- status,
- output: output ?? null,
- error: error ?? null,
- completedAt: now(),
- })
- .where(eq(automationRuns.id, runId))
- .run();
- }
-
- findRunsByAutomationId(automationId: string, limit = 20): AutomationRunRow[] {
- return this.db
- .select()
- .from(automationRuns)
- .where(runByAutomationId(automationId))
- .orderBy(desc(automationRuns.startedAt))
- .limit(limit)
- .all();
- }
-
- findRecentRuns(limit = 50): AutomationRunRow[] {
- return this.db
- .select()
- .from(automationRuns)
- .orderBy(desc(automationRuns.startedAt))
- .limit(limit)
- .all();
- }
-
- /** Convert a DB row to the shared Automation type */
- toAutomation(row: AutomationRow): AutomationType {
- return {
- id: row.id,
- name: row.name,
- prompt: row.prompt,
- repoPath: row.repoPath,
- repository: row.repository,
- githubIntegrationId: row.githubIntegrationId,
- scheduleTime: row.scheduleTime,
- timezone: row.timezone,
- enabled: row.enabled,
- templateId: row.templateId,
- createdAt: row.createdAt,
- updatedAt: row.updatedAt,
- nextRunAt: row.nextRunAt,
- lastRunAt: row.lastRunAt,
- lastRunStatus: row.lastRunStatus as AutomationRunStatus | null,
- lastTaskId: row.lastTaskId,
- lastError: row.lastError,
- };
- }
-}
diff --git a/apps/code/src/main/di/container.ts b/apps/code/src/main/di/container.ts
index 0a8df3cbd..4f94a1ca0 100644
--- a/apps/code/src/main/di/container.ts
+++ b/apps/code/src/main/di/container.ts
@@ -2,7 +2,6 @@ import "reflect-metadata";
import { Container } from "inversify";
import { ArchiveRepository } from "../db/repositories/archive-repository";
-import { AutomationRepository } from "../db/repositories/automation-repository";
import { RepositoryRepository } from "../db/repositories/repository-repository";
import { SuspensionRepositoryImpl } from "../db/repositories/suspension-repository";
import { WorkspaceRepository } from "../db/repositories/workspace-repository";
@@ -12,7 +11,6 @@ import { AgentService } from "../services/agent/service";
import { AppLifecycleService } from "../services/app-lifecycle/service";
import { ArchiveService } from "../services/archive/service";
import { AuthProxyService } from "../services/auth-proxy/service";
-import { AutomationService } from "../services/automation/service";
import { CloudTaskService } from "../services/cloud-task/service";
import { ConnectivityService } from "../services/connectivity/service";
import { ContextMenuService } from "../services/context-menu/service";
@@ -55,10 +53,8 @@ container.bind(MAIN_TOKENS.RepositoryRepository).to(RepositoryRepository);
container.bind(MAIN_TOKENS.WorkspaceRepository).to(WorkspaceRepository);
container.bind(MAIN_TOKENS.WorktreeRepository).to(WorktreeRepository);
container.bind(MAIN_TOKENS.ArchiveRepository).to(ArchiveRepository);
-container.bind(MAIN_TOKENS.AutomationRepository).to(AutomationRepository);
container.bind(MAIN_TOKENS.SuspensionRepository).to(SuspensionRepositoryImpl);
container.bind(MAIN_TOKENS.AgentService).to(AgentService);
-container.bind(MAIN_TOKENS.AutomationService).to(AutomationService);
container.bind(MAIN_TOKENS.AuthProxyService).to(AuthProxyService);
container.bind(MAIN_TOKENS.ArchiveService).to(ArchiveService);
container.bind(MAIN_TOKENS.SuspensionService).to(SuspensionService);
diff --git a/apps/code/src/main/di/tokens.ts b/apps/code/src/main/di/tokens.ts
index 8e2e6affc..a11400b67 100644
--- a/apps/code/src/main/di/tokens.ts
+++ b/apps/code/src/main/di/tokens.ts
@@ -16,12 +16,8 @@ export const MAIN_TOKENS = Object.freeze({
ArchiveRepository: Symbol.for("Main.ArchiveRepository"),
SuspensionRepository: Symbol.for("Main.SuspensionRepository"),
- // Repositories
- AutomationRepository: Symbol.for("Main.AutomationRepository"),
-
// Services
AgentService: Symbol.for("Main.AgentService"),
- AutomationService: Symbol.for("Main.AutomationService"),
AuthProxyService: Symbol.for("Main.AuthProxyService"),
ArchiveService: Symbol.for("Main.ArchiveService"),
SuspensionService: Symbol.for("Main.SuspensionService"),
diff --git a/apps/code/src/main/index.ts b/apps/code/src/main/index.ts
index b94df2209..09c683ef1 100644
--- a/apps/code/src/main/index.ts
+++ b/apps/code/src/main/index.ts
@@ -11,7 +11,6 @@ import { container } from "./di/container";
import { MAIN_TOKENS } from "./di/tokens";
import { registerMcpSandboxProtocol } from "./protocols/mcp-sandbox";
import type { AppLifecycleService } from "./services/app-lifecycle/service";
-import type { AutomationService } from "./services/automation/service";
import type { ExternalAppsService } from "./services/external-apps/service";
import type { NotificationService } from "./services/notification/service";
import type { OAuthService } from "./services/oauth/service";
@@ -44,7 +43,6 @@ function initializeServices(): void {
container.get(MAIN_TOKENS.TaskLinkService);
container.get(MAIN_TOKENS.ExternalAppsService);
container.get(MAIN_TOKENS.PosthogPluginService);
- container.get(MAIN_TOKENS.AutomationService);
// Initialize workspace branch watcher for live branch rename detection
const workspaceService = container.get(
diff --git a/apps/code/src/main/services/automation/scheduler.ts b/apps/code/src/main/services/automation/scheduler.ts
deleted file mode 100644
index 7d54554e8..000000000
--- a/apps/code/src/main/services/automation/scheduler.ts
+++ /dev/null
@@ -1,112 +0,0 @@
-/**
- * Schedule utilities for automations.
- *
- * The model is simple: each automation has a `scheduleTime` (HH:MM)
- * and a `timezone`. It runs daily at that time.
- */
-
-function getZonedParts(
- date: Date,
- timezone: string,
-): { year: number; month: number; day: number; hour: number; minute: number } {
- const formatter = new Intl.DateTimeFormat("en-GB", {
- timeZone: timezone,
- year: "numeric",
- month: "2-digit",
- day: "2-digit",
- hour: "2-digit",
- minute: "2-digit",
- hour12: false,
- });
- const parts = formatter.formatToParts(date);
- const get = (type: string) =>
- Number(parts.find((p) => p.type === type)?.value ?? 0);
- return {
- year: get("year"),
- month: get("month"),
- day: get("day"),
- hour: get("hour"),
- minute: get("minute"),
- };
-}
-
-function zonedToUtc(
- year: number,
- month: number,
- day: number,
- hour: number,
- minute: number,
- timezone: string,
-): Date {
- let guess = new Date(Date.UTC(year, month - 1, day, hour, minute, 0, 0));
- for (let attempt = 0; attempt < 4; attempt++) {
- const actual = getZonedParts(guess, timezone);
- const actualMs = Date.UTC(
- actual.year,
- actual.month - 1,
- actual.day,
- actual.hour,
- actual.minute,
- );
- const intendedMs = Date.UTC(year, month - 1, day, hour, minute);
- const diff = Math.round((actualMs - intendedMs) / 60_000);
- if (diff === 0) return guess;
- guess = new Date(guess.getTime() - diff * 60_000);
- }
- return guess;
-}
-
-/**
- * Compute the next run time for a daily automation.
- * If today's run time hasn't passed, returns today's time.
- * Otherwise returns tomorrow's time.
- */
-export function computeNextRunAt(
- scheduleTime: string,
- timezone: string,
- from: Date = new Date(),
-): Date {
- const [hourStr, minuteStr] = scheduleTime.split(":");
- const hour = Number(hourStr ?? 0);
- const minute = Number(minuteStr ?? 0);
-
- const today = getZonedParts(from, timezone);
- const todayTarget = zonedToUtc(
- today.year,
- today.month,
- today.day,
- hour,
- minute,
- timezone,
- );
-
- if (todayTarget.getTime() > from.getTime()) {
- return todayTarget;
- }
-
- // Tomorrow
- const tomorrow = new Date(
- Date.UTC(today.year, today.month - 1, today.day + 1),
- );
- return zonedToUtc(
- tomorrow.getUTCFullYear(),
- tomorrow.getUTCMonth() + 1,
- tomorrow.getUTCDate(),
- hour,
- minute,
- timezone,
- );
-}
-
-/**
- * Get the delay in ms until the next run.
- * Returns at least 1000ms to prevent tight loops.
- */
-export function getDelayMs(
- scheduleTime: string,
- timezone: string,
- from: Date = new Date(),
-): number {
- const next = computeNextRunAt(scheduleTime, timezone, from);
- return Math.max(1000, next.getTime() - from.getTime());
-}
diff --git a/apps/code/src/main/services/automation/service.ts b/apps/code/src/main/services/automation/service.ts
deleted file mode 100644
index d3d661907..000000000
--- a/apps/code/src/main/services/automation/service.ts
+++ /dev/null
@@ -1,394 +0,0 @@
-import { tmpdir } from "node:os";
-import type {
- Automation,
- AutomationRunInfo,
- AutomationRunStatus,
-} from "@shared/types/automations";
-import { powerMonitor } from "electron";
-import { inject, injectable, postConstruct, preDestroy } from "inversify";
-import type {
- AutomationRepository,
- AutomationRow,
-} from "../../db/repositories/automation-repository";
-import { MAIN_TOKENS } from "../../di/tokens";
-import { logger } from "../../utils/logger";
-import { TypedEventEmitter } from "../../utils/typed-event-emitter";
-import type { AgentService } from "../agent/service";
-import { computeNextRunAt, getDelayMs } from "./scheduler";
-
-const log = logger.scope("automation-service");
-
-export const AutomationServiceEvent = {
- AutomationCreated: "automation-created",
- AutomationUpdated: "automation-updated",
- AutomationDeleted: "automation-deleted",
- RunStarted: "run-started",
- RunCompleted: "run-completed",
-} as const;
-
-export interface AutomationServiceEvents {
- [AutomationServiceEvent.AutomationCreated]: Automation;
- [AutomationServiceEvent.AutomationUpdated]: Automation;
- [AutomationServiceEvent.AutomationDeleted]: { id: string };
- [AutomationServiceEvent.RunStarted]: AutomationRunInfo;
- [AutomationServiceEvent.RunCompleted]: AutomationRunInfo;
-}
-
-interface ScheduledJob {
- automationId: string;
- timer: ReturnType;
- nextRunAt: Date;
-}
-
-/** Credentials needed to start an agent session for automations */
-export interface AutomationCredentials {
- apiKey: string;
- apiHost: string;
- projectId: number;
-}
-
-@injectable()
-export class AutomationService extends TypedEventEmitter {
- private jobs = new Map();
- private runningAutomations = new Set();
- private credentials: AutomationCredentials | null = null;
-
- constructor(
- @inject(MAIN_TOKENS.AutomationRepository)
- private readonly repo: AutomationRepository,
- @inject(MAIN_TOKENS.AgentService)
- private readonly agentService: AgentService,
- ) {
- super();
- }
-
- @postConstruct()
- init(): void {
- log.info("Initializing automation service");
-
- powerMonitor.on("resume", () => {
- log.info("System resumed, rescheduling automations");
- this.rescheduleAll();
- });
- }
-
- /**
- * Store credentials for running automations.
- * Called from the renderer when auth state changes.
- */
- setCredentials(creds: AutomationCredentials): void {
- this.credentials = creds;
- if (this.jobs.size === 0) {
- this.loadAndScheduleAll();
- }
- }
-
- clearCredentials(): void {
- this.credentials = null;
- this.cancelAllJobs();
- }
-
- @preDestroy()
- shutdown(): void {
- log.info("Shutting down automation service");
- this.cancelAllJobs();
- }
-
- // --- CRUD ---
-
- create(data: {
- name: string;
- prompt: string;
- repoPath: string;
- repository?: string | null;
- githubIntegrationId?: number | null;
- scheduleTime: string;
- timezone: string;
- templateId?: string | null;
- }): Automation {
- const row = this.repo.create({
- name: data.name,
- prompt: data.prompt,
- repoPath: data.repoPath,
- repository: data.repository,
- githubIntegrationId: data.githubIntegrationId,
- scheduleTime: data.scheduleTime,
- timezone: data.timezone,
- templateId: data.templateId,
- enabled: true,
- });
- const automation = this.repo.toAutomation(row);
- this.scheduleJob(row);
- this.emit(AutomationServiceEvent.AutomationCreated, automation);
- log.info("Created automation", { id: row.id, name: data.name });
- return automation;
- }
-
- update(
- id: string,
- data: {
- name?: string;
- prompt?: string;
- repoPath?: string;
- repository?: string | null;
- githubIntegrationId?: number | null;
- scheduleTime?: string;
- timezone?: string;
- templateId?: string | null;
- enabled?: boolean;
- },
- ): Automation {
- const row = this.repo.update(id, data);
- const automation = this.repo.toAutomation(row);
-
- this.cancelJob(id);
- if (row.enabled) {
- this.scheduleJob(row);
- }
-
- this.emit(AutomationServiceEvent.AutomationUpdated, automation);
- log.info("Updated automation", { id });
- return automation;
- }
-
- delete(id: string): void {
- this.cancelJob(id);
- this.repo.deleteById(id);
- this.emit(AutomationServiceEvent.AutomationDeleted, { id });
- log.info("Deleted automation", { id });
- }
-
- list(): Automation[] {
- return this.repo.findAll().map((row) => this.repo.toAutomation(row));
- }
-
- getById(id: string): Automation | null {
- const row = this.repo.findById(id);
- return row ? this.repo.toAutomation(row) : null;
- }
-
- getRuns(automationId: string, limit = 20): AutomationRunInfo[] {
- return this.repo.findRunsByAutomationId(automationId, limit).map(toRunInfo);
- }
-
- getRecentRuns(limit = 50): AutomationRunInfo[] {
- return this.repo.findRecentRuns(limit).map(toRunInfo);
- }
-
- /** Manually trigger an automation right now */
- async triggerNow(id: string): Promise {
- const row = this.repo.findById(id);
- if (!row) {
- throw new Error(`Automation not found: ${id}`);
- }
- return this.executeAutomation(row);
- }
-
- // --- Scheduling ---
-
- private loadAndScheduleAll(): void {
- const rows = this.repo.findEnabled();
- log.info("Loading automations", { count: rows.length });
- for (const row of rows) {
- this.scheduleJob(row);
- }
- }
-
- private rescheduleAll(): void {
- this.cancelAllJobs();
- if (this.credentials) {
- this.loadAndScheduleAll();
- }
- }
-
- private scheduleJob(row: AutomationRow): void {
- if (!row.enabled) return;
-
- const nextRunAt = computeNextRunAt(row.scheduleTime, row.timezone);
- const delayMs = getDelayMs(row.scheduleTime, row.timezone);
-
- log.info("Scheduling automation", {
- id: row.id,
- name: row.name,
- scheduleTime: row.scheduleTime,
- timezone: row.timezone,
- nextRunAt: nextRunAt.toISOString(),
- delayMs,
- });
-
- // Persist nextRunAt so the UI can show it
- this.repo.update(row.id, { nextRunAt: nextRunAt.toISOString() });
-
- const timer = setTimeout(() => {
- this.onJobFired(row.id);
- }, delayMs);
-
- timer.unref();
-
- this.jobs.set(row.id, {
- automationId: row.id,
- timer,
- nextRunAt,
- });
- }
-
- private cancelJob(id: string): void {
- const job = this.jobs.get(id);
- if (job) {
- clearTimeout(job.timer);
- this.jobs.delete(id);
- }
- }
-
- private cancelAllJobs(): void {
- for (const [, job] of this.jobs) {
- clearTimeout(job.timer);
- }
- this.jobs.clear();
- }
-
- private async onJobFired(automationId: string): Promise {
- this.jobs.delete(automationId);
-
- const row = this.repo.findById(automationId);
- if (!row || !row.enabled) {
- log.info("Automation disabled or deleted, skipping", { automationId });
- return;
- }
-
- if (this.runningAutomations.has(automationId)) {
- log.warn("Automation already running, skipping", { automationId });
- this.scheduleJob(row);
- return;
- }
-
- try {
- await this.executeAutomation(row);
- } catch (err) {
- log.error("Failed to execute automation", {
- automationId,
- error: err instanceof Error ? err.message : String(err),
- });
- }
-
- // Reschedule
- const current = this.repo.findById(automationId);
- if (current?.enabled) {
- this.scheduleJob(current);
- }
- }
-
- // --- Execution ---
-
- private async executeAutomation(
- row: AutomationRow,
- ): Promise {
- if (!this.credentials) {
- throw new Error("No credentials available for automation execution");
- }
-
- this.runningAutomations.add(row.id);
- this.repo.updateLastRun(row.id, "running");
-
- const run = this.repo.createRun(row.id);
- const runInfo = toRunInfo(run);
- this.emit(AutomationServiceEvent.RunStarted, runInfo);
-
- log.info("Executing automation", {
- automationId: row.id,
- name: row.name,
- runId: run.id,
- });
-
- try {
- const output = await this.runAgent(row.prompt, row.repoPath);
-
- this.repo.completeRun(run.id, "success", output);
- this.repo.updateLastRun(row.id, "success");
-
- const completed: AutomationRunInfo = {
- ...runInfo,
- status: "success",
- output,
- completedAt: new Date().toISOString(),
- };
- this.emit(AutomationServiceEvent.RunCompleted, completed);
- log.info("Automation completed", { automationId: row.id, runId: run.id });
- return completed;
- } catch (err) {
- const errorMsg = err instanceof Error ? err.message : String(err);
- this.repo.completeRun(run.id, "failed", undefined, errorMsg);
- this.repo.updateLastRun(row.id, "failed", { error: errorMsg });
-
- const failed: AutomationRunInfo = {
- ...runInfo,
- status: "failed",
- error: errorMsg,
- completedAt: new Date().toISOString(),
- };
- this.emit(AutomationServiceEvent.RunCompleted, failed);
- log.error("Automation failed", {
- automationId: row.id,
- runId: run.id,
- error: errorMsg,
- });
- return failed;
- } finally {
- this.runningAutomations.delete(row.id);
- }
- }
-
- private async runAgent(prompt: string, repoPath: string): Promise {
- if (!this.credentials) {
- throw new Error("No credentials available");
- }
-
- const taskId = `automation-${crypto.randomUUID()}`;
- const taskRunId = `${taskId}:run`;
-
- try {
- const session = await this.agentService.startSession({
- taskId,
- taskRunId,
- repoPath: repoPath || tmpdir(),
- apiKey: this.credentials.apiKey,
- apiHost: this.credentials.apiHost,
- projectId: this.credentials.projectId,
- permissionMode: "bypassPermissions",
- adapter: "claude",
- });
-
- const result = await this.agentService.prompt(session.sessionId, [
- { type: "text", text: prompt },
- ]);
-
- return `Completed with stop reason: ${result.stopReason}`;
- } finally {
- try {
- await this.agentService.cancelSession(taskRunId);
- } catch {
- // Session may already be cleaned up
- }
- }
- }
-}
-
-function toRunInfo(run: {
- id: string;
- automationId: string;
- status: string;
- output: string | null;
- error: string | null;
- startedAt: string;
- completedAt: string | null;
-}): AutomationRunInfo {
- return {
- id: run.id,
- automationId: run.automationId,
- status: run.status as AutomationRunStatus,
- output: run.output,
- error: run.error,
- startedAt: run.startedAt,
- completedAt: run.completedAt,
- };
-}
diff --git a/apps/code/src/main/trpc/router.ts b/apps/code/src/main/trpc/router.ts
index c026d2fdc..73a960569 100644
--- a/apps/code/src/main/trpc/router.ts
+++ b/apps/code/src/main/trpc/router.ts
@@ -1,7 +1,6 @@
import { agentRouter } from "./routers/agent";
import { analyticsRouter } from "./routers/analytics";
import { archiveRouter } from "./routers/archive";
-import { automationsRouter } from "./routers/automations";
import { cloudTaskRouter } from "./routers/cloud-task";
import { connectivityRouter } from "./routers/connectivity";
import { contextMenuRouter } from "./routers/context-menu";
@@ -39,7 +38,6 @@ export const trpcRouter = router({
agent: agentRouter,
analytics: analyticsRouter,
archive: archiveRouter,
- automations: automationsRouter,
cloudTask: cloudTaskRouter,
connectivity: connectivityRouter,
contextMenu: contextMenuRouter,
diff --git a/apps/code/src/main/trpc/routers/automations.ts b/apps/code/src/main/trpc/routers/automations.ts
deleted file mode 100644
index 785c935b9..000000000
--- a/apps/code/src/main/trpc/routers/automations.ts
+++ /dev/null
@@ -1,155 +0,0 @@
-import { z } from "zod";
-import { container } from "../../di/container";
-import { MAIN_TOKENS } from "../../di/tokens";
-import {
- type AutomationService,
- AutomationServiceEvent,
-} from "../../services/automation/service";
-import { publicProcedure, router } from "../trpc";
-
-const getService = () =>
- container.get(MAIN_TOKENS.AutomationService);
-
-export const automationsRouter = router({
- list: publicProcedure.query(() => {
- return getService().list();
- }),
-
- getById: publicProcedure
- .input(z.object({ id: z.string() }))
- .query(({ input }) => {
- return getService().getById(input.id);
- }),
-
- create: publicProcedure
- .input(
- z.object({
- name: z.string().min(1).max(200),
- prompt: z.string().min(1).max(10000),
- repoPath: z.string(),
- repository: z.string().nullable().optional(),
- githubIntegrationId: z.number().nullable().optional(),
- scheduleTime: z.string(),
- timezone: z.string(),
- templateId: z.string().nullable().optional(),
- }),
- )
- .mutation(({ input }) => {
- return getService().create(input);
- }),
-
- update: publicProcedure
- .input(
- z.object({
- id: z.string(),
- name: z.string().min(1).max(200).optional(),
- prompt: z.string().min(1).max(10000).optional(),
- repoPath: z.string().optional(),
- repository: z.string().nullable().optional(),
- githubIntegrationId: z.number().nullable().optional(),
- scheduleTime: z.string().optional(),
- timezone: z.string().optional(),
- templateId: z.string().nullable().optional(),
- enabled: z.boolean().optional(),
- }),
- )
- .mutation(({ input }) => {
- const { id, ...data } = input;
- return getService().update(id, data);
- }),
-
- delete: publicProcedure
- .input(z.object({ id: z.string() }))
- .mutation(({ input }) => {
- getService().delete(input.id);
- return { success: true };
- }),
-
- triggerNow: publicProcedure
- .input(z.object({ id: z.string() }))
- .mutation(async ({ input }) => {
- return getService().triggerNow(input.id);
- }),
-
- getRuns: publicProcedure
- .input(
- z.object({
- automationId: z.string(),
- limit: z.number().min(1).max(100).optional(),
- }),
- )
- .query(({ input }) => {
- return getService().getRuns(input.automationId, input.limit);
- }),
-
- getRecentRuns: publicProcedure
- .input(z.object({ limit: z.number().min(1).max(100).optional() }))
- .query(({ input }) => {
- return getService().getRecentRuns(input.limit);
- }),
-
- setCredentials: publicProcedure
- .input(
- z.object({
- apiKey: z.string(),
- apiHost: z.string(),
- projectId: z.number(),
- }),
- )
- .mutation(({ input }) => {
- getService().setCredentials(input);
- return { success: true };
- }),
-
- // --- Subscriptions ---
-
- onAutomationCreated: publicProcedure.subscription(async function* (opts) {
- const service = getService();
- for await (const data of service.toIterable(
- AutomationServiceEvent.AutomationCreated,
- { signal: opts.signal },
- )) {
- yield data;
- }
- }),
-
- onAutomationUpdated: publicProcedure.subscription(async function* (opts) {
- const service = getService();
- for await (const data of service.toIterable(
- AutomationServiceEvent.AutomationUpdated,
- { signal: opts.signal },
- )) {
- yield data;
- }
- }),
-
- onAutomationDeleted: publicProcedure.subscription(async function* (opts) {
- const service = getService();
- for await (const data of service.toIterable(
- AutomationServiceEvent.AutomationDeleted,
- { signal: opts.signal },
- )) {
- yield data;
- }
- }),
-
- onRunStarted: publicProcedure.subscription(async function* (opts) {
- const service = getService();
- for await (const data of service.toIterable(
- AutomationServiceEvent.RunStarted,
- { signal: opts.signal },
- )) {
- yield data;
- }
- }),
-
- onRunCompleted: publicProcedure.subscription(async function* (opts) {
- const service = getService();
- for await (const data of service.toIterable(
- AutomationServiceEvent.RunCompleted,
- { signal: opts.signal },
- )) {
- yield data;
- }
- }),
-});
diff --git a/apps/code/src/renderer/api/fetcher.ts b/apps/code/src/renderer/api/fetcher.ts
index 14a69051b..9fb1242c4 100644
--- a/apps/code/src/renderer/api/fetcher.ts
+++ b/apps/code/src/renderer/api/fetcher.ts
@@ -8,6 +8,22 @@ export const buildApiFetcher: (config: {
}) => Parameters[0] = (config) => {
let currentToken = config.apiToken;
+ const formatErrorResponse = async (response: Response): Promise => {
+ const contentType = response.headers.get("content-type") ?? "";
+
+ if (contentType.includes("application/json")) {
+ const errorResponse = await response.json().catch(() => ({}));
+ return `Failed request: [${response.status}] ${JSON.stringify(errorResponse)}`;
+ }
+
+ const bodyText = await response.text().catch(() => "");
+ const preview = bodyText.replace(/\s+/g, " ").trim().slice(0, 200);
+
+ return `Failed request: [${response.status}] ${response.statusText}${
+ preview ? ` ${preview}` : ""
+ }`;
+ };
+
const makeRequest = async (
input: Parameters[0]["fetch"]>[0],
token: string,
@@ -66,18 +82,12 @@ export const buildApiFetcher: (config: {
response = await makeRequest(input, currentToken);
} catch {
// Token refresh failed - throw the original 401 error
- const errorResponse = await response.json();
- throw new Error(
- `Failed request: [${response.status}] ${JSON.stringify(errorResponse)}`,
- );
+ throw new Error(await formatErrorResponse(response));
}
}
if (!response.ok) {
- const errorResponse = await response.json();
- throw new Error(
- `Failed request: [${response.status}] ${JSON.stringify(errorResponse)}`,
- );
+ throw new Error(await formatErrorResponse(response));
}
return response;
diff --git a/apps/code/src/renderer/api/posthogClient.ts b/apps/code/src/renderer/api/posthogClient.ts
index c7eef4a64..fb8ea5caa 100644
--- a/apps/code/src/renderer/api/posthogClient.ts
+++ b/apps/code/src/renderer/api/posthogClient.ts
@@ -50,6 +50,25 @@ export interface ExternalDataSource {
schemas?: ExternalDataSourceSchema[] | string;
}
+export interface TaskAutomationApi {
+ id: string;
+ name: string;
+ prompt: string;
+ repository: string;
+ github_integration?: number | null;
+ schedule_time: string;
+ timezone: string;
+ template_id?: string | null;
+ enabled: boolean;
+ last_run_at?: string | null;
+ last_run_status?: "success" | "failed" | "running" | null;
+ last_task_id?: string | null;
+ last_task_run_id?: string | null;
+ last_error?: string | null;
+ created_at: string;
+ updated_at: string;
+}
+
function isObjectRecord(value: unknown): value is Record {
return typeof value === "object" && value !== null;
}
@@ -382,6 +401,139 @@ export class PostHogAPIClient {
return data.results ?? [];
}
+ async listTaskAutomations(): Promise {
+ const teamId = await this.getTeamId();
+ const urlPath = `/api/projects/${teamId}/task_automations/`;
+ const url = new URL(`${this.api.baseUrl}${urlPath}`);
+ const response = await this.api.fetcher.fetch({
+ method: "get",
+ url,
+ path: urlPath,
+ });
+ if (!response.ok) {
+ throw new Error(
+ `Failed to fetch task automations: ${response.statusText}`,
+ );
+ }
+ const data = (await response.json()) as
+ | { results?: TaskAutomationApi[] }
+ | TaskAutomationApi[];
+
+ return Array.isArray(data) ? data : (data.results ?? []);
+ }
+
+ async createTaskAutomation(input: {
+ name: string;
+ prompt: string;
+ repository: string;
+ github_integration?: number | null;
+ schedule_time: string;
+ timezone: string;
+ template_id?: string | null;
+ enabled?: boolean;
+ }): Promise {
+ const teamId = await this.getTeamId();
+ const urlPath = `/api/projects/${teamId}/task_automations/`;
+ const url = new URL(`${this.api.baseUrl}${urlPath}`);
+ const response = await this.api.fetcher.fetch({
+ method: "post",
+ url,
+ path: urlPath,
+ overrides: {
+ body: JSON.stringify(input),
+ },
+ });
+ if (!response.ok) {
+ const errorData = (await response.json().catch(() => ({}))) as {
+ detail?: string;
+ };
+ throw new Error(
+ errorData.detail ??
+ `Failed to create task automation: ${response.statusText}`,
+ );
+ }
+
+ return (await response.json()) as TaskAutomationApi;
+ }
+
+ async updateTaskAutomation(
+ automationId: string,
+ input: Partial<{
+ name: string;
+ prompt: string;
+ repository: string;
+ github_integration: number | null;
+ schedule_time: string;
+ timezone: string;
+ template_id: string | null;
+ enabled: boolean;
+ }>,
+ ): Promise {
+ const teamId = await this.getTeamId();
+ const urlPath = `/api/projects/${teamId}/task_automations/${automationId}/`;
+ const url = new URL(`${this.api.baseUrl}${urlPath}`);
+ const response = await this.api.fetcher.fetch({
+ method: "patch",
+ url,
+ path: urlPath,
+ overrides: {
+ body: JSON.stringify(input),
+ },
+ });
+ if (!response.ok) {
+ const errorData = (await response.json().catch(() => ({}))) as {
+ detail?: string;
+ };
+ throw new Error(
+ errorData.detail ??
+ `Failed to update task automation: ${response.statusText}`,
+ );
+ }
+
+ return (await response.json()) as TaskAutomationApi;
+ }
+
+ async deleteTaskAutomation(automationId: string): Promise {
+ const teamId = await this.getTeamId();
+ const urlPath = `/api/projects/${teamId}/task_automations/${automationId}/`;
+ const url = new URL(`${this.api.baseUrl}${urlPath}`);
+ const response = await this.api.fetcher.fetch({
+ method: "delete",
+ url,
+ path: urlPath,
+ });
+ if (!response.ok) {
+ throw new Error(
+ `Failed to delete task automation: ${response.statusText}`,
+ );
+ }
+ }
+
+ async runTaskAutomationNow(automationId: string): Promise {
+ const teamId = await this.getTeamId();
+ const urlPath = `/api/projects/${teamId}/task_automations/${automationId}/run_now/`;
+ const url = new URL(`${this.api.baseUrl}${urlPath}`);
+ const response = await this.api.fetcher.fetch({
+ method: "post",
+ url,
+ path: urlPath,
+ overrides: {
+ body: JSON.stringify({}),
+ },
+ });
+ if (!response.ok) {
+ const errorData = (await response.json().catch(() => ({}))) as {
+ detail?: string;
+ };
+ throw new Error(
+ errorData.detail ??
+ `Failed to run task automation: ${response.statusText}`,
+ );
+ }
+
+ return (await response.json()) as TaskAutomationApi;
+ }
+
async getTask(taskId: string) {
const teamId = await this.getTeamId();
const data = await this.api.get(`/api/projects/{project_id}/tasks/{id}/`, {
@@ -700,12 +852,12 @@ export class PostHogAPIClient {
async getIntegrationsForProject(projectId: number) {
const url = new URL(
- `${this.api.baseUrl}/api/environments/${projectId}/integrations/`,
+ `${this.api.baseUrl}/api/projects/${projectId}/integrations/`,
);
const response = await this.api.fetcher.fetch({
method: "get",
url,
- path: `/api/environments/${projectId}/integrations/`,
+ path: `/api/projects/${projectId}/integrations/`,
});
if (!response.ok) {
@@ -722,13 +874,13 @@ export class PostHogAPIClient {
): Promise<{ branches: string[]; defaultBranch: string | null }> {
const teamId = await this.getTeamId();
const url = new URL(
- `${this.api.baseUrl}/api/environments/${teamId}/integrations/${integrationId}/github_branches/`,
+ `${this.api.baseUrl}/api/projects/${teamId}/integrations/${integrationId}/github_branches/`,
);
url.searchParams.set("repo", repo);
const response = await this.api.fetcher.fetch({
method: "get",
url,
- path: `/api/environments/${teamId}/integrations/${integrationId}/github_branches/`,
+ path: `/api/projects/${teamId}/integrations/${integrationId}/github_branches/`,
});
if (!response.ok) {
@@ -749,12 +901,12 @@ export class PostHogAPIClient {
): Promise {
const teamId = await this.getTeamId();
const url = new URL(
- `${this.api.baseUrl}/api/environments/${teamId}/integrations/${integrationId}/github_repos/`,
+ `${this.api.baseUrl}/api/projects/${teamId}/integrations/${integrationId}/github_repos/`,
);
const response = await this.api.fetcher.fetch({
method: "get",
url,
- path: `/api/environments/${teamId}/integrations/${integrationId}/github_repos/`,
+ path: `/api/projects/${teamId}/integrations/${integrationId}/github_repos/`,
});
if (!response.ok) {
diff --git a/apps/code/src/renderer/components/MainLayout.tsx b/apps/code/src/renderer/components/MainLayout.tsx
index 78b85cab3..9bb563ea6 100644
--- a/apps/code/src/renderer/components/MainLayout.tsx
+++ b/apps/code/src/renderer/components/MainLayout.tsx
@@ -5,7 +5,6 @@ import { KeyboardShortcutsSheet } from "@components/KeyboardShortcutsSheet";
import { ArchivedTasksView } from "@features/archive/components/ArchivedTasksView";
import { AutomationsView } from "@features/automations/components/AutomationsView";
-import { useAutomationScheduler } from "@features/automations/hooks/useAutomationScheduler";
import { CommandMenu } from "@features/command/components/CommandMenu";
import { CommandCenterView } from "@features/command-center/components/CommandCenterView";
import { InboxView } from "@features/inbox/components/InboxView";
@@ -44,7 +43,6 @@ export function MainLayout() {
useIntegrations();
useTaskDeepLink();
- useAutomationScheduler();
useEffect(() => {
if (tasks) {
diff --git a/apps/code/src/renderer/features/automations/components/AutomationsView.tsx b/apps/code/src/renderer/features/automations/components/AutomationsView.tsx
index b42796cfc..53865586d 100644
--- a/apps/code/src/renderer/features/automations/components/AutomationsView.tsx
+++ b/apps/code/src/renderer/features/automations/components/AutomationsView.tsx
@@ -1,4 +1,3 @@
-import { FolderPicker } from "@features/folder-picker/components/FolderPicker";
import { GitHubRepoPicker } from "@features/folder-picker/components/GitHubRepoPicker";
import { useRepositoryIntegration } from "@hooks/useIntegrations";
import { useSetHeaderContent } from "@hooks/useSetHeaderContent";
@@ -24,15 +23,19 @@ import {
} from "@radix-ui/themes";
import type { Automation } from "@shared/types/automations";
import { useEffect, useMemo, useState } from "react";
-import { runAutomationNow } from "../hooks/useAutomationScheduler";
-import { useAutomationStore } from "../stores/automationStore";
+import {
+ useAutomations,
+ useCreateAutomation,
+ useDeleteAutomation,
+ useRunAutomationNow,
+ useUpdateAutomation,
+} from "../hooks/useAutomations";
import { AUTOMATION_TEMPLATES } from "../templates";
import { formatAutomationDateTime, getLocalTimezone } from "../utils/schedule";
interface AutomationDraft {
name: string;
prompt: string;
- repoPath: string;
repository: string | null;
githubIntegrationId: number | null;
scheduleTime: string;
@@ -47,7 +50,6 @@ function toDraft(
return {
name: "",
prompt: "",
- repoPath: "",
repository: null,
githubIntegrationId: githubIntegrationId ?? null,
scheduleTime: "09:00",
@@ -58,8 +60,7 @@ function toDraft(
return {
name: automation.name,
prompt: automation.prompt,
- repoPath: automation.repoPath,
- repository: automation.repository ?? null,
+ repository: automation.repository ?? automation.repoPath ?? null,
githubIntegrationId:
automation.githubIntegrationId ?? githubIntegrationId ?? null,
scheduleTime: automation.scheduleTime,
@@ -92,6 +93,14 @@ function AutomationStatusBadge({ automation }: { automation: Automation }) {
);
}
+ if (automation.lastRunStatus === "running") {
+ return (
+
+ Running
+
+ );
+ }
+
return (
Active
@@ -100,32 +109,29 @@ function AutomationStatusBadge({ automation }: { automation: Automation }) {
}
export function AutomationsView() {
- const automations = useAutomationStore((state) => state.automations);
- const selectedAutomationId = useAutomationStore(
- (state) => state.selectedAutomationId,
- );
- const runningAutomationIds = useAutomationStore(
- (state) => state.runningAutomationIds,
- );
- const setSelectedAutomationId = useAutomationStore(
- (state) => state.setSelectedAutomationId,
- );
- const createAutomation = useAutomationStore(
- (state) => state.createAutomation,
- );
- const updateAutomation = useAutomationStore(
- (state) => state.updateAutomation,
- );
- const deleteAutomation = useAutomationStore(
- (state) => state.deleteAutomation,
- );
- const toggleAutomation = useAutomationStore(
- (state) => state.toggleAutomation,
- );
-
+ const { automations, isLoading } = useAutomations();
+ const createAutomation = useCreateAutomation();
+ const updateAutomation = useUpdateAutomation();
+ const deleteAutomation = useDeleteAutomation();
+ const runAutomationNow = useRunAutomationNow();
const { githubIntegration, repositories, isLoadingRepos } =
useRepositoryIntegration();
+ const [selectedAutomationId, setSelectedAutomationId] = useState<
+ string | null
+ >(null);
+ const [isCreating, setIsCreating] = useState(true);
+ const [draft, setDraft] = useState(() =>
+ toDraft(null, githubIntegration?.id),
+ );
+ const [pendingRunAutomationId, setPendingRunAutomationId] = useState<
+ string | null
+ >(null);
+ const [pendingToggleAutomationId, setPendingToggleAutomationId] = useState<
+ string | null
+ >(null);
+ const [formError, setFormError] = useState(null);
+
const selectedAutomation = useMemo(
() =>
automations.find(
@@ -134,13 +140,41 @@ export function AutomationsView() {
[automations, selectedAutomationId],
);
- const [isCreating, setIsCreating] = useState(automations.length === 0);
- const [draft, setDraft] = useState(() =>
- toDraft(null, githubIntegration?.id),
- );
+ useEffect(() => {
+ if (isLoading) {
+ return;
+ }
+
+ if (automations.length === 0) {
+ setIsCreating(true);
+ setSelectedAutomationId(null);
+ return;
+ }
+
+ if (isCreating) {
+ return;
+ }
+
+ if (!selectedAutomationId) {
+ setSelectedAutomationId(automations[0]?.id ?? null);
+ return;
+ }
+
+ const stillExists = automations.some(
+ (automation) => automation.id === selectedAutomationId,
+ );
+ if (!stillExists) {
+ setSelectedAutomationId(automations[0]?.id ?? null);
+ }
+ }, [automations, isCreating, isLoading, selectedAutomationId]);
useEffect(() => {
- if (!isCreating && selectedAutomation) {
+ if (isCreating) {
+ setDraft(toDraft(null, githubIntegration?.id));
+ return;
+ }
+
+ if (selectedAutomation) {
setDraft(toDraft(selectedAutomation, githubIntegration?.id));
}
}, [isCreating, selectedAutomation, githubIntegration?.id]);
@@ -164,16 +198,23 @@ export function AutomationsView() {
useSetHeaderContent(headerContent);
+ const timezone = getLocalTimezone();
+ const enabledCount = automations.filter(
+ (automation) => automation.enabled,
+ ).length;
+ const hasGitHubIntegration =
+ Boolean(githubIntegration) && repositories.length > 0;
+
const openCreate = () => {
+ setFormError(null);
setIsCreating(true);
setSelectedAutomationId(null);
- setDraft(toDraft(null, githubIntegration?.id));
};
const openExisting = (automation: Automation) => {
+ setFormError(null);
setIsCreating(false);
setSelectedAutomationId(automation.id);
- setDraft(toDraft(automation, githubIntegration?.id));
};
const applyTemplate = (templateId: string) => {
@@ -192,53 +233,101 @@ export function AutomationsView() {
}));
};
- const handleSave = () => {
- if (!draft.name.trim() || !draft.prompt.trim() || !draft.repoPath.trim()) {
+ const handleSave = async () => {
+ if (!draft.name.trim() || !draft.prompt.trim() || !draft.repository) {
return;
}
- if (isCreating || !selectedAutomation) {
- const automationId = createAutomation({
- name: draft.name,
- prompt: draft.prompt,
- repoPath: draft.repoPath,
- repository: draft.repository,
- githubIntegrationId: draft.githubIntegrationId,
- scheduleTime: draft.scheduleTime,
- templateId: draft.templateId,
- });
- const created = useAutomationStore
- .getState()
- .automations.find((item) => item.id === automationId);
- if (created) {
- openExisting(created);
+ setFormError(null);
+
+ try {
+ if (isCreating || !selectedAutomation) {
+ const created = await createAutomation.mutateAsync({
+ name: draft.name.trim(),
+ prompt: draft.prompt.trim(),
+ repository: draft.repository,
+ github_integration: draft.githubIntegrationId,
+ schedule_time: draft.scheduleTime,
+ timezone,
+ template_id: draft.templateId,
+ enabled: true,
+ });
+ setIsCreating(false);
+ setSelectedAutomationId(created.id);
+ return;
}
+
+ await updateAutomation.mutateAsync({
+ automationId: selectedAutomation.id,
+ updates: {
+ name: draft.name.trim(),
+ prompt: draft.prompt.trim(),
+ repository: draft.repository,
+ github_integration: draft.githubIntegrationId,
+ schedule_time: draft.scheduleTime,
+ timezone,
+ template_id: draft.templateId,
+ },
+ });
+ } catch (error) {
+ setFormError(
+ error instanceof Error ? error.message : "Failed to save automation.",
+ );
+ }
+ };
+
+ const handleDelete = async () => {
+ if (!selectedAutomation) {
return;
}
- updateAutomation(selectedAutomation.id, {
- name: draft.name,
- prompt: draft.prompt,
- repoPath: draft.repoPath,
- repository: draft.repository,
- githubIntegrationId: draft.githubIntegrationId,
- scheduleTime: draft.scheduleTime,
- templateId: draft.templateId,
- });
+ await deleteAutomation.mutateAsync(selectedAutomation.id);
+ openCreate();
};
- const handleDelete = () => {
+ const handleToggleEnabled = async (enabled: boolean) => {
if (!selectedAutomation) {
return;
}
- deleteAutomation(selectedAutomation.id);
- openCreate();
+
+ setPendingToggleAutomationId(selectedAutomation.id);
+ setFormError(null);
+ try {
+ await updateAutomation.mutateAsync({
+ automationId: selectedAutomation.id,
+ updates: { enabled },
+ });
+ } catch (error) {
+ setFormError(
+ error instanceof Error
+ ? error.message
+ : "Failed to update automation state.",
+ );
+ } finally {
+ setPendingToggleAutomationId(null);
+ }
};
- const enabledCount = automations.filter(
- (automation) => automation.enabled,
- ).length;
- const timezone = getLocalTimezone();
+ const handleRunNow = async () => {
+ if (!selectedAutomation) {
+ return;
+ }
+
+ setPendingRunAutomationId(selectedAutomation.id);
+ setFormError(null);
+ try {
+ await runAutomationNow.mutateAsync(selectedAutomation.id);
+ } catch (error) {
+ setFormError(
+ error instanceof Error ? error.message : "Failed to run automation.",
+ );
+ } finally {
+ setPendingRunAutomationId(null);
+ }
+ };
+
+ const isSaving = createAutomation.isPending || updateAutomation.isPending;
+ const isDeleting = deleteAutomation.isPending;
return (
@@ -266,7 +355,19 @@ export function AutomationsView() {
- {automations.length === 0 ? (
+ {isLoading ? (
+
+
+ Loading automations...
+
+
+ ) : automations.length === 0 ? (
- {automation.repoPath}
+ {automation.repository ?? automation.repoPath}
@@ -343,8 +444,8 @@ export function AutomationsView() {
: (selectedAutomation?.name ?? "Automation")}
- Runs locally on this app while it is open. Missed runs are
- skipped.
+ Runs in the cloud sandbox on schedule, even while Twig is
+ closed.
@@ -431,24 +532,6 @@ export function AutomationsView() {
/>
-
-
- Local context
-
-
- setDraft((current) => ({ ...current, repoPath }))
- }
- placeholder="Select repository..."
- size="2"
- />
-
-
GitHub repository
-
- setDraft((current) => ({
- ...current,
- repository,
- githubIntegrationId: githubIntegration?.id ?? null,
- }))
- }
- repositories={repositories}
- isLoading={isLoadingRepos}
- placeholder="Optional"
- size="2"
- />
+ {hasGitHubIntegration ? (
+
+ setDraft((current) => ({
+ ...current,
+ repository,
+ githubIntegrationId: githubIntegration?.id ?? null,
+ }))
+ }
+ repositories={repositories}
+ isLoading={isLoadingRepos}
+ placeholder="Select repository..."
+ size="2"
+ />
+ ) : (
+
+ setDraft((current) => ({
+ ...current,
+ repository: event.target.value.trim() || null,
+ githubIntegrationId: null,
+ }))
+ }
+ placeholder="posthog/posthog"
+ />
+ )}
+
+ {hasGitHubIntegration
+ ? "Each automation runs against a single GitHub repository in the cloud sandbox."
+ : "No GitHub integration is connected. You can still enter org/repo for local testing, but the sandbox will not clone the repository until GitHub is connected."}
+
@@ -558,13 +660,24 @@ export function AutomationsView() {
) : null}
+ {formError ? (
+
+
+ {formError}
+
+
+ ) : null}
+
{!isCreating && selectedAutomation ? (
<>
- toggleAutomation(selectedAutomation.id)
+ disabled={
+ pendingToggleAutomationId === selectedAutomation.id
+ }
+ onCheckedChange={(enabled) =>
+ void handleToggleEnabled(enabled)
}
/>
void runAutomationNow(selectedAutomation.id)}
+ disabled={pendingRunAutomationId === selectedAutomation.id}
+ onClick={() => void handleRunNow()}
>
Run now
) : null}
{!isCreating && selectedAutomation ? (
-