diff --git a/src/components/ProjectsCard.tsx b/src/components/ProjectsCard.tsx index cb3f674..ba0e125 100644 --- a/src/components/ProjectsCard.tsx +++ b/src/components/ProjectsCard.tsx @@ -16,12 +16,12 @@ import { Title, UnstyledButton, } from "@mantine/core"; -import { IconChevronDown, IconChevronRight } from "@tabler/icons-react"; +import { IconChevronDown, IconChevronRight, IconRefresh } from "@tabler/icons-react"; import { fetchJson } from "@/lib/api"; import { AGENT_PROVIDER_OPTIONS, type AgentProvider } from "@/lib/agentProviders"; import { PRIORITY_LABELS, rowBorder, sectionHeader } from "@/lib/dashboard"; import { EmptyRow, StatusBadge } from "@/components/primitives"; -import type { ContinueResult, LinearIssue, Project, Repo } from "@/types"; +import type { ContinueResult, LinearIssue, Project, Repo, TodoExecutionResult } from "@/types"; export function ProjectsCard({ projects, repos }: { projects: Project[]; repos: Repo[] }) { const queryClient = useQueryClient(); @@ -104,6 +104,19 @@ function ProjectRow({ project, repos }: { project: Project; repos: Repo[] }) { }, }); + const executeTodosMutation = useMutation({ + mutationFn: () => + fetchJson(`/api/projects/${project.id}/execute-todos`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ agentProvider }), + }), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: ["overview"] }); + void queryClient.invalidateQueries({ queryKey: ["issues", project.id] }); + }, + }); + const panelId = `project-panel-${project.id}`; return ( @@ -201,6 +214,15 @@ function ProjectRow({ project, repos }: { project: Project; repos: Repo[] }) { Continue ▶ )} + {continueMutation.error ? ( @@ -209,6 +231,18 @@ function ProjectRow({ project, repos }: { project: Project; repos: Repo[] }) { ) : null} + {executeTodosMutation.error ? ( + + {(executeTodosMutation.error as Error).message} + + ) : null} + + {executeTodosMutation.data ? ( + + {todoExecutionSummary(executeTodosMutation.data)} + + ) : null} + {continueMutation.data ? ( {continueMutation.data.summary ? ( @@ -298,3 +332,23 @@ function ProjectRow({ project, repos }: { project: Project; repos: Repo[] }) { ); } + +function todoExecutionSummary(result: TodoExecutionResult): string { + const runs = result.runs ?? []; + const queued = runs.filter((run) => run.status === "queued").length; + const waiting = runs.filter((run) => run.status === "waiting_approval").length; + const other = runs.length - queued - waiting; + const parts = [ + queued ? `${queued} queued` : null, + waiting ? `${waiting} waiting approval` : null, + other ? `${other} created` : null, + result.skippedActive ? `${result.skippedActive} already active` : null, + result.skippedCap ? `${result.skippedCap} deferred by cap` : null, + result.failedRuns?.length ? `${result.failedRuns.length} failed` : null, + ].filter(Boolean); + + if (parts.length === 0) { + return `No To Dos queued (${result.eligibleIssues} eligible of ${result.totalOpenIssues} open).`; + } + return `${parts.join(" · ")} (${result.eligibleIssues} eligible of ${result.totalOpenIssues} open).`; +} diff --git a/src/types.ts b/src/types.ts index ad9a52b..df4f0f9 100644 --- a/src/types.ts +++ b/src/types.ts @@ -110,3 +110,13 @@ export type ContinueResult = { queuedRuns: Array<{ id: string; issue: string }>; skipped: number; }; + +export type TodoExecutionResult = { + totalOpenIssues: number; + eligibleIssues: number; + runs: Array<{ id: string; issue: string; status: string }>; + skippedActive: number; + skippedState: number; + skippedCap: number; + failedRuns: Array<{ issue: string; error: string }>; +}; diff --git a/tests/continue.test.ts b/tests/continue.test.ts index ef30208..7bdc9db 100644 --- a/tests/continue.test.ts +++ b/tests/continue.test.ts @@ -1,6 +1,10 @@ /* AGPL-3.0-or-later */ import { describe, expect, it } from "vitest"; -import { selectExecuteTargets, type CreatedWithMeta } from "../worker/src/platform/continue"; +import { + selectExecuteTargets, + selectTodoExecutionTargets, + type CreatedWithMeta, +} from "../worker/src/platform/continue"; function made(planIndex: number, priority: number): CreatedWithMeta { return { @@ -36,3 +40,62 @@ describe("selectExecuteTargets", () => { expect(picked.map((p) => p.planIndex)).toEqual([0, 2]); }); }); + +function todo( + id: string, + stateType: string, + priority = 0, + updatedAt = "2026-06-01T12:00:00Z", +) { + return { + id, + identifier: id.toUpperCase(), + title: `Issue ${id}`, + description: null, + stateType, + priority, + updatedAt, + teamId: "team_1", + }; +} + +describe("selectTodoExecutionTargets", () => { + it("selects backlog and unstarted issues", () => { + const picked = selectTodoExecutionTargets( + [todo("a", "backlog"), todo("b", "unstarted"), todo("c", "started")], + new Set(), + 10, + ); + + expect(picked.targets.map((issue) => issue.id)).toEqual(["a", "b"]); + expect(picked.eligibleIssues).toBe(2); + expect(picked.skippedState).toBe(1); + }); + + it("skips issues that already have an active run", () => { + const picked = selectTodoExecutionTargets( + [todo("a", "unstarted"), todo("b", "unstarted")], + new Set(["a"]), + 10, + ); + + expect(picked.targets.map((issue) => issue.id)).toEqual(["b"]); + expect(picked.skippedActive).toBe(1); + }); + + it("prioritizes urgent issues and respects the execution cap", () => { + const picked = selectTodoExecutionTargets( + [ + todo("low", "unstarted", 4), + todo("none", "unstarted", 0), + todo("urgent", "unstarted", 1), + todo("high", "backlog", 2), + ], + new Set(), + 2, + ); + + expect(picked.targets.map((issue) => issue.id)).toEqual(["urgent", "high"]); + expect(picked.skippedCap).toBe(2); + }); +}); diff --git a/worker/src/index.ts b/worker/src/index.ts index b604d41..8652859 100644 --- a/worker/src/index.ts +++ b/worker/src/index.ts @@ -65,7 +65,7 @@ import { } from "./platform/orchestration"; import { writeBackToLinear } from "./platform/linear"; import { mergeWhenGreen, deleteRepository, renameRepository } from "./platform/github"; -import { continueProject } from "./platform/continue"; +import { continueProject, executeProjectTodos } from "./platform/continue"; import { runGoal, listGoals } from "./platform/goal"; import { getCore, getCoreResponse, saveCore, buildCorePreamble } from "./platform/core"; import { listWorkflows, saveWorkflow, deleteWorkflow } from "./platform/workflows"; @@ -537,6 +537,19 @@ app.post("/api/projects/:id/continue", async (c) => { return continueProject(c.env, user, c.req.param("id"), payload); }); +// Manual To Do execution: fetch open Linear issues live and dispatch the backlog / +// unstarted ones immediately, without relying on the scheduled watcher table. +app.post("/api/projects/:id/execute-todos", async (c) => { + const user = await requireUser(c.req.raw, c.env); + if (user instanceof Response) return user; + const raw = await c.req.json().catch(() => ({})); + const payload = + raw && typeof raw === "object" + ? (raw as { agentProvider?: "claude-code" | "codex" | "cloudflare" }) + : {}; + return executeProjectTodos(c.env, user, c.req.param("id"), payload); +}); + // Auto-map all Linear projects to GitHub repos by name (confident matches become // active; weaker ones are suggested; manual mappings are preserved). app.post("/api/projects/auto-map", async (c) => { diff --git a/worker/src/platform/continue.ts b/worker/src/platform/continue.ts index ff31a3f..7b9a0f9 100644 --- a/worker/src/platform/continue.ts +++ b/worker/src/platform/continue.ts @@ -1,13 +1,15 @@ /* AGPL-3.0-or-later */ import type { CurrentUser, Env } from "../env"; -import { first } from "./data"; +import { all, first } from "./data"; import { autoMapProjects, getLinearProjectIssues, getValidLinearToken } from "./integrations"; +import type { LinearIssue } from "./integrations"; import { planNextSteps, type ProjectContext } from "./planner"; import { createLinearIssue, resolveProjectTeam, type CreatedLinearIssue } from "./linear"; import { createAutonomousRun, type AgentProvider } from "./orchestration"; const DEFAULT_EXECUTE_CAP = 3; const MAX_EXECUTE_CAP = 10; // hard ceiling so a misconfigured env var can't start a flood of runs +const TODO_STATE_TYPES = new Set(["backlog", "unstarted"]); export type CreatedWithMeta = { planIndex: number; @@ -23,6 +25,27 @@ export type ContinueResult = { skipped: number; }; +export type TodoExecutionRun = { + id: string; + issue: string; + status: string; +}; + +export type TodoExecutionResult = { + totalOpenIssues: number; + eligibleIssues: number; + runs: TodoExecutionRun[]; + skippedActive: number; + skippedState: number; + skippedCap: number; + failedRuns: Array<{ issue: string; error: string }>; +}; + +type TodoCandidate = Pick< + LinearIssue, + "id" | "identifier" | "title" | "description" | "stateType" | "priority" | "updatedAt" | "teamId" +>; + // Choose which created issues to execute: prefer the planner's `execute` indexes // (those that actually got created), else fall back to highest priority. Capped. export function selectExecuteTargets( @@ -38,6 +61,116 @@ export function selectExecuteTargets( return pool.slice(0, Math.max(0, cap)); } +export function selectTodoExecutionTargets( + issues: TodoCandidate[], + activeIssueIds: Set, + cap: number, +): { + targets: TodoCandidate[]; + eligibleIssues: number; + skippedActive: number; + skippedState: number; + skippedCap: number; +} { + const todoIssues = issues + .filter((issue) => TODO_STATE_TYPES.has(issue.stateType)) + .sort((a, b) => { + const byPriority = priorityRank(a.priority) - priorityRank(b.priority); + if (byPriority !== 0) return byPriority; + const byUpdated = updatedAtMs(a.updatedAt) - updatedAtMs(b.updatedAt); + if (byUpdated !== 0) return byUpdated; + return a.identifier.localeCompare(b.identifier); + }); + const available = todoIssues.filter((issue) => !activeIssueIds.has(issue.id)); + const targetCap = Math.max(0, cap); + const targets = available.slice(0, targetCap); + + return { + targets, + eligibleIssues: todoIssues.length, + skippedActive: todoIssues.length - available.length, + skippedState: issues.length - todoIssues.length, + skippedCap: Math.max(0, available.length - targets.length), + }; +} + +export async function executeProjectTodos( + env: Env, + user: CurrentUser, + projectId: string, + options: { agentProvider?: AgentProvider } = {}, +): Promise { + const project = await first<{ id: string }>( + env, + "SELECT id FROM linear_projects WHERE id = ?", + [projectId], + ); + if (!project) { + return Response.json({ error: "Project not found" }, { status: 404 }); + } + + let repo = await activeRepo(env, projectId); + if (!repo) { + await autoMapProjects(env, user.id).catch(() => undefined); + repo = await activeRepo(env, projectId); + } + if (!repo) { + return Response.json( + { error: "No GitHub repo mapped to this project. Map a repo first.", needsRepo: true }, + { status: 409 }, + ); + } + + const { issues, reason } = await getLinearProjectIssues(env, user.id, projectId); + if (reason) { + return Response.json({ error: reason, needsReconnect: reason.includes("Linear") }, { status: 400 }); + } + + const activeRows = await all<{ linear_issue_id: string }>( + env, + `SELECT linear_issue_id + FROM runs + WHERE project_id = ? + AND linear_issue_id IS NOT NULL + AND status IN ('queued', 'running', 'waiting_approval')`, + [projectId], + ); + const activeIssueIds = new Set(activeRows.map((row) => row.linear_issue_id)); + const selection = selectTodoExecutionTargets(issues, activeIssueIds, executeCap(env)); + + const runs: TodoExecutionRun[] = []; + const failedRuns: TodoExecutionResult["failedRuns"] = []; + for (const issue of selection.targets) { + try { + const run = await createAutonomousRun(env, user, { + objective: `${issue.title}\n\n${issue.description ?? ""}`.trim(), + linearProjectId: projectId, + linearIssueId: issue.id, + linearTeamId: issue.teamId ?? undefined, + agentProvider: options.agentProvider ?? "claude-code", + autonomyMode: "auto_eligible", + source: "manual-todo-refresh", + }); + runs.push({ id: run.id, issue: issue.identifier, status: run.status }); + } catch (err) { + failedRuns.push({ + issue: issue.identifier, + error: err instanceof Error ? err.message : String(err), + }); + } + } + + return Response.json({ + totalOpenIssues: issues.length, + eligibleIssues: selection.eligibleIssues, + runs, + skippedActive: selection.skippedActive, + skippedState: selection.skippedState, + skippedCap: selection.skippedCap, + failedRuns, + } satisfies TodoExecutionResult); +} + export async function continueProject( env: Env, user: CurrentUser, @@ -162,3 +295,12 @@ export function executeCap(env: Env): number { const cap = Number.isInteger(n) && n > 0 ? n : DEFAULT_EXECUTE_CAP; return Math.min(cap, MAX_EXECUTE_CAP); } + +function priorityRank(priority: number): number { + return priority > 0 ? priority : Number.MAX_SAFE_INTEGER; +} + +function updatedAtMs(updatedAt: string | null): number { + const ms = updatedAt ? Date.parse(updatedAt) : Number.NaN; + return Number.isFinite(ms) ? ms : Number.MAX_SAFE_INTEGER; +}