From 3302785475ec92d25e663454cdcdba828add943f Mon Sep 17 00:00:00 2001 From: Bailey Dixon Date: Wed, 15 Jul 2026 08:56:27 -0400 Subject: [PATCH 1/6] feat: add scheduled task automation surface --- DEVLOG.md | 29 ++ docs/.vitepress/config.ts | 1 + docs/automation/scheduled-tasks.md | 45 ++ .../0106_scheduled_tasks/migration.sql | 89 ++++ prisma/schema.prisma | 183 ++++++-- .../(app)/w/[slug]/scheduled-tasks/page.tsx | 427 ++++++++++++++++++ .../scheduled-tasks/scheduled-task-dialog.tsx | 378 ++++++++++++++++ src/components/sidebar-nav.ts | 13 + .../routers/__tests__/scheduled-task.test.ts | 251 ++++++++++ src/server/routers/_app.ts | 2 + src/server/routers/scheduled-task.ts | 369 +++++++++++++++ .../services/scheduled-task-schedule.ts | 221 +++++++++ src/server/services/scheduled-task.ts | 287 ++++++++++++ src/server/worker.ts | 23 + tests/e2e/scheduled-tasks.spec.ts | 41 ++ tests/unit/scheduled-task-schedule.test.ts | 65 +++ tests/unit/sidebar-nav.test.ts | 8 + 17 files changed, 2394 insertions(+), 38 deletions(-) create mode 100644 docs/automation/scheduled-tasks.md create mode 100644 prisma/migrations/0106_scheduled_tasks/migration.sql create mode 100644 src/app/(app)/w/[slug]/scheduled-tasks/page.tsx create mode 100644 src/app/(app)/w/[slug]/scheduled-tasks/scheduled-task-dialog.tsx create mode 100644 src/server/routers/__tests__/scheduled-task.test.ts create mode 100644 src/server/routers/scheduled-task.ts create mode 100644 src/server/services/scheduled-task-schedule.ts create mode 100644 src/server/services/scheduled-task.ts create mode 100644 tests/e2e/scheduled-tasks.spec.ts create mode 100644 tests/unit/scheduled-task-schedule.test.ts diff --git a/DEVLOG.md b/DEVLOG.md index 17746512..a8581f0f 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -13101,3 +13101,32 @@ fresh production E2E build completed. The browser run passed 35 of 38 journeys before Chromium crashed on a memory-saturated host; each of the three reported journeys passed immediately in a fresh isolated browser process. Release, deployment, and production smoke results follow after the pull request lands. + +## 2026-07-15 — First-class scheduled tasks (AXI-92) + +Added a ScheduledTask / ScheduledTaskRun automation domain alongside the +unchanged legacy RecurringIssue feature. Scheduled tasks support interval, +daily, and weekly timezone-aware schedules; create real Forge issues in the +workspace inbox or an active project through the canonical issue creation +service; and retain durable success/failure run history. The maintenance worker +claims due tasks once per minute and persists the next future occurrence before +executing, so a failed action keeps its next run visible and active. + +Added tenant-scoped, admin-gated tRPC lifecycle operations with audit/activity +events, safe pause/resume and typed-name deletion, manual runs, a first-class +Automation navigation entry and responsive management surface, schedule and +delivery builders, failure detail, linked output issues, documentation, and +focused schedule/router/execution coverage. Independent review then hardened +worker-crash recovery, archived-workspace filtering, soft-deleted delivery +target validation, and pause/resume races around in-flight runs. + +Verification: Prisma format, validate, generate, and migration deploy passed; +typecheck and the production Next build passed; lint passed with only existing +repository warnings; focused coverage passed 17/17 after review hardening; and +the full Vitest gate +passed 1,300 tests with one intentional live-connector skip. The new scheduled +task Playwright lifecycle passed in an isolated single-worker run. The full +parallel Playwright gate passed 35/40: the new lifecycle initially hit a strict +locator ambiguity that was fixed and rerun green; three unrelated chat/browser +failures passed immediately in isolation, while the pre-existing shared E2E +workspace state left one dispatch-default assertion disabled on rerun. diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 9ecd975d..0f59710d 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -181,6 +181,7 @@ export default defineConfig({ { text: "Automation Surfaces", items: [ + { text: "Scheduled tasks", link: "/automation/scheduled-tasks.html" }, { text: "Webhooks", link: "/automation/webhooks.html" }, { text: "Plugins", link: "/automation/plugins.html" }, { text: "API Keys", link: "/automation/api-keys.html" }, diff --git a/docs/automation/scheduled-tasks.md b/docs/automation/scheduled-tasks.md new file mode 100644 index 00000000..af3dd5e9 --- /dev/null +++ b/docs/automation/scheduled-tasks.md @@ -0,0 +1,45 @@ +# Scheduled tasks + +Scheduled tasks are Forge's first-class recurring automation surface. They are +separate from legacy recurring issues: existing recurring issue definitions and +their behavior are unchanged. + +Open **Automation → Scheduled tasks** in a workspace to create and monitor a +task. Owners and admins can manage tasks; every workspace member can inspect +their schedule, current status, error state, and recent runs. + +## Create-issue action + +The initial supported action creates a real Forge issue through the same +canonical service used by the rest of the product. Configure: + +- a task name and issue title; +- a prompt, which becomes the issue description; +- issue priority; +- delivery to the workspace inbox or an active project; and +- an interval, daily, or weekly schedule. + +Daily and weekly schedules use an IANA timezone and retain their local +wall-clock time across daylight-saving changes. Interval schedules are elapsed +time based and run at least five minutes apart. + +## Runs and failures + +Every attempt gets a durable run row before the action begins. Successful runs +link to the issue they created. Failed runs retain a useful error message on +both the task and run history. + +For scheduled attempts, Forge calculates and saves the next future occurrence +before executing the action. A failed action therefore remains visibly active +for its next run. Pausing is the only lifecycle action that clears `nextRunAt`; +resuming computes a new future occurrence. + +**Run now** executes immediately without moving the regular future occurrence. +Deleting a task requires typing its exact name and removes its run history, but +keeps issues created by earlier runs. + +## Worker operation + +The BullMQ maintenance worker scans due tasks every minute. Run `pnpm worker` +alongside the web process in deployments that do not enable in-process workers. +The legacy `RecurringIssue` ticker continues independently. diff --git a/prisma/migrations/0106_scheduled_tasks/migration.sql b/prisma/migrations/0106_scheduled_tasks/migration.sql new file mode 100644 index 00000000..c25ed433 --- /dev/null +++ b/prisma/migrations/0106_scheduled_tasks/migration.sql @@ -0,0 +1,89 @@ +-- First-class recurring automation tasks. Legacy RecurringIssue rows remain +-- untouched and continue to use their existing ticker. + +CREATE TYPE "ScheduledTaskAction" AS ENUM ('CREATE_ISSUE'); +CREATE TYPE "ScheduledTaskScheduleType" AS ENUM ('INTERVAL', 'DAILY', 'WEEKLY'); +CREATE TYPE "ScheduledTaskDeliveryType" AS ENUM ('INBOX', 'PROJECT'); +CREATE TYPE "ScheduledTaskStatus" AS ENUM ('ACTIVE', 'RUNNING', 'SUCCEEDED', 'FAILED', 'PAUSED'); +CREATE TYPE "ScheduledTaskRunStatus" AS ENUM ('RUNNING', 'SUCCEEDED', 'FAILED'); +CREATE TYPE "ScheduledTaskRunTrigger" AS ENUM ('SCHEDULE', 'MANUAL'); + +ALTER TYPE "EventKind" ADD VALUE 'SCHEDULED_TASK_CREATED'; +ALTER TYPE "EventKind" ADD VALUE 'SCHEDULED_TASK_UPDATED'; +ALTER TYPE "EventKind" ADD VALUE 'SCHEDULED_TASK_DELETED'; +ALTER TYPE "EventKind" ADD VALUE 'SCHEDULED_TASK_RUN_STARTED'; +ALTER TYPE "EventKind" ADD VALUE 'SCHEDULED_TASK_RUN_SUCCEEDED'; +ALTER TYPE "EventKind" ADD VALUE 'SCHEDULED_TASK_RUN_FAILED'; + +CREATE TABLE "ScheduledTask" ( + "id" TEXT NOT NULL, + "workspaceId" TEXT NOT NULL, + "createdById" TEXT NOT NULL, + "projectId" TEXT, + "name" TEXT NOT NULL, + "action" "ScheduledTaskAction" NOT NULL DEFAULT 'CREATE_ISSUE', + "prompt" TEXT NOT NULL, + "issueTitle" TEXT NOT NULL, + "issuePriority" "Priority" NOT NULL DEFAULT 'NONE', + "deliveryType" "ScheduledTaskDeliveryType" NOT NULL DEFAULT 'INBOX', + "scheduleType" "ScheduledTaskScheduleType" NOT NULL, + "intervalMinutes" INTEGER, + "timeOfDayMinutes" INTEGER, + "dayOfWeek" INTEGER, + "timezone" TEXT NOT NULL, + "enabled" BOOLEAN NOT NULL DEFAULT true, + "status" "ScheduledTaskStatus" NOT NULL DEFAULT 'ACTIVE', + "nextRunAt" TIMESTAMP(3), + "lastRunAt" TIMESTAMP(3), + "lastSucceededAt" TIMESTAMP(3), + "lastError" TEXT, + "consecutiveFailures" INTEGER NOT NULL DEFAULT 0, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + CONSTRAINT "ScheduledTask_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "ScheduledTaskRun" ( + "id" TEXT NOT NULL, + "workspaceId" TEXT NOT NULL, + "scheduledTaskId" TEXT NOT NULL, + "outputIssueId" TEXT, + "status" "ScheduledTaskRunStatus" NOT NULL DEFAULT 'RUNNING', + "trigger" "ScheduledTaskRunTrigger" NOT NULL, + "scheduledForAt" TIMESTAMP(3), + "startedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "completedAt" TIMESTAMP(3), + "error" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "ScheduledTaskRun_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX "ScheduledTask_workspaceId_enabled_nextRunAt_idx" + ON "ScheduledTask"("workspaceId", "enabled", "nextRunAt"); +CREATE INDEX "ScheduledTask_workspaceId_status_idx" + ON "ScheduledTask"("workspaceId", "status"); +CREATE INDEX "ScheduledTask_projectId_idx" ON "ScheduledTask"("projectId"); +CREATE INDEX "ScheduledTaskRun_scheduledTaskId_createdAt_idx" + ON "ScheduledTaskRun"("scheduledTaskId", "createdAt"); +CREATE INDEX "ScheduledTaskRun_workspaceId_createdAt_idx" + ON "ScheduledTaskRun"("workspaceId", "createdAt"); +CREATE INDEX "ScheduledTaskRun_outputIssueId_idx" ON "ScheduledTaskRun"("outputIssueId"); + +ALTER TABLE "ScheduledTask" + ADD CONSTRAINT "ScheduledTask_workspaceId_fkey" + FOREIGN KEY ("workspaceId") REFERENCES "Workspace"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "ScheduledTask" + ADD CONSTRAINT "ScheduledTask_createdById_fkey" + FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "ScheduledTask" + ADD CONSTRAINT "ScheduledTask_projectId_fkey" + FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE SET NULL ON UPDATE CASCADE; +ALTER TABLE "ScheduledTaskRun" + ADD CONSTRAINT "ScheduledTaskRun_workspaceId_fkey" + FOREIGN KEY ("workspaceId") REFERENCES "Workspace"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "ScheduledTaskRun" + ADD CONSTRAINT "ScheduledTaskRun_scheduledTaskId_fkey" + FOREIGN KEY ("scheduledTaskId") REFERENCES "ScheduledTask"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "ScheduledTaskRun" + ADD CONSTRAINT "ScheduledTaskRun_outputIssueId_fkey" + FOREIGN KEY ("outputIssueId") REFERENCES "Issue"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 4489e19d..bfe3f5a9 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -178,6 +178,12 @@ enum EventKind { PLAN_BUDGET_EXCEEDED /// A RUNNING plan has no valid path to make progress without operator action. PLAN_STALLED + SCHEDULED_TASK_CREATED + SCHEDULED_TASK_UPDATED + SCHEDULED_TASK_DELETED + SCHEDULED_TASK_RUN_STARTED + SCHEDULED_TASK_RUN_SUCCEEDED + SCHEDULED_TASK_RUN_FAILED } /// Operator-requested control on an in-flight AgentRun. NONE is the @@ -211,6 +217,40 @@ enum CompletionAutomation { AUTO_WHEN_SAFE } +enum ScheduledTaskAction { + CREATE_ISSUE +} + +enum ScheduledTaskScheduleType { + INTERVAL + DAILY + WEEKLY +} + +enum ScheduledTaskDeliveryType { + INBOX + PROJECT +} + +enum ScheduledTaskStatus { + ACTIVE + RUNNING + SUCCEEDED + FAILED + PAUSED +} + +enum ScheduledTaskRunStatus { + RUNNING + SUCCEEDED + FAILED +} + +enum ScheduledTaskRunTrigger { + SCHEDULE + MANUAL +} + enum ChatRole { USER AGENT @@ -619,6 +659,7 @@ model User { claimedIssues Issue[] @relation("IssueClaimedBy") defaultIssueAssigneeForWorkspaces Workspace[] @relation("WorkspaceDefaultIssueAssignee") recurringCreated RecurringIssue[] @relation("RecurringCreator") + scheduledTasksCreated ScheduledTask[] @relation("ScheduledTaskCreator") savedViews SavedView[] issueSavedViews IssueSavedView[] timeEntries TimeEntry[] @@ -955,6 +996,8 @@ model Workspace { templates IssueTemplate[] projectTemplates ProjectTemplate[] recurring RecurringIssue[] + scheduledTasks ScheduledTask[] + scheduledTaskRuns ScheduledTaskRun[] savedViews SavedView[] issueSavedViews IssueSavedView[] cycles Cycle[] @@ -1070,6 +1113,7 @@ model Project { issues Issue[] templates IssueTemplate[] recurring RecurringIssue[] + scheduledTasks ScheduledTask[] dispatchRules DispatchRule[] artifacts Artifact[] executionPlans ExecutionPlan[] @@ -1196,31 +1240,32 @@ model Issue { updatedAt DateTime @updatedAt deletedAt DateTime? - workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) - project Project? @relation(fields: [projectId], references: [id], onDelete: SetNull) - parent Issue? @relation("IssueChildren", fields: [parentId], references: [id], onDelete: SetNull) - children Issue[] @relation("IssueChildren") - cycle Cycle? @relation(fields: [cycleId], references: [id], onDelete: SetNull) - status Status @relation(fields: [statusId], references: [id]) - author User @relation("IssueAuthor", fields: [authorId], references: [id]) - claimedBy User? @relation("IssueClaimedBy", fields: [claimedById], references: [id], onDelete: SetNull) - claimedByAgent Agent? @relation("IssueClaimedByAgent", fields: [claimedByAgentId], references: [id], onDelete: SetNull) - assignedAgent Agent? @relation("IssueAssignedAgent", fields: [assignedAgentId], references: [id], onDelete: SetNull) - assignees IssueAssignee[] - labels IssueLabel[] - comments Comment[] - attachments Attachment[] - relationsFrom IssueRelation[] @relation("IssueRelationFrom") - relationsTo IssueRelation[] @relation("IssueRelationTo") - timeEntries TimeEntry[] - agentRuns AgentRun[] - watchers IssueWatcher[] - artifacts Artifact[] - executionPlans ExecutionPlan[] - executionSteps ExecutionStep[] @relation("ExecutionStepIssue") - goals Goal[] - actionRequests ActionRequest[] - externalLinks ExternalResourceLink[] + workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) + project Project? @relation(fields: [projectId], references: [id], onDelete: SetNull) + parent Issue? @relation("IssueChildren", fields: [parentId], references: [id], onDelete: SetNull) + children Issue[] @relation("IssueChildren") + cycle Cycle? @relation(fields: [cycleId], references: [id], onDelete: SetNull) + status Status @relation(fields: [statusId], references: [id]) + author User @relation("IssueAuthor", fields: [authorId], references: [id]) + claimedBy User? @relation("IssueClaimedBy", fields: [claimedById], references: [id], onDelete: SetNull) + claimedByAgent Agent? @relation("IssueClaimedByAgent", fields: [claimedByAgentId], references: [id], onDelete: SetNull) + assignedAgent Agent? @relation("IssueAssignedAgent", fields: [assignedAgentId], references: [id], onDelete: SetNull) + assignees IssueAssignee[] + labels IssueLabel[] + comments Comment[] + attachments Attachment[] + relationsFrom IssueRelation[] @relation("IssueRelationFrom") + relationsTo IssueRelation[] @relation("IssueRelationTo") + timeEntries TimeEntry[] + agentRuns AgentRun[] + watchers IssueWatcher[] + artifacts Artifact[] + executionPlans ExecutionPlan[] + executionSteps ExecutionStep[] @relation("ExecutionStepIssue") + goals Goal[] + actionRequests ActionRequest[] + externalLinks ExternalResourceLink[] + scheduledTaskRuns ScheduledTaskRun[] @@unique([workspaceId, number]) @@index([workspaceId, statusId]) @@ -1301,6 +1346,68 @@ model RecurringIssue { @@index([workspaceId]) } +/// Recurring automation independent from the legacy RecurringIssue feature. +/// INTERVAL uses intervalMinutes, DAILY uses timeOfDayMinutes, and WEEKLY +/// uses timeOfDayMinutes + dayOfWeek (0=Sunday ... 6=Saturday). +model ScheduledTask { + id String @id @default(cuid()) + workspaceId String + createdById String + projectId String? + name String + action ScheduledTaskAction @default(CREATE_ISSUE) + prompt String @db.Text + issueTitle String + issuePriority Priority @default(NONE) + deliveryType ScheduledTaskDeliveryType @default(INBOX) + scheduleType ScheduledTaskScheduleType + intervalMinutes Int? + timeOfDayMinutes Int? + dayOfWeek Int? + timezone String + enabled Boolean @default(true) + status ScheduledTaskStatus @default(ACTIVE) + nextRunAt DateTime? + lastRunAt DateTime? + lastSucceededAt DateTime? + lastError String? @db.Text + consecutiveFailures Int @default(0) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) + createdBy User @relation("ScheduledTaskCreator", fields: [createdById], references: [id]) + project Project? @relation(fields: [projectId], references: [id], onDelete: SetNull) + runs ScheduledTaskRun[] + + @@index([workspaceId, enabled, nextRunAt]) + @@index([workspaceId, status]) + @@index([projectId]) +} + +/// Durable execution attempt, created before the configured action runs. +model ScheduledTaskRun { + id String @id @default(cuid()) + workspaceId String + scheduledTaskId String + outputIssueId String? + status ScheduledTaskRunStatus @default(RUNNING) + trigger ScheduledTaskRunTrigger + scheduledForAt DateTime? + startedAt DateTime @default(now()) + completedAt DateTime? + error String? @db.Text + createdAt DateTime @default(now()) + + workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) + scheduledTask ScheduledTask @relation(fields: [scheduledTaskId], references: [id], onDelete: Cascade) + outputIssue Issue? @relation(fields: [outputIssueId], references: [id], onDelete: SetNull) + + @@index([scheduledTaskId, createdAt]) + @@index([workspaceId, createdAt]) + @@index([outputIssueId]) +} + /// Bookmarked filter preset. userId null = shared workspace view. model SavedView { id String @id @default(cuid()) @@ -2267,23 +2374,23 @@ model ExternalResourceLink { /// stores `X-GitHub-Delivery` plus event/action so redeliveries can be /// acknowledged without duplicating Forge side effects. model ExternalWebhookEvent { - id String @id @default(cuid()) - workspaceId String? - provider String - deliveryId String - event String - action String? - repoFullName String? - externalResourceId String? - status String - error String? @db.Text - receivedAt DateTime @default(now()) + id String @id @default(cuid()) + workspaceId String? + provider String + deliveryId String + event String + action String? + repoFullName String? + externalResourceId String? + status String + error String? @db.Text + receivedAt DateTime @default(now()) /// Start of the current processing lease. Failed or abandoned deliveries /// can be atomically reclaimed without racing an in-flight request. processingStartedAt DateTime? /// Number of processing claims, including the initial delivery. - attemptCount Int @default(1) - processedAt DateTime? + attemptCount Int @default(1) + processedAt DateTime? workspace Workspace? @relation(fields: [workspaceId], references: [id], onDelete: Cascade) diff --git a/src/app/(app)/w/[slug]/scheduled-tasks/page.tsx b/src/app/(app)/w/[slug]/scheduled-tasks/page.tsx new file mode 100644 index 00000000..7f54da5a --- /dev/null +++ b/src/app/(app)/w/[slug]/scheduled-tasks/page.tsx @@ -0,0 +1,427 @@ +"use client"; + +import { useState } from "react"; +import Link from "next/link"; +import { toast } from "sonner"; +import { + AlertTriangle, + CalendarClock, + CheckCircle2, + Clock3, + ExternalLink, + Pause, + Pencil, + Play, + Plus, + RotateCcw, + Trash2, +} from "lucide-react"; +import { Topbar } from "@/components/topbar"; +import { Button } from "@/components/ui/button"; +import { Confirm } from "@/components/ui/modal/confirm"; +import { EmptyState } from "@/components/ui/empty-state"; +import { Spinner } from "@/components/ui/spinner"; +import { trpc } from "@/lib/trpc"; +import { cn, formatDate } from "@/lib/utils"; +import { useTimePrefs } from "@/lib/time-prefs"; +import { useWorkspace } from "@/hooks/use-workspace"; +import { ScheduledTaskDialog, type ScheduledTaskListItem } from "./scheduled-task-dialog"; + +const WEEKDAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; + +export default function ScheduledTasksPage() { + const workspace = useWorkspace(); + const timePrefs = useTimePrefs(); + const utils = trpc.useUtils(); + const { data: tasks, isLoading } = trpc.scheduledTask.list.useQuery(); + const [dialogOpen, setDialogOpen] = useState(false); + const [editing, setEditing] = useState(null); + const [deleteTarget, setDeleteTarget] = useState(null); + const canManage = workspace.role === "OWNER" || workspace.role === "ADMIN"; + + const refresh = () => utils.scheduledTask.list.invalidate(); + const pauseTask = trpc.scheduledTask.pause.useMutation({ onSuccess: refresh }); + const resumeTask = trpc.scheduledTask.resume.useMutation({ onSuccess: refresh }); + const runTask = trpc.scheduledTask.runNow.useMutation({ onSuccess: refresh }); + const deleteTask = trpc.scheduledTask.delete.useMutation({ onSuccess: refresh }); + + function openCreate() { + setEditing(null); + setDialogOpen(true); + } + + function openEdit(task: ScheduledTaskListItem) { + setEditing(task); + setDialogOpen(true); + } + + async function runNow(task: ScheduledTaskListItem) { + try { + const result = await runTask.mutateAsync({ id: task.id }); + if (result?.status === "FAILED") toast.error("Run failed. Error details were saved."); + else toast.success("Scheduled task completed."); + } catch (error) { + toast.error(error instanceof Error ? error.message : "Could not run task."); + } + } + + async function toggleEnabled(task: ScheduledTaskListItem) { + try { + if (task.enabled) { + await pauseTask.mutateAsync({ id: task.id }); + toast.success("Scheduled task paused."); + } else { + await resumeTask.mutateAsync({ id: task.id }); + toast.success("Scheduled task resumed with a new future run time."); + } + } catch (error) { + toast.error(error instanceof Error ? error.message : "Could not update task."); + } + } + + return ( + <> + + + New task + + ) : null + } + /> + +
+
+ {!canManage && ( +
+ Scheduled tasks are visible to workspace members. An owner or admin can change them. +
+ )} + + {isLoading ? ( +
+ Loading scheduled tasks… +
+ ) : !tasks?.length ? ( +
+ } + title="No scheduled tasks yet" + description="Turn a recurring prompt into a real issue delivered to the inbox or a project." + action={ + canManage ? ( + + ) : undefined + } + /> +
+ ) : ( +
+ {tasks.map((task) => ( + formatDate(date, timePrefs)} + onEdit={() => openEdit(task)} + onRun={() => runNow(task)} + onToggle={() => toggleEnabled(task)} + onDelete={() => setDeleteTarget(task)} + pending={ + (runTask.isPending && runTask.variables?.id === task.id) || + (pauseTask.isPending && pauseTask.variables?.id === task.id) || + (resumeTask.isPending && resumeTask.variables?.id === task.id) + } + /> + ))} +
+ )} +
+
+ + setDialogOpen(false)} + onSaved={() => { + setDialogOpen(false); + void refresh(); + toast.success(editing ? "Scheduled task updated." : "Scheduled task created."); + }} + /> + + !open && setDeleteTarget(null)} + variant="destructive" + typeToConfirm={deleteTarget?.name} + title={`Delete “${deleteTarget?.name ?? "scheduled task"}”?`} + description="The task and its run history will be permanently deleted. Issues already created by it are kept." + primaryLabel="Delete task" + loading={deleteTask.isPending} + onConfirm={async () => { + if (!deleteTarget) return; + try { + await deleteTask.mutateAsync({ + id: deleteTarget.id, + confirmation: deleteTarget.name, + }); + toast.success("Scheduled task deleted."); + setDeleteTarget(null); + } catch (error) { + toast.error(error instanceof Error ? error.message : "Could not delete task."); + } + }} + /> + + ); +} + +function TaskCard({ + task, + workspaceSlug, + workspaceKey, + canManage, + formatTimestamp, + onEdit, + onRun, + onToggle, + onDelete, + pending, +}: { + task: ScheduledTaskListItem; + workspaceSlug: string; + workspaceKey: string; + canManage: boolean; + formatTimestamp: (date: Date | string) => string; + onEdit: () => void; + onRun: () => void; + onToggle: () => void; + onDelete: () => void; + pending: boolean; +}) { + const running = task.status === "RUNNING"; + return ( +
+
+
+
+ +

{task.name}

+ Create issue +
+
+
{task.issueTitle}
+

+ {task.prompt} +

+
+
+ + + + +
+ {task.status === "FAILED" && task.lastError && ( +
+ +
+
+ Last run failed + {task.consecutiveFailures > 1 + ? ` · ${task.consecutiveFailures} consecutive failures` + : ""} +
+

{task.lastError}

+ {task.nextRunAt && ( +

The next scheduled run remains active.

+ )} +
+
+ )} +
+ + {canManage && ( +
+ + + + +
+ )} +
+ +
+ + Recent runs ({task.runs.length}) + + {task.runs.length ? ( +
+ {task.runs.map((run) => ( +
+
+ + {run.status.toLowerCase()} + + {run.trigger.toLowerCase()} + +
+
+ {formatTimestamp(run.startedAt)} + {run.error &&

{run.error}

} +
+ {run.outputIssue && ( + + + {workspaceKey}-{run.outputIssue.number} + + {run.outputIssue.title} + + + )} +
+ ))} +
+ ) : ( +

+ No runs yet. +

+ )} +
+
+ ); +} + +function runTaskIsLikely(task: ScheduledTaskListItem) { + return task.status !== "PAUSED"; +} + +function Metadata({ + label, + value, + emphasized = false, +}: { + label: string; + value: string; + emphasized?: boolean; +}) { + return ( +
+
{label}
+
+ {value} +
+
+ ); +} + +function StatusBadge({ status }: { status: ScheduledTaskListItem["status"] }) { + const styles = { + ACTIVE: "bg-ember/10 text-ember", + RUNNING: "bg-warning/10 text-warning", + SUCCEEDED: "bg-success/10 text-success", + FAILED: "bg-danger/10 text-danger", + PAUSED: "bg-subtle text-muted-foreground", + }[status]; + return ( + + {status.charAt(0) + status.slice(1).toLowerCase()} + + ); +} + +function RunIcon({ status }: { status: ScheduledTaskListItem["runs"][number]["status"] }) { + if (status === "SUCCEEDED") return ; + if (status === "FAILED") return ; + return ; +} + +function scheduleLabel(task: ScheduledTaskListItem) { + if (task.scheduleType === "INTERVAL") { + const minutes = task.intervalMinutes ?? 0; + if (minutes % 1_440 === 0) return `Every ${minutes / 1_440} day${minutes === 1_440 ? "" : "s"}`; + if (minutes % 60 === 0) return `Every ${minutes / 60} hour${minutes === 60 ? "" : "s"}`; + return `Every ${minutes} minutes`; + } + const minutes = task.timeOfDayMinutes ?? 0; + const time = `${String(Math.floor(minutes / 60)).padStart(2, "0")}:${String(minutes % 60).padStart(2, "0")}`; + if (task.scheduleType === "DAILY") return `Daily at ${time} · ${task.timezone}`; + return `${WEEKDAYS[task.dayOfWeek ?? 0]} at ${time} · ${task.timezone}`; +} diff --git a/src/app/(app)/w/[slug]/scheduled-tasks/scheduled-task-dialog.tsx b/src/app/(app)/w/[slug]/scheduled-tasks/scheduled-task-dialog.tsx new file mode 100644 index 00000000..e984d3ac --- /dev/null +++ b/src/app/(app)/w/[slug]/scheduled-tasks/scheduled-task-dialog.tsx @@ -0,0 +1,378 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import type { inferRouterOutputs } from "@trpc/server"; +import type { AppRouter } from "@/server/routers/_app"; +import { Dialog } from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { Spinner } from "@/components/ui/spinner"; +import { Combobox } from "@/components/ui/combobox"; +import { trpc } from "@/lib/trpc"; + +type RouterOutputs = inferRouterOutputs; +export type ScheduledTaskListItem = RouterOutputs["scheduledTask"]["list"][number]; + +const TIMEZONES = [ + "UTC", + "America/New_York", + "America/Chicago", + "America/Denver", + "America/Los_Angeles", + "Europe/London", + "Europe/Berlin", + "Asia/Tokyo", + "Asia/Kolkata", + "Australia/Sydney", +]; +const WEEKDAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; +const PRIORITIES = ["NONE", "LOW", "MEDIUM", "HIGH", "URGENT"] as const; + +type FormState = { + name: string; + issueTitle: string; + prompt: string; + issuePriority: (typeof PRIORITIES)[number]; + deliveryType: "INBOX" | "PROJECT"; + projectId: string; + scheduleType: "INTERVAL" | "DAILY" | "WEEKLY"; + intervalMinutes: number; + localTime: string; + dayOfWeek: number; + timezone: string; +}; + +function minutesToTime(minutes: number | null) { + const value = minutes ?? 9 * 60; + return `${String(Math.floor(value / 60)).padStart(2, "0")}:${String(value % 60).padStart(2, "0")}`; +} + +function timeToMinutes(value: string) { + const [hour, minute] = value.split(":").map(Number); + return hour * 60 + minute; +} + +function initialState(task: ScheduledTaskListItem | null): FormState { + const browserTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; + return { + name: task?.name ?? "", + issueTitle: task?.issueTitle ?? "", + prompt: task?.prompt ?? "", + issuePriority: task?.issuePriority ?? "NONE", + deliveryType: task?.deliveryType ?? "INBOX", + projectId: task?.projectId ?? "", + scheduleType: task?.scheduleType ?? "DAILY", + intervalMinutes: task?.intervalMinutes ?? 60, + localTime: minutesToTime(task?.timeOfDayMinutes ?? null), + dayOfWeek: task?.dayOfWeek ?? 1, + timezone: task?.timezone ?? browserTimezone, + }; +} + +export function ScheduledTaskDialog({ + open, + task, + onClose, + onSaved, +}: { + open: boolean; + task: ScheduledTaskListItem | null; + onClose: () => void; + onSaved: () => void; +}) { + const [form, setForm] = useState(() => initialState(task)); + const { data: projects } = trpc.project.list.useQuery( + { archived: false, limit: 500 }, + { enabled: open }, + ); + const create = trpc.scheduledTask.create.useMutation(); + const update = trpc.scheduledTask.update.useMutation(); + + useEffect(() => { + if (open) setForm(initialState(task)); + }, [open, task]); + + const timezoneOptions = useMemo( + () => (TIMEZONES.includes(form.timezone) ? TIMEZONES : [form.timezone, ...TIMEZONES]), + [form.timezone], + ); + const pending = create.isPending || update.isPending; + const error = create.error?.message ?? update.error?.message; + + async function submit(event: React.FormEvent) { + event.preventDefault(); + const schedule = + form.scheduleType === "INTERVAL" + ? ({ + type: "INTERVAL" as const, + intervalMinutes: form.intervalMinutes, + timezone: form.timezone, + } as const) + : form.scheduleType === "DAILY" + ? ({ + type: "DAILY" as const, + timeOfDayMinutes: timeToMinutes(form.localTime), + timezone: form.timezone, + } as const) + : ({ + type: "WEEKLY" as const, + timeOfDayMinutes: timeToMinutes(form.localTime), + dayOfWeek: form.dayOfWeek, + timezone: form.timezone, + } as const); + const payload = { + name: form.name, + action: "CREATE_ISSUE" as const, + prompt: form.prompt, + issueTitle: form.issueTitle, + issuePriority: form.issuePriority, + deliveryType: form.deliveryType, + projectId: form.deliveryType === "PROJECT" ? form.projectId || null : null, + schedule, + }; + if (task) await update.mutateAsync({ id: task.id, ...payload }); + else await create.mutateAsync(payload); + onSaved(); + } + + return ( + +
+
+

+ {task ? "Edit scheduled task" : "New scheduled task"} +

+

+ Create a real Forge issue from this prompt on a recurring schedule. +

+
+ +
+
+

+ Task +

+ + setForm({ ...form, name: event.target.value })} + placeholder="Monday customer follow-up" + /> + +
+ + {}} + options={[{ value: "CREATE_ISSUE", label: "Create issue" }]} + disabled + ariaLabel="Action" + className="h-9 w-full" + matchTriggerWidth + /> + + + + value && + setForm({ ...form, issuePriority: value as FormState["issuePriority"] }) + } + options={PRIORITIES.map((priority) => ({ + value: priority, + label: priority.charAt(0) + priority.slice(1).toLowerCase(), + }))} + ariaLabel="Issue priority" + className="h-9 w-full" + matchTriggerWidth + /> + +
+ + setForm({ ...form, issueTitle: event.target.value })} + placeholder="Prepare weekly customer summary" + /> + + +