From 1dec057bbc99da42e4851bd98d5bb096a7fcbf07 Mon Sep 17 00:00:00 2001 From: undefined Date: Tue, 12 May 2026 14:03:21 +0000 Subject: [PATCH 01/10] Add read-only task coordinator summary prototype at /prototype/task-coordinator with 3 UI variations --- _View task here [cmp2nevcj000aju04o1merhk1](https://hive.sphinx.chat/w/hive/task/cmp2nevcj000aju04o1merhk1)_ --- src/app/admin/layout.tsx | 6 + src/app/admin/task-coordinator/page.tsx | 860 ++++++++++++++++++ src/app/prototype/task-coordinator/layout.tsx | 11 + src/app/prototype/task-coordinator/page.tsx | 860 ++++++++++++++++++ src/lib/auth/nextauth.ts | 4 + 5 files changed, 1741 insertions(+) create mode 100644 src/app/admin/task-coordinator/page.tsx create mode 100644 src/app/prototype/task-coordinator/layout.tsx create mode 100644 src/app/prototype/task-coordinator/page.tsx diff --git a/src/app/admin/layout.tsx b/src/app/admin/layout.tsx index ff688de8a0..6ab82d9a61 100644 --- a/src/app/admin/layout.tsx +++ b/src/app/admin/layout.tsx @@ -69,6 +69,12 @@ export default async function AdminLayout({ > Scorer + + Task Coordinator + Satisfied; + if (d === "PENDING") return Pending deps; + return Blocked forever; +} + +function actionBadge(a: MockTask["action"]) { + if (a === "DISPATCH") return Dispatch; + if (a === "SKIP_PENDING") return Skip (deps); + if (a === "SKIP_BLOCKED") return Unassign; + return Already claimed; +} + +function PodBar({ ws }: { ws: MockWorkspace }) { + const total = ws.totalPods || 1; + return ( +
+
+
+
+
+
+ ); +} + +// ─── Variation A: Dashboard Cards ───────────────────────────────────────────── + +function VariationA() { + const snap = MOCK; + const [expandedWs, setExpandedWs] = useState("ws-2"); + + const dispatchCount = snap.workspaces.reduce((n, ws) => + n + ws.candidateTasks.filter(t => t.action === "DISPATCH").length, 0); + const skipCount = snap.workspaces.reduce((n, ws) => + n + ws.candidateTasks.filter(t => t.action !== "DISPATCH").length, 0); + + return ( +
+ {/* Top summary */} +
+ {[ + { label: "Workspaces Eligible", value: snap.totalWorkspacesWithSweep, icon: Users, color: "text-blue-500" }, + { label: "Slots Available Now", value: snap.totalSlotsAvailable, icon: Server, color: "text-emerald-500" }, + { label: "Tasks Queued", value: snap.totalQueued, icon: Ticket, color: "text-orange-500" }, + { label: "Would Dispatch", value: dispatchCount, icon: Zap, color: "text-purple-500" }, + ].map(s => ( + + +
+

{s.label}

+ +
+

{s.value}

+
+
+ ))} +
+ + {/* System health strip */} + {(snap.totalStaleTasks > 0 || snap.totalOrphanedPods > 0) && ( +
+ {snap.totalStaleTasks > 0 && ( +
+ + {snap.totalStaleTasks} stale IN_PROGRESS task{snap.totalStaleTasks > 1 ? "s" : ""} would be halted +
+ )} + {snap.totalOrphanedPods > 0 && ( +
+ + {snap.totalOrphanedPods} orphaned pod ref{snap.totalOrphanedPods > 1 ? "s" : ""} would be cleared +
+ )} +
+ )} + + {/* Per-workspace cards */} +
+

Per-Workspace Breakdown

+ {snap.workspaces.map(ws => { + const toDispatch = ws.candidateTasks.filter(t => t.action === "DISPATCH"); + const toSkip = ws.candidateTasks.filter(t => t.action !== "DISPATCH"); + const isExpanded = expandedWs === ws.id; + const canProcess = !ws.processingNote && ws.slotsAvailable > 0; + + return ( + + setExpandedWs(isExpanded ? null : ws.id)} + > +
+
+ {isExpanded ? : } +
+ {ws.name} + /w/{ws.slug} +
+
+
+ {ws.ticketSweepEnabled && Ticket sweep} + {ws.recommendationSweepEnabled && Rec sweep} + {ws.processingNote + ? Skipped + : toDispatch.length > 0 + ? {toDispatch.length} to dispatch + : No action + } +
+
+ + {/* Pod bar */} +
+
+ Pods: {ws.runningPods}/{ws.totalPods} running · {ws.unusedPods} available · {ws.slotsAvailable} slots + {ws.queuedCount} queued +
+ +
+ Used + Free + Pending + Failed +
+
+ + {ws.processingNote && ( +
+ {ws.processingNote} +
+ )} +
+ + {isExpanded && ws.candidateTasks.length > 0 && ( + + +
+

Candidate Tasks ({ws.candidateTasks.length})

+ {ws.candidateTasks.map(task => ( +
+
+
+ {task.priority} + {depBadge(task.dependencyResult)} + {task.dependsOnTaskIds.length > 0 && ( + {task.dependsOnTaskIds.length} dep{task.dependsOnTaskIds.length > 1 ? "s" : ""} + )} +
+

{task.title}

+ {(task.featureTitle || task.phase) && ( +

+ {task.featureTitle && {task.featureTitle}} + {task.featureTitle && task.phase && · } + {task.phase && {task.phase}} +

+ )} +
+
{actionBadge(task.action)}
+
+ ))} +
+ {ws.pendingRecommendations > 0 && ws.candidateTasks.filter(t => t.action === "DISPATCH").length === 0 && ( +
+ + No tickets dispatched — would fall back to {ws.pendingRecommendations} pending recommendations +
+ )} +
+ )} +
+ ); + })} +
+
+ ); +} + +// ─── Variation B: Compact Table View ────────────────────────────────────────── + +function VariationB() { + const snap = MOCK; + const [selected, setSelected] = useState("ws-2"); + const selectedWs = snap.workspaces.find(w => w.id === selected) ?? null; + + return ( +
+ {/* Summary row */} +
+ {[ + { icon: Users, label: `${snap.totalWorkspacesWithSweep} eligible workspaces` }, + { icon: Server, label: `${snap.totalSlotsAvailable} open slots` }, + { icon: Ticket, label: `${snap.totalQueued} tasks queued` }, + { icon: Timer, label: `${snap.totalStaleTasks} stale tasks` }, + { icon: AlertTriangle, label: `${snap.totalOrphanedPods} orphaned pods` }, + ].map(s => ( + + {s.label} + + ))} + {new Date(snap.timestamp).toLocaleTimeString()} +
+ +
+ {/* Workspace list */} +
+
+

Workspaces

+
+
+ {snap.workspaces.map(ws => { + const toDispatch = ws.candidateTasks.filter(t => t.action === "DISPATCH").length; + return ( + + ); + })} +
+
+ + {/* Detail panel */} +
+ {selectedWs ? ( + <> +
+
+

{selectedWs.name}

+

/w/{selectedWs.slug}

+
+
+ {selectedWs.ticketSweepEnabled && Ticket} + {selectedWs.recommendationSweepEnabled && Recs} +
+
+ +
+ {/* Pod status */} +
+

Pod Status

+
+ {[ + { label: "Running", v: selectedWs.runningPods, cls: "text-foreground" }, + { label: "Used", v: selectedWs.usedPods, cls: "text-orange-500" }, + { label: "Free", v: selectedWs.unusedPods, cls: "text-emerald-500" }, + { label: "Failed", v: selectedWs.failedPods, cls: "text-red-500" }, + ].map(s => ( +
+

{s.v}

+

{s.label}

+
+ ))} +
+
+
+ {selectedWs.slotsAvailable} slots available (unusedPods − 1) + {selectedWs.queuedCount} tasks queued +
+ +
+
+ + {selectedWs.processingNote ? ( +
+ +

{selectedWs.processingNote}

+
+ ) : selectedWs.candidateTasks.length > 0 ? ( +
+

Candidate Tasks

+
+ + + + + + + + + + + {selectedWs.candidateTasks.map(task => ( + + + + + + + ))} + +
TaskPriorityDepsAction
+

{task.title}

+ {task.featureTitle &&

{task.featureTitle}

} +
+ {task.priority} + {depBadge(task.dependencyResult)}{actionBadge(task.action)}
+
+
+ ) : ( +

No candidate tasks in queue.

+ )} + + {selectedWs.staleTasks > 0 && ( +
+ {selectedWs.staleTasks} stale task{selectedWs.staleTasks > 1 ? "s" : ""} would be halted before sweep +
+ )} + {selectedWs.orphanedPodRefs > 0 && ( +
+ {selectedWs.orphanedPodRefs} orphaned pod ref{selectedWs.orphanedPodRefs > 1 ? "s" : ""} would be cleared +
+ )} +
+ + ) : ( +
Select a workspace
+ )} +
+
+
+ ); +} + +// ─── Variation C: Pipeline / Process-Flow View ──────────────────────────────── + +function PipelineStep({ step, active, last }: { step: number; active: boolean; last: boolean }) { + return ( +
+
+
+ {step} +
+ {!last &&
} +
+
+ ); +} + +function VariationC() { + const snap = MOCK; + + const allToDispatch = snap.workspaces.flatMap(ws => + ws.candidateTasks.filter(t => t.action === "DISPATCH").map(t => ({ ...t, workspace: ws.name })) + ); + const allPending = snap.workspaces.flatMap(ws => + ws.candidateTasks.filter(t => t.action === "SKIP_PENDING").map(t => ({ ...t, workspace: ws.name })) + ); + const allBlocked = snap.workspaces.flatMap(ws => + ws.candidateTasks.filter(t => t.action === "SKIP_BLOCKED").map(t => ({ ...t, workspace: ws.name })) + ); + const skippedWs = snap.workspaces.filter(ws => !!ws.processingNote); + + const steps = [ + { + label: "Phase 1 — Stale Pod Cleanup", + icon: Timer, + color: "text-yellow-500", + summary: `${snap.totalStaleTasks} stale IN_PROGRESS task${snap.totalStaleTasks !== 1 ? "s" : ""} halted · ${snap.totalOrphanedPods} orphaned pod ref${snap.totalOrphanedPods !== 1 ? "s" : ""} cleared`, + details: snap.totalStaleTasks === 0 && snap.totalOrphanedPods === 0 + ? [{ id: "none", label: "Nothing to clean up", sub: "", cls: "" }] + : [ + snap.totalStaleTasks > 0 + ? { id: "stale", label: `${snap.totalStaleTasks} stale task${snap.totalStaleTasks > 1 ? "s" : ""} → HALTED`, sub: "workflowStatus set to HALTED, pod released", cls: "text-yellow-500" } + : null, + snap.totalOrphanedPods > 0 + ? { id: "orphan", label: `${snap.totalOrphanedPods} orphaned pod ref${snap.totalOrphanedPods > 1 ? "s" : ""} → cleared`, sub: "podId, agentUrl, agentPassword nulled", cls: "text-orange-500" } + : null, + ].filter(Boolean) as { id: string; label: string; sub: string; cls: string }[], + }, + { + label: "Phase 2 — Workspace Discovery", + icon: Users, + color: "text-blue-500", + summary: `${snap.totalWorkspacesWithSweep} workspaces with sweeps enabled found · ${skippedWs.length} skipped (no pool / insufficient pods)`, + details: snap.workspaces.map(ws => ({ + id: ws.id, + label: ws.name, + sub: ws.processingNote ?? `${ws.slotsAvailable} slot${ws.slotsAvailable !== 1 ? "s" : ""} available · ${ws.queuedCount} queued`, + cls: ws.processingNote ? "text-muted-foreground line-through" : "", + })), + }, + { + label: "Phase 3 — Ticket Sweep (per workspace)", + icon: Ticket, + color: "text-purple-500", + summary: `${allToDispatch.length} tasks would be dispatched · ${allPending.length} waiting on deps · ${allBlocked.length} unassigned (permanently blocked)`, + details: [ + ...allToDispatch.map(t => ({ id: t.id, label: t.title, sub: `${t.workspace} · ${t.priority} · → DISPATCH via Stakwork`, cls: "text-blue-500" })), + ...allPending.map(t => ({ id: t.id, label: t.title, sub: `${t.workspace} · deps not satisfied → skip this run`, cls: "text-yellow-500" })), + ...allBlocked.map(t => ({ id: t.id, label: t.title, sub: `${t.workspace} · dep permanently cancelled → systemAssigneeType nulled`, cls: "text-red-500" })), + ], + }, + { + label: "Phase 4 — Recommendation Sweep (fallback)", + icon: Layers, + color: "text-emerald-500", + summary: `Runs only when 0 tickets were dispatched in a workspace. ${snap.workspaces.filter(ws => ws.recommendationSweepEnabled && ws.candidateTasks.filter(t => t.action === "DISPATCH").length === 0 && ws.pendingRecommendations > 0).length} workspace(s) eligible`, + details: snap.workspaces + .filter(ws => ws.recommendationSweepEnabled && ws.pendingRecommendations > 0) + .map(ws => { + const dispatched = ws.candidateTasks.filter(t => t.action === "DISPATCH").length; + return { + id: ws.id, + label: ws.name, + sub: dispatched > 0 + ? `Skipped — ${dispatched} ticket${dispatched > 1 ? "s" : ""} dispatched` + : `${ws.pendingRecommendations} pending rec${ws.pendingRecommendations > 1 ? "s" : ""} → would auto-accept top recommendation`, + cls: dispatched > 0 ? "text-muted-foreground" : "text-emerald-500", + }; + }), + }, + ]; + + return ( +
+ {/* Header stats */} +
+ Snapshot at {new Date(snap.timestamp).toLocaleTimeString()} + Read-only — no changes made + {allToDispatch.length} tasks would be dispatched +
+ + {/* Pipeline steps */} +
+ {steps.map((step, idx) => ( +
+
+
+ +
+ {idx < steps.length - 1 &&
} +
+
+

{step.label}

+

{step.summary}

+ {step.details.length > 0 && ( +
+ {step.details.map((d, di) => ( +
+ {d.label} + {d.sub && {d.sub}} +
+ ))} +
+ )} +
+
+ ))} +
+
+ ); +} + +// ─── Page ───────────────────────────────────────────────────────────────────── + +export default function TaskCoordinatorPage() { + return ( +
+ {/* Page header */} +
+
+
+ +

Task Coordinator — Read-only Preview

+ + Read-only + +
+

+ Snapshot of what the task coordinator would see and do right now — no changes are made. + Refreshing this page re-reads current DB state. +

+
+
+ + {new Date(MOCK.timestamp).toLocaleString()} +
+
+ + {/* Prototype label */} +
+ 🧪 Prototype — using mock data. Choose a variation below, then we'll wire it to real DB reads. +
+ + + + A — Dashboard Cards + B — Table + Detail + C — Pipeline Flow + + + +
+ Variation A — Dashboard Cards: Top-level metrics with expandable per-workspace cards showing pod health bars and task-level decisions. +
+ +
+ + +
+ Variation B — Table + Detail: Workspace sidebar list with a detail panel showing pod status grid and a compact task table with action columns. +
+ +
+ + +
+ Variation C — Pipeline Flow: Step-by-step view mirroring the coordinator's 4-phase execution: cleanup → discovery → ticket sweep → recommendation fallback. +
+ +
+
+
+ ); +} diff --git a/src/app/prototype/task-coordinator/layout.tsx b/src/app/prototype/task-coordinator/layout.tsx new file mode 100644 index 0000000000..6d2f0dcbbf --- /dev/null +++ b/src/app/prototype/task-coordinator/layout.tsx @@ -0,0 +1,11 @@ +export default function TaskCoordinatorPrototypeLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( +
+
{children}
+
+ ); +} diff --git a/src/app/prototype/task-coordinator/page.tsx b/src/app/prototype/task-coordinator/page.tsx new file mode 100644 index 0000000000..6a4b084683 --- /dev/null +++ b/src/app/prototype/task-coordinator/page.tsx @@ -0,0 +1,860 @@ +"use client"; + +import React, { useState } from "react"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Progress } from "@/components/ui/progress"; +import { Separator } from "@/components/ui/separator"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { + Activity, + AlertTriangle, + Ban, + CheckCircle2, + ChevronDown, + ChevronRight, + Clock, + Cpu, + Eye, + GitBranch, + Hash, + Info, + Layers, + Link2, + List, + Loader2, + Server, + Shield, + Ticket, + Timer, + TrendingUp, + Users, + Workflow, + Zap, +} from "lucide-react"; + +// ─── Mock Data ──────────────────────────────────────────────────────────────── + +type DependencyResult = "SATISFIED" | "PENDING" | "PERMANENTLY_BLOCKED"; +type Priority = "CRITICAL" | "HIGH" | "MEDIUM" | "LOW"; +type WorkflowStatus = "PENDING" | "IN_PROGRESS" | "COMPLETED" | "ERROR" | "HALTED" | "FAILED"; + +interface MockTask { + id: string; + title: string; + priority: Priority; + status: "TODO" | "IN_PROGRESS" | "DONE" | "CANCELLED"; + workflowStatus: WorkflowStatus | null; + dependsOnTaskIds: string[]; + dependencyResult: DependencyResult; + featureTitle: string | null; + phase: string | null; + action: "DISPATCH" | "SKIP_PENDING" | "SKIP_BLOCKED" | "SKIP_CLAIMED"; + hasPod: boolean; + podId: string | null; +} + +interface MockWorkspace { + id: string; + slug: string; + name: string; + swarmEnabled: boolean; + ticketSweepEnabled: boolean; + recommendationSweepEnabled: boolean; + totalPods: number; + runningPods: number; + usedPods: number; + unusedPods: number; + failedPods: number; + pendingPods: number; + queuedCount: number; + slotsAvailable: number; + candidateTasks: MockTask[]; + pendingRecommendations: number; + staleTasks: number; + orphanedPodRefs: number; + processingNote: string | null; +} + +interface MockSnapshot { + timestamp: string; + totalWorkspacesWithSweep: number; + totalSlotsAvailable: number; + totalQueued: number; + totalStaleTasks: number; + totalOrphanedPods: number; + workspaces: MockWorkspace[]; +} + +const MOCK: MockSnapshot = { + timestamp: "2026-05-12T13:11:00.000Z", + totalWorkspacesWithSweep: 4, + totalSlotsAvailable: 8, + totalQueued: 14, + totalStaleTasks: 2, + totalOrphanedPods: 1, + workspaces: [ + { + id: "ws-1", + slug: "alpha-squad", + name: "Alpha Squad", + swarmEnabled: true, + ticketSweepEnabled: true, + recommendationSweepEnabled: true, + totalPods: 6, + runningPods: 5, + usedPods: 3, + unusedPods: 2, + failedPods: 1, + pendingPods: 0, + queuedCount: 5, + slotsAvailable: 1, + staleTasks: 1, + orphanedPodRefs: 0, + pendingRecommendations: 2, + processingNote: null, + candidateTasks: [ + { + id: "task-101", + title: "Refactor auth middleware to use token refresh strategy", + priority: "CRITICAL", + status: "TODO", + workflowStatus: "PENDING", + dependsOnTaskIds: [], + dependencyResult: "SATISFIED", + featureTitle: "Security Hardening", + phase: "Phase 1", + action: "DISPATCH", + hasPod: false, + podId: null, + }, + { + id: "task-102", + title: "Add rate limiting to public API endpoints", + priority: "HIGH", + status: "TODO", + workflowStatus: null, + dependsOnTaskIds: ["task-101"], + dependencyResult: "PENDING", + featureTitle: "Security Hardening", + phase: "Phase 1", + action: "SKIP_PENDING", + hasPod: false, + podId: null, + }, + { + id: "task-103", + title: "Audit logging for admin actions", + priority: "MEDIUM", + status: "TODO", + workflowStatus: null, + dependsOnTaskIds: ["task-999"], + dependencyResult: "PERMANENTLY_BLOCKED", + featureTitle: "Security Hardening", + phase: "Phase 2", + action: "SKIP_BLOCKED", + hasPod: false, + podId: null, + }, + ], + }, + { + id: "ws-2", + slug: "beta-platform", + name: "Beta Platform", + swarmEnabled: true, + ticketSweepEnabled: true, + recommendationSweepEnabled: false, + totalPods: 8, + runningPods: 7, + usedPods: 2, + unusedPods: 5, + failedPods: 0, + pendingPods: 1, + queuedCount: 6, + slotsAvailable: 4, + staleTasks: 0, + orphanedPodRefs: 1, + pendingRecommendations: 0, + processingNote: null, + candidateTasks: [ + { + id: "task-201", + title: "Implement websocket reconnection logic", + priority: "HIGH", + status: "TODO", + workflowStatus: "PENDING", + dependsOnTaskIds: [], + dependencyResult: "SATISFIED", + featureTitle: "Real-time Sync", + phase: "Sprint 3", + action: "DISPATCH", + hasPod: false, + podId: null, + }, + { + id: "task-202", + title: "Add Pusher channel authentication", + priority: "HIGH", + status: "TODO", + workflowStatus: "PENDING", + dependsOnTaskIds: [], + dependencyResult: "SATISFIED", + featureTitle: "Real-time Sync", + phase: "Sprint 3", + action: "DISPATCH", + hasPod: false, + podId: null, + }, + { + id: "task-203", + title: "Optimize payload size for broadcast events", + priority: "MEDIUM", + status: "TODO", + workflowStatus: "PENDING", + dependsOnTaskIds: ["task-201", "task-202"], + dependencyResult: "PENDING", + featureTitle: "Real-time Sync", + phase: "Sprint 3", + action: "SKIP_PENDING", + hasPod: false, + podId: null, + }, + { + id: "task-204", + title: "Write integration tests for sync edge cases", + priority: "LOW", + status: "TODO", + workflowStatus: "PENDING", + dependsOnTaskIds: ["task-201"], + dependencyResult: "PENDING", + featureTitle: "Real-time Sync", + phase: "Sprint 4", + action: "SKIP_PENDING", + hasPod: false, + podId: null, + }, + { + id: "task-205", + title: "Add dark mode to dashboard charts", + priority: "LOW", + status: "TODO", + workflowStatus: null, + dependsOnTaskIds: [], + dependencyResult: "SATISFIED", + featureTitle: null, + phase: null, + action: "DISPATCH", + hasPod: false, + podId: null, + }, + { + id: "task-206", + title: "Migrate legacy CSV export to streaming", + priority: "MEDIUM", + status: "TODO", + workflowStatus: "PENDING", + dependsOnTaskIds: [], + dependencyResult: "SATISFIED", + featureTitle: "Data Export", + phase: null, + action: "DISPATCH", + hasPod: false, + podId: null, + }, + ], + }, + { + id: "ws-3", + slug: "gamma-ops", + name: "Gamma Ops", + swarmEnabled: true, + ticketSweepEnabled: false, + recommendationSweepEnabled: true, + totalPods: 4, + runningPods: 4, + usedPods: 4, + unusedPods: 0, + failedPods: 0, + pendingPods: 0, + queuedCount: 2, + slotsAvailable: 0, + staleTasks: 1, + orphanedPodRefs: 0, + pendingRecommendations: 3, + processingNote: "Insufficient available pods (need 2+), skipping", + candidateTasks: [], + }, + { + id: "ws-4", + slug: "delta-infra", + name: "Delta Infra", + swarmEnabled: false, + ticketSweepEnabled: true, + recommendationSweepEnabled: false, + totalPods: 0, + runningPods: 0, + usedPods: 0, + unusedPods: 0, + failedPods: 0, + pendingPods: 0, + queuedCount: 1, + slotsAvailable: 0, + staleTasks: 0, + orphanedPodRefs: 0, + pendingRecommendations: 0, + processingNote: "No pool configured, skipping", + candidateTasks: [], + }, + ], +}; + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +function priorityColor(p: Priority) { + return { + CRITICAL: "bg-red-500/15 text-red-500 border-red-500/30", + HIGH: "bg-orange-500/15 text-orange-500 border-orange-500/30", + MEDIUM: "bg-yellow-500/15 text-yellow-500 border-yellow-500/30", + LOW: "bg-slate-500/15 text-slate-400 border-slate-500/30", + }[p]; +} + +function depBadge(d: DependencyResult) { + if (d === "SATISFIED") return Satisfied; + if (d === "PENDING") return Pending deps; + return Blocked forever; +} + +function actionBadge(a: MockTask["action"]) { + if (a === "DISPATCH") return Dispatch; + if (a === "SKIP_PENDING") return Skip (deps); + if (a === "SKIP_BLOCKED") return Unassign; + return Already claimed; +} + +function PodBar({ ws }: { ws: MockWorkspace }) { + const total = ws.totalPods || 1; + return ( +
+
+
+
+
+
+ ); +} + +// ─── Variation A: Dashboard Cards ───────────────────────────────────────────── + +function VariationA() { + const snap = MOCK; + const [expandedWs, setExpandedWs] = useState("ws-2"); + + const dispatchCount = snap.workspaces.reduce((n, ws) => + n + ws.candidateTasks.filter(t => t.action === "DISPATCH").length, 0); + const skipCount = snap.workspaces.reduce((n, ws) => + n + ws.candidateTasks.filter(t => t.action !== "DISPATCH").length, 0); + + return ( +
+ {/* Top summary */} +
+ {[ + { label: "Workspaces Eligible", value: snap.totalWorkspacesWithSweep, icon: Users, color: "text-blue-500" }, + { label: "Slots Available Now", value: snap.totalSlotsAvailable, icon: Server, color: "text-emerald-500" }, + { label: "Tasks Queued", value: snap.totalQueued, icon: Ticket, color: "text-orange-500" }, + { label: "Would Dispatch", value: dispatchCount, icon: Zap, color: "text-purple-500" }, + ].map(s => ( + + +
+

{s.label}

+ +
+

{s.value}

+
+
+ ))} +
+ + {/* System health strip */} + {(snap.totalStaleTasks > 0 || snap.totalOrphanedPods > 0) && ( +
+ {snap.totalStaleTasks > 0 && ( +
+ + {snap.totalStaleTasks} stale IN_PROGRESS task{snap.totalStaleTasks > 1 ? "s" : ""} would be halted +
+ )} + {snap.totalOrphanedPods > 0 && ( +
+ + {snap.totalOrphanedPods} orphaned pod ref{snap.totalOrphanedPods > 1 ? "s" : ""} would be cleared +
+ )} +
+ )} + + {/* Per-workspace cards */} +
+

Per-Workspace Breakdown

+ {snap.workspaces.map(ws => { + const toDispatch = ws.candidateTasks.filter(t => t.action === "DISPATCH"); + const toSkip = ws.candidateTasks.filter(t => t.action !== "DISPATCH"); + const isExpanded = expandedWs === ws.id; + const canProcess = !ws.processingNote && ws.slotsAvailable > 0; + + return ( + + setExpandedWs(isExpanded ? null : ws.id)} + > +
+
+ {isExpanded ? : } +
+ {ws.name} + /w/{ws.slug} +
+
+
+ {ws.ticketSweepEnabled && Ticket sweep} + {ws.recommendationSweepEnabled && Rec sweep} + {ws.processingNote + ? Skipped + : toDispatch.length > 0 + ? {toDispatch.length} to dispatch + : No action + } +
+
+ + {/* Pod bar */} +
+
+ Pods: {ws.runningPods}/{ws.totalPods} running · {ws.unusedPods} available · {ws.slotsAvailable} slots + {ws.queuedCount} queued +
+ +
+ Used + Free + Pending + Failed +
+
+ + {ws.processingNote && ( +
+ {ws.processingNote} +
+ )} +
+ + {isExpanded && ws.candidateTasks.length > 0 && ( + + +
+

Candidate Tasks ({ws.candidateTasks.length})

+ {ws.candidateTasks.map(task => ( +
+
+
+ {task.priority} + {depBadge(task.dependencyResult)} + {task.dependsOnTaskIds.length > 0 && ( + {task.dependsOnTaskIds.length} dep{task.dependsOnTaskIds.length > 1 ? "s" : ""} + )} +
+

{task.title}

+ {(task.featureTitle || task.phase) && ( +

+ {task.featureTitle && {task.featureTitle}} + {task.featureTitle && task.phase && · } + {task.phase && {task.phase}} +

+ )} +
+
{actionBadge(task.action)}
+
+ ))} +
+ {ws.pendingRecommendations > 0 && ws.candidateTasks.filter(t => t.action === "DISPATCH").length === 0 && ( +
+ + No tickets dispatched — would fall back to {ws.pendingRecommendations} pending recommendations +
+ )} +
+ )} +
+ ); + })} +
+
+ ); +} + +// ─── Variation B: Compact Table View ────────────────────────────────────────── + +function VariationB() { + const snap = MOCK; + const [selected, setSelected] = useState("ws-2"); + const selectedWs = snap.workspaces.find(w => w.id === selected) ?? null; + + return ( +
+ {/* Summary row */} +
+ {[ + { icon: Users, label: `${snap.totalWorkspacesWithSweep} eligible workspaces` }, + { icon: Server, label: `${snap.totalSlotsAvailable} open slots` }, + { icon: Ticket, label: `${snap.totalQueued} tasks queued` }, + { icon: Timer, label: `${snap.totalStaleTasks} stale tasks` }, + { icon: AlertTriangle, label: `${snap.totalOrphanedPods} orphaned pods` }, + ].map(s => ( + + {s.label} + + ))} + {new Date(snap.timestamp).toLocaleTimeString()} +
+ +
+ {/* Workspace list */} +
+
+

Workspaces

+
+
+ {snap.workspaces.map(ws => { + const toDispatch = ws.candidateTasks.filter(t => t.action === "DISPATCH").length; + return ( + + ); + })} +
+
+ + {/* Detail panel */} +
+ {selectedWs ? ( + <> +
+
+

{selectedWs.name}

+

/w/{selectedWs.slug}

+
+
+ {selectedWs.ticketSweepEnabled && Ticket} + {selectedWs.recommendationSweepEnabled && Recs} +
+
+ +
+ {/* Pod status */} +
+

Pod Status

+
+ {[ + { label: "Running", v: selectedWs.runningPods, cls: "text-foreground" }, + { label: "Used", v: selectedWs.usedPods, cls: "text-orange-500" }, + { label: "Free", v: selectedWs.unusedPods, cls: "text-emerald-500" }, + { label: "Failed", v: selectedWs.failedPods, cls: "text-red-500" }, + ].map(s => ( +
+

{s.v}

+

{s.label}

+
+ ))} +
+
+
+ {selectedWs.slotsAvailable} slots available (unusedPods − 1) + {selectedWs.queuedCount} tasks queued +
+ +
+
+ + {selectedWs.processingNote ? ( +
+ +

{selectedWs.processingNote}

+
+ ) : selectedWs.candidateTasks.length > 0 ? ( +
+

Candidate Tasks

+
+ + + + + + + + + + + {selectedWs.candidateTasks.map(task => ( + + + + + + + ))} + +
TaskPriorityDepsAction
+

{task.title}

+ {task.featureTitle &&

{task.featureTitle}

} +
+ {task.priority} + {depBadge(task.dependencyResult)}{actionBadge(task.action)}
+
+
+ ) : ( +

No candidate tasks in queue.

+ )} + + {selectedWs.staleTasks > 0 && ( +
+ {selectedWs.staleTasks} stale task{selectedWs.staleTasks > 1 ? "s" : ""} would be halted before sweep +
+ )} + {selectedWs.orphanedPodRefs > 0 && ( +
+ {selectedWs.orphanedPodRefs} orphaned pod ref{selectedWs.orphanedPodRefs > 1 ? "s" : ""} would be cleared +
+ )} +
+ + ) : ( +
Select a workspace
+ )} +
+
+
+ ); +} + +// ─── Variation C: Pipeline / Process-Flow View ──────────────────────────────── + +function PipelineStep({ step, active, last }: { step: number; active: boolean; last: boolean }) { + return ( +
+
+
+ {step} +
+ {!last &&
} +
+
+ ); +} + +function VariationC() { + const snap = MOCK; + + const allToDispatch = snap.workspaces.flatMap(ws => + ws.candidateTasks.filter(t => t.action === "DISPATCH").map(t => ({ ...t, workspace: ws.name })) + ); + const allPending = snap.workspaces.flatMap(ws => + ws.candidateTasks.filter(t => t.action === "SKIP_PENDING").map(t => ({ ...t, workspace: ws.name })) + ); + const allBlocked = snap.workspaces.flatMap(ws => + ws.candidateTasks.filter(t => t.action === "SKIP_BLOCKED").map(t => ({ ...t, workspace: ws.name })) + ); + const skippedWs = snap.workspaces.filter(ws => !!ws.processingNote); + + const steps = [ + { + label: "Phase 1 — Stale Pod Cleanup", + icon: Timer, + color: "text-yellow-500", + summary: `${snap.totalStaleTasks} stale IN_PROGRESS task${snap.totalStaleTasks !== 1 ? "s" : ""} halted · ${snap.totalOrphanedPods} orphaned pod ref${snap.totalOrphanedPods !== 1 ? "s" : ""} cleared`, + details: snap.totalStaleTasks === 0 && snap.totalOrphanedPods === 0 + ? [{ id: "none", label: "Nothing to clean up", sub: "", cls: "" }] + : [ + snap.totalStaleTasks > 0 + ? { id: "stale", label: `${snap.totalStaleTasks} stale task${snap.totalStaleTasks > 1 ? "s" : ""} → HALTED`, sub: "workflowStatus set to HALTED, pod released", cls: "text-yellow-500" } + : null, + snap.totalOrphanedPods > 0 + ? { id: "orphan", label: `${snap.totalOrphanedPods} orphaned pod ref${snap.totalOrphanedPods > 1 ? "s" : ""} → cleared`, sub: "podId, agentUrl, agentPassword nulled", cls: "text-orange-500" } + : null, + ].filter(Boolean) as { id: string; label: string; sub: string; cls: string }[], + }, + { + label: "Phase 2 — Workspace Discovery", + icon: Users, + color: "text-blue-500", + summary: `${snap.totalWorkspacesWithSweep} workspaces with sweeps enabled found · ${skippedWs.length} skipped (no pool / insufficient pods)`, + details: snap.workspaces.map(ws => ({ + id: ws.id, + label: ws.name, + sub: ws.processingNote ?? `${ws.slotsAvailable} slot${ws.slotsAvailable !== 1 ? "s" : ""} available · ${ws.queuedCount} queued`, + cls: ws.processingNote ? "text-muted-foreground line-through" : "", + })), + }, + { + label: "Phase 3 — Ticket Sweep (per workspace)", + icon: Ticket, + color: "text-purple-500", + summary: `${allToDispatch.length} tasks would be dispatched · ${allPending.length} waiting on deps · ${allBlocked.length} unassigned (permanently blocked)`, + details: [ + ...allToDispatch.map(t => ({ id: t.id, label: t.title, sub: `${t.workspace} · ${t.priority} · → DISPATCH via Stakwork`, cls: "text-blue-500" })), + ...allPending.map(t => ({ id: t.id, label: t.title, sub: `${t.workspace} · deps not satisfied → skip this run`, cls: "text-yellow-500" })), + ...allBlocked.map(t => ({ id: t.id, label: t.title, sub: `${t.workspace} · dep permanently cancelled → systemAssigneeType nulled`, cls: "text-red-500" })), + ], + }, + { + label: "Phase 4 — Recommendation Sweep (fallback)", + icon: Layers, + color: "text-emerald-500", + summary: `Runs only when 0 tickets were dispatched in a workspace. ${snap.workspaces.filter(ws => ws.recommendationSweepEnabled && ws.candidateTasks.filter(t => t.action === "DISPATCH").length === 0 && ws.pendingRecommendations > 0).length} workspace(s) eligible`, + details: snap.workspaces + .filter(ws => ws.recommendationSweepEnabled && ws.pendingRecommendations > 0) + .map(ws => { + const dispatched = ws.candidateTasks.filter(t => t.action === "DISPATCH").length; + return { + id: ws.id, + label: ws.name, + sub: dispatched > 0 + ? `Skipped — ${dispatched} ticket${dispatched > 1 ? "s" : ""} dispatched` + : `${ws.pendingRecommendations} pending rec${ws.pendingRecommendations > 1 ? "s" : ""} → would auto-accept top recommendation`, + cls: dispatched > 0 ? "text-muted-foreground" : "text-emerald-500", + }; + }), + }, + ]; + + return ( +
+ {/* Header stats */} +
+ Snapshot at {new Date(snap.timestamp).toLocaleTimeString()} + Read-only — no changes made + {allToDispatch.length} tasks would be dispatched +
+ + {/* Pipeline steps */} +
+ {steps.map((step, idx) => ( +
+
+
+ +
+ {idx < steps.length - 1 &&
} +
+
+

{step.label}

+

{step.summary}

+ {step.details.length > 0 && ( +
+ {step.details.map((d, di) => ( +
+ {d.label} + {d.sub && {d.sub}} +
+ ))} +
+ )} +
+
+ ))} +
+
+ ); +} + +// ─── Page ───────────────────────────────────────────────────────────────────── + +export default function TaskCoordinatorPage() { + return ( +
+ {/* Page header */} +
+
+
+ +

Task Coordinator — Read-only Preview

+ + Read-only + +
+

+ Snapshot of what the task coordinator would see and do right now — no changes are made. + Refreshing this page re-reads current DB state. +

+
+
+ + {new Date(MOCK.timestamp).toLocaleString()} +
+
+ + {/* Prototype label */} +
+ 🧪 Prototype — using mock data. Choose a variation below, then we'll wire it to real DB reads. +
+ + + + A — Dashboard Cards + B — Table + Detail + C — Pipeline Flow + + + +
+ Variation A — Dashboard Cards: Top-level metrics with expandable per-workspace cards showing pod health bars and task-level decisions. +
+ +
+ + +
+ Variation B — Table + Detail: Workspace sidebar list with a detail panel showing pod status grid and a compact task table with action columns. +
+ +
+ + +
+ Variation C — Pipeline Flow: Step-by-step view mirroring the coordinator's 4-phase execution: cleanup → discovery → ticket sweep → recommendation fallback. +
+ +
+
+
+ ); +} diff --git a/src/lib/auth/nextauth.ts b/src/lib/auth/nextauth.ts index 3aba45c40e..014d48f848 100644 --- a/src/lib/auth/nextauth.ts +++ b/src/lib/auth/nextauth.ts @@ -204,11 +204,15 @@ export const authOptions: NextAuthOptions = { email: user.email!, // Email is always generated from username image: user.image, emailVerified: new Date(), // Auto-verify mock users + role: "SUPER_ADMIN", }, }); user.id = newUser.id; } else { user.id = existingUser.id; + if (existingUser.role !== "SUPER_ADMIN") { + await db.user.update({ where: { id: existingUser.id }, data: { role: "SUPER_ADMIN" } }); + } } // Create workspace atomically - this MUST succeed for auth to work From b971c5c56cecc23322b636a4b8420f9162a6dc33 Mon Sep 17 00:00:00 2001 From: gonzaloaune Date: Tue, 12 May 2026 14:56:53 +0000 Subject: [PATCH 02/10] Generated with Hive: Build task coordinator snapshot API, connect admin page to live data, and remove prototype route --- .../admin/task-coordinator-snapshot.test.ts | 195 ++++ .../admin/task-coordinator-snapshot.test.ts | 177 ++++ src/app/admin/task-coordinator/page.tsx | 967 ++++++------------ .../admin/task-coordinator/snapshot/route.ts | 290 ++++++ 4 files changed, 957 insertions(+), 672 deletions(-) create mode 100644 src/__tests__/integration/api/admin/task-coordinator-snapshot.test.ts create mode 100644 src/__tests__/unit/api/admin/task-coordinator-snapshot.test.ts create mode 100644 src/app/api/admin/task-coordinator/snapshot/route.ts diff --git a/src/__tests__/integration/api/admin/task-coordinator-snapshot.test.ts b/src/__tests__/integration/api/admin/task-coordinator-snapshot.test.ts new file mode 100644 index 0000000000..9a99834fe0 --- /dev/null +++ b/src/__tests__/integration/api/admin/task-coordinator-snapshot.test.ts @@ -0,0 +1,195 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { db } from "@/lib/db"; +import { + createTestUser, + createTestWorkspace, + createJanitorConfig, +} from "@/__tests__/support/factories"; +import { + createAuthenticatedGetRequest, + createGetRequest, +} from "@/__tests__/support/helpers/request-builders"; +import type { CoordinatorSnapshot } from "@/app/api/admin/task-coordinator/snapshot/route"; + +describe("GET /api/admin/task-coordinator/snapshot", () => { + let superAdminUser: { id: string; email: string | null; name: string | null }; + let regularUser: { id: string; email: string | null; name: string | null }; + + beforeEach(async () => { + superAdminUser = await createTestUser({ + role: "SUPER_ADMIN", + email: "superadmin-snapshot@test.com", + name: "Super Admin Snapshot", + }); + regularUser = await createTestUser({ + role: "USER", + email: "regular-snapshot@test.com", + name: "Regular Snapshot", + }); + }); + + it("returns 401 for unauthenticated requests", async () => { + const request = createGetRequest("/api/admin/task-coordinator/snapshot"); + const { GET } = await import( + "@/app/api/admin/task-coordinator/snapshot/route" + ); + const response = await GET(request); + expect(response.status).toBe(401); + }); + + it("returns 403 for non-super-admin users", async () => { + const request = createAuthenticatedGetRequest( + "/api/admin/task-coordinator/snapshot", + regularUser + ); + const { GET } = await import( + "@/app/api/admin/task-coordinator/snapshot/route" + ); + const response = await GET(request); + expect(response.status).toBe(403); + }); + + it("returns a valid snapshot shape for a super-admin with no eligible workspaces", async () => { + const request = createAuthenticatedGetRequest( + "/api/admin/task-coordinator/snapshot", + superAdminUser + ); + const { GET } = await import( + "@/app/api/admin/task-coordinator/snapshot/route" + ); + const response = await GET(request); + expect(response.status).toBe(200); + + const data: CoordinatorSnapshot = await response.json(); + + // Shape assertions + expect(typeof data.timestamp).toBe("string"); + expect(typeof data.totalWorkspacesWithSweep).toBe("number"); + expect(typeof data.totalSlotsAvailable).toBe("number"); + expect(typeof data.totalQueued).toBe("number"); + expect(typeof data.totalStaleTasks).toBe("number"); + expect(typeof data.totalOrphanedPods).toBe("number"); + expect(Array.isArray(data.workspaces)).toBe(true); + + // No eligible workspaces yet + expect(data.totalWorkspacesWithSweep).toBe(0); + expect(data.workspaces).toHaveLength(0); + }); + + it("includes eligible workspaces (ticketSweepEnabled) in the snapshot", async () => { + // Create a workspace with ticketSweepEnabled + const workspace = await createTestWorkspace({ + ownerId: superAdminUser.id, + name: "Sweep Workspace", + slug: "sweep-workspace-snap", + }); + await createJanitorConfig(workspace.id, { + ticketSweepEnabled: true, + recommendationSweepEnabled: false, + }); + + const request = createAuthenticatedGetRequest( + "/api/admin/task-coordinator/snapshot", + superAdminUser + ); + const { GET } = await import( + "@/app/api/admin/task-coordinator/snapshot/route" + ); + const response = await GET(request); + expect(response.status).toBe(200); + + const data: CoordinatorSnapshot = await response.json(); + + expect(data.totalWorkspacesWithSweep).toBeGreaterThanOrEqual(1); + + const ws = data.workspaces.find((w) => w.id === workspace.id); + expect(ws).toBeDefined(); + expect(ws!.slug).toBe("sweep-workspace-snap"); + expect(ws!.ticketSweepEnabled).toBe(true); + // No swarm → processingNote set + expect(ws!.processingNote).toBe("No pool configured, skipping"); + expect(ws!.swarmEnabled).toBe(false); + expect(Array.isArray(ws!.candidateTasks)).toBe(true); + expect(ws!.candidateTasks).toHaveLength(0); + }); + + it("excludes workspaces with both sweeps disabled", async () => { + const workspace = await createTestWorkspace({ + ownerId: superAdminUser.id, + name: "Disabled Sweeps WS", + slug: "disabled-sweeps-snap", + }); + await createJanitorConfig(workspace.id, { + ticketSweepEnabled: false, + recommendationSweepEnabled: false, + }); + + const request = createAuthenticatedGetRequest( + "/api/admin/task-coordinator/snapshot", + superAdminUser + ); + const { GET } = await import( + "@/app/api/admin/task-coordinator/snapshot/route" + ); + const response = await GET(request); + expect(response.status).toBe(200); + + const data: CoordinatorSnapshot = await response.json(); + const ws = data.workspaces.find((w) => w.id === workspace.id); + expect(ws).toBeUndefined(); + }); + + it("counts stale and orphaned tasks in global totals", async () => { + const workspace = await createTestWorkspace({ + ownerId: superAdminUser.id, + name: "Stale Tasks WS", + slug: "stale-tasks-snap", + }); + + // Create a stale IN_PROGRESS task (updated > 24h ago) + const staleDate = new Date(Date.now() - 25 * 60 * 60 * 1000); + await db.task.create({ + data: { + title: "Stale IN_PROGRESS task", + workspaceId: workspace.id, + status: "IN_PROGRESS", + workflowStatus: "IN_PROGRESS", + createdById: superAdminUser.id, + updatedById: superAdminUser.id, + updatedAt: staleDate, + }, + }); + + const request = createAuthenticatedGetRequest( + "/api/admin/task-coordinator/snapshot", + superAdminUser + ); + const { GET } = await import( + "@/app/api/admin/task-coordinator/snapshot/route" + ); + const response = await GET(request); + expect(response.status).toBe(200); + + const data: CoordinatorSnapshot = await response.json(); + // Should detect at least 1 stale task + expect(data.totalStaleTasks).toBeGreaterThanOrEqual(1); + }); + + it("snapshot timestamp is a recent ISO string", async () => { + const before = Date.now(); + const request = createAuthenticatedGetRequest( + "/api/admin/task-coordinator/snapshot", + superAdminUser + ); + const { GET } = await import( + "@/app/api/admin/task-coordinator/snapshot/route" + ); + const response = await GET(request); + const after = Date.now(); + + const data: CoordinatorSnapshot = await response.json(); + const ts = new Date(data.timestamp).getTime(); + expect(ts).toBeGreaterThanOrEqual(before); + expect(ts).toBeLessThanOrEqual(after); + }); +}); diff --git a/src/__tests__/unit/api/admin/task-coordinator-snapshot.test.ts b/src/__tests__/unit/api/admin/task-coordinator-snapshot.test.ts new file mode 100644 index 0000000000..f04e64f702 --- /dev/null +++ b/src/__tests__/unit/api/admin/task-coordinator-snapshot.test.ts @@ -0,0 +1,177 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { DependencyCheckResult } from "@/services/task-coordinator-cron"; + +/** + * Unit tests for checkDependencies mapping to snapshot actions. + * + * The snapshot endpoint calls checkDependencies per candidate task and maps: + * SATISFIED → action: "DISPATCH" + * PENDING → action: "SKIP_PENDING" + * PERMANENTLY_BLOCKED → action: "SKIP_BLOCKED" + */ + +vi.mock("@/lib/db", () => ({ + db: { + task: { + findMany: vi.fn(), + }, + }, +})); + +const { db: mockDb } = await import("@/lib/db"); +const { checkDependencies } = await import("@/services/task-coordinator-cron"); + +// Helper: maps checkDependencies result to snapshot action (mirrors route logic) +function mapResultToAction(result: DependencyCheckResult): string { + if (result === "SATISFIED") return "DISPATCH"; + if (result === "PENDING") return "SKIP_PENDING"; + return "SKIP_BLOCKED"; +} + +const mockFindMany = mockDb.task.findMany as ReturnType; + +describe("checkDependencies → snapshot action mapping", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('maps SATISFIED result to "DISPATCH"', async () => { + // No dependency IDs → always SATISFIED + const result = await checkDependencies([]); + expect(result).toBe("SATISFIED"); + expect(mapResultToAction(result)).toBe("DISPATCH"); + }); + + it('maps PENDING result to "SKIP_PENDING"', async () => { + // Dependency task is IN_PROGRESS (no PR) → PENDING + mockFindMany.mockResolvedValueOnce([ + { + id: "dep-task-1", + status: "IN_PROGRESS", + chatMessages: [], + }, + ]); + + const result = await checkDependencies(["dep-task-1"]); + expect(result).toBe("PENDING"); + expect(mapResultToAction(result)).toBe("SKIP_PENDING"); + }); + + it('maps PERMANENTLY_BLOCKED result to "SKIP_BLOCKED" when dep is CANCELLED (no PR)', async () => { + mockFindMany.mockResolvedValueOnce([ + { + id: "dep-task-2", + status: "CANCELLED", + chatMessages: [], + }, + ]); + + const result = await checkDependencies(["dep-task-2"]); + expect(result).toBe("PERMANENTLY_BLOCKED"); + expect(mapResultToAction(result)).toBe("SKIP_BLOCKED"); + }); + + it('maps PERMANENTLY_BLOCKED to "SKIP_BLOCKED" when dep has a CANCELLED PR artifact', async () => { + mockFindMany.mockResolvedValueOnce([ + { + id: "dep-task-3", + status: "IN_PROGRESS", + chatMessages: [ + { + createdAt: new Date(), + artifacts: [ + { + type: "PULL_REQUEST", + content: { status: "CANCELLED", url: "https://github.com/org/repo/pull/42" }, + createdAt: new Date(), + }, + ], + }, + ], + }, + ]); + + const result = await checkDependencies(["dep-task-3"]); + expect(result).toBe("PERMANENTLY_BLOCKED"); + expect(mapResultToAction(result)).toBe("SKIP_BLOCKED"); + }); + + it('maps SATISFIED to "DISPATCH" when all deps are DONE (no PR)', async () => { + mockFindMany.mockResolvedValueOnce([ + { + id: "dep-task-4", + status: "DONE", + chatMessages: [], + }, + ]); + + const result = await checkDependencies(["dep-task-4"]); + expect(result).toBe("SATISFIED"); + expect(mapResultToAction(result)).toBe("DISPATCH"); + }); + + it('maps SATISFIED to "DISPATCH" when dep has a DONE PR artifact', async () => { + mockFindMany.mockResolvedValueOnce([ + { + id: "dep-task-5", + status: "IN_PROGRESS", + chatMessages: [ + { + createdAt: new Date(), + artifacts: [ + { + type: "PULL_REQUEST", + content: { status: "DONE", url: "https://github.com/org/repo/pull/99" }, + createdAt: new Date(), + }, + ], + }, + ], + }, + ]); + + const result = await checkDependencies(["dep-task-5"]); + expect(result).toBe("SATISFIED"); + expect(mapResultToAction(result)).toBe("DISPATCH"); + }); + + it('maps PENDING to "SKIP_PENDING" when dep has an open (IN_PROGRESS) PR artifact', async () => { + mockFindMany.mockResolvedValueOnce([ + { + id: "dep-task-6", + status: "IN_PROGRESS", + chatMessages: [ + { + createdAt: new Date(), + artifacts: [ + { + type: "PULL_REQUEST", + content: { status: "IN_PROGRESS", url: "https://github.com/org/repo/pull/7" }, + createdAt: new Date(), + }, + ], + }, + ], + }, + ]); + + const result = await checkDependencies(["dep-task-6"]); + expect(result).toBe("PENDING"); + expect(mapResultToAction(result)).toBe("SKIP_PENDING"); + }); + + it("returns PENDING for missing (not-found) dependency tasks", async () => { + // Only 1 of the 2 requested tasks was found → mismatch → PENDING + mockFindMany.mockResolvedValueOnce([ + { + id: "dep-task-7", + status: "DONE", + chatMessages: [], + }, + ]); + + const result = await checkDependencies(["dep-task-7", "dep-task-missing"]); + expect(result).toBe("PENDING"); + expect(mapResultToAction(result)).toBe("SKIP_PENDING"); + }); +}); diff --git a/src/app/admin/task-coordinator/page.tsx b/src/app/admin/task-coordinator/page.tsx index 6a4b084683..36ac70c539 100644 --- a/src/app/admin/task-coordinator/page.tsx +++ b/src/app/admin/task-coordinator/page.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useState } from "react"; +import React, { useCallback, useEffect, useState } from "react"; import { Card, CardContent, @@ -9,311 +9,37 @@ import { CardTitle, } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; -import { Progress } from "@/components/ui/progress"; +import { Button } from "@/components/ui/button"; import { Separator } from "@/components/ui/separator"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { - Activity, AlertTriangle, Ban, - CheckCircle2, ChevronDown, ChevronRight, Clock, - Cpu, Eye, - GitBranch, - Hash, Info, Layers, Link2, - List, Loader2, + RefreshCw, Server, - Shield, Ticket, Timer, - TrendingUp, Users, Workflow, Zap, } from "lucide-react"; +import type { + CoordinatorSnapshot, + WorkspaceSnapshot, + TaskSnapshot, +} from "@/app/api/admin/task-coordinator/snapshot/route"; -// ─── Mock Data ──────────────────────────────────────────────────────────────── - -type DependencyResult = "SATISFIED" | "PENDING" | "PERMANENTLY_BLOCKED"; -type Priority = "CRITICAL" | "HIGH" | "MEDIUM" | "LOW"; -type WorkflowStatus = "PENDING" | "IN_PROGRESS" | "COMPLETED" | "ERROR" | "HALTED" | "FAILED"; - -interface MockTask { - id: string; - title: string; - priority: Priority; - status: "TODO" | "IN_PROGRESS" | "DONE" | "CANCELLED"; - workflowStatus: WorkflowStatus | null; - dependsOnTaskIds: string[]; - dependencyResult: DependencyResult; - featureTitle: string | null; - phase: string | null; - action: "DISPATCH" | "SKIP_PENDING" | "SKIP_BLOCKED" | "SKIP_CLAIMED"; - hasPod: boolean; - podId: string | null; -} - -interface MockWorkspace { - id: string; - slug: string; - name: string; - swarmEnabled: boolean; - ticketSweepEnabled: boolean; - recommendationSweepEnabled: boolean; - totalPods: number; - runningPods: number; - usedPods: number; - unusedPods: number; - failedPods: number; - pendingPods: number; - queuedCount: number; - slotsAvailable: number; - candidateTasks: MockTask[]; - pendingRecommendations: number; - staleTasks: number; - orphanedPodRefs: number; - processingNote: string | null; -} - -interface MockSnapshot { - timestamp: string; - totalWorkspacesWithSweep: number; - totalSlotsAvailable: number; - totalQueued: number; - totalStaleTasks: number; - totalOrphanedPods: number; - workspaces: MockWorkspace[]; -} - -const MOCK: MockSnapshot = { - timestamp: "2026-05-12T13:11:00.000Z", - totalWorkspacesWithSweep: 4, - totalSlotsAvailable: 8, - totalQueued: 14, - totalStaleTasks: 2, - totalOrphanedPods: 1, - workspaces: [ - { - id: "ws-1", - slug: "alpha-squad", - name: "Alpha Squad", - swarmEnabled: true, - ticketSweepEnabled: true, - recommendationSweepEnabled: true, - totalPods: 6, - runningPods: 5, - usedPods: 3, - unusedPods: 2, - failedPods: 1, - pendingPods: 0, - queuedCount: 5, - slotsAvailable: 1, - staleTasks: 1, - orphanedPodRefs: 0, - pendingRecommendations: 2, - processingNote: null, - candidateTasks: [ - { - id: "task-101", - title: "Refactor auth middleware to use token refresh strategy", - priority: "CRITICAL", - status: "TODO", - workflowStatus: "PENDING", - dependsOnTaskIds: [], - dependencyResult: "SATISFIED", - featureTitle: "Security Hardening", - phase: "Phase 1", - action: "DISPATCH", - hasPod: false, - podId: null, - }, - { - id: "task-102", - title: "Add rate limiting to public API endpoints", - priority: "HIGH", - status: "TODO", - workflowStatus: null, - dependsOnTaskIds: ["task-101"], - dependencyResult: "PENDING", - featureTitle: "Security Hardening", - phase: "Phase 1", - action: "SKIP_PENDING", - hasPod: false, - podId: null, - }, - { - id: "task-103", - title: "Audit logging for admin actions", - priority: "MEDIUM", - status: "TODO", - workflowStatus: null, - dependsOnTaskIds: ["task-999"], - dependencyResult: "PERMANENTLY_BLOCKED", - featureTitle: "Security Hardening", - phase: "Phase 2", - action: "SKIP_BLOCKED", - hasPod: false, - podId: null, - }, - ], - }, - { - id: "ws-2", - slug: "beta-platform", - name: "Beta Platform", - swarmEnabled: true, - ticketSweepEnabled: true, - recommendationSweepEnabled: false, - totalPods: 8, - runningPods: 7, - usedPods: 2, - unusedPods: 5, - failedPods: 0, - pendingPods: 1, - queuedCount: 6, - slotsAvailable: 4, - staleTasks: 0, - orphanedPodRefs: 1, - pendingRecommendations: 0, - processingNote: null, - candidateTasks: [ - { - id: "task-201", - title: "Implement websocket reconnection logic", - priority: "HIGH", - status: "TODO", - workflowStatus: "PENDING", - dependsOnTaskIds: [], - dependencyResult: "SATISFIED", - featureTitle: "Real-time Sync", - phase: "Sprint 3", - action: "DISPATCH", - hasPod: false, - podId: null, - }, - { - id: "task-202", - title: "Add Pusher channel authentication", - priority: "HIGH", - status: "TODO", - workflowStatus: "PENDING", - dependsOnTaskIds: [], - dependencyResult: "SATISFIED", - featureTitle: "Real-time Sync", - phase: "Sprint 3", - action: "DISPATCH", - hasPod: false, - podId: null, - }, - { - id: "task-203", - title: "Optimize payload size for broadcast events", - priority: "MEDIUM", - status: "TODO", - workflowStatus: "PENDING", - dependsOnTaskIds: ["task-201", "task-202"], - dependencyResult: "PENDING", - featureTitle: "Real-time Sync", - phase: "Sprint 3", - action: "SKIP_PENDING", - hasPod: false, - podId: null, - }, - { - id: "task-204", - title: "Write integration tests for sync edge cases", - priority: "LOW", - status: "TODO", - workflowStatus: "PENDING", - dependsOnTaskIds: ["task-201"], - dependencyResult: "PENDING", - featureTitle: "Real-time Sync", - phase: "Sprint 4", - action: "SKIP_PENDING", - hasPod: false, - podId: null, - }, - { - id: "task-205", - title: "Add dark mode to dashboard charts", - priority: "LOW", - status: "TODO", - workflowStatus: null, - dependsOnTaskIds: [], - dependencyResult: "SATISFIED", - featureTitle: null, - phase: null, - action: "DISPATCH", - hasPod: false, - podId: null, - }, - { - id: "task-206", - title: "Migrate legacy CSV export to streaming", - priority: "MEDIUM", - status: "TODO", - workflowStatus: "PENDING", - dependsOnTaskIds: [], - dependencyResult: "SATISFIED", - featureTitle: "Data Export", - phase: null, - action: "DISPATCH", - hasPod: false, - podId: null, - }, - ], - }, - { - id: "ws-3", - slug: "gamma-ops", - name: "Gamma Ops", - swarmEnabled: true, - ticketSweepEnabled: false, - recommendationSweepEnabled: true, - totalPods: 4, - runningPods: 4, - usedPods: 4, - unusedPods: 0, - failedPods: 0, - pendingPods: 0, - queuedCount: 2, - slotsAvailable: 0, - staleTasks: 1, - orphanedPodRefs: 0, - pendingRecommendations: 3, - processingNote: "Insufficient available pods (need 2+), skipping", - candidateTasks: [], - }, - { - id: "ws-4", - slug: "delta-infra", - name: "Delta Infra", - swarmEnabled: false, - ticketSweepEnabled: true, - recommendationSweepEnabled: false, - totalPods: 0, - runningPods: 0, - usedPods: 0, - unusedPods: 0, - failedPods: 0, - pendingPods: 0, - queuedCount: 1, - slotsAvailable: 0, - staleTasks: 0, - orphanedPodRefs: 0, - pendingRecommendations: 0, - processingNote: "No pool configured, skipping", - candidateTasks: [], - }, - ], -}; +// Re-export types for local use +type DependencyResult = TaskSnapshot["dependencyResult"]; +type Priority = TaskSnapshot["priority"]; +type TaskAction = TaskSnapshot["action"]; // ─── Helpers ────────────────────────────────────────────────────────────────── @@ -327,51 +53,116 @@ function priorityColor(p: Priority) { } function depBadge(d: DependencyResult) { - if (d === "SATISFIED") return Satisfied; - if (d === "PENDING") return Pending deps; - return Blocked forever; + if (d === "SATISFIED") + return ( + + Satisfied + + ); + if (d === "PENDING") + return ( + + Pending deps + + ); + return ( + + Blocked forever + + ); } -function actionBadge(a: MockTask["action"]) { - if (a === "DISPATCH") return Dispatch; - if (a === "SKIP_PENDING") return Skip (deps); - if (a === "SKIP_BLOCKED") return Unassign; - return Already claimed; +function actionBadge(a: TaskAction) { + if (a === "DISPATCH") + return ( + + + Dispatch + + ); + if (a === "SKIP_PENDING") + return ( + + + Skip (deps) + + ); + return ( + + + Unassign + + ); } -function PodBar({ ws }: { ws: MockWorkspace }) { +function PodBar({ ws }: { ws: WorkspaceSnapshot }) { const total = ws.totalPods || 1; return (
-
-
-
-
+
+
+
+
); } // ─── Variation A: Dashboard Cards ───────────────────────────────────────────── -function VariationA() { - const snap = MOCK; - const [expandedWs, setExpandedWs] = useState("ws-2"); +function VariationA({ snap }: { snap: CoordinatorSnapshot }) { + const [expandedWs, setExpandedWs] = useState(null); - const dispatchCount = snap.workspaces.reduce((n, ws) => - n + ws.candidateTasks.filter(t => t.action === "DISPATCH").length, 0); - const skipCount = snap.workspaces.reduce((n, ws) => - n + ws.candidateTasks.filter(t => t.action !== "DISPATCH").length, 0); + const dispatchCount = snap.workspaces.reduce( + (n, ws) => n + ws.candidateTasks.filter((t) => t.action === "DISPATCH").length, + 0 + ); return (
{/* Top summary */}
{[ - { label: "Workspaces Eligible", value: snap.totalWorkspacesWithSweep, icon: Users, color: "text-blue-500" }, - { label: "Slots Available Now", value: snap.totalSlotsAvailable, icon: Server, color: "text-emerald-500" }, - { label: "Tasks Queued", value: snap.totalQueued, icon: Ticket, color: "text-orange-500" }, - { label: "Would Dispatch", value: dispatchCount, icon: Zap, color: "text-purple-500" }, - ].map(s => ( + { + label: "Workspaces Eligible", + value: snap.totalWorkspacesWithSweep, + icon: Users, + color: "text-blue-500", + }, + { + label: "Slots Available Now", + value: snap.totalSlotsAvailable, + icon: Server, + color: "text-emerald-500", + }, + { + label: "Tasks Queued", + value: snap.totalQueued, + icon: Ticket, + color: "text-orange-500", + }, + { + label: "Would Dispatch", + value: dispatchCount, + icon: Zap, + color: "text-purple-500", + }, + ].map((s) => (
@@ -390,13 +181,19 @@ function VariationA() { {snap.totalStaleTasks > 0 && (
- {snap.totalStaleTasks} stale IN_PROGRESS task{snap.totalStaleTasks > 1 ? "s" : ""} would be halted + + {snap.totalStaleTasks} stale IN_PROGRESS task + {snap.totalStaleTasks > 1 ? "s" : ""} would be halted +
)} {snap.totalOrphanedPods > 0 && (
- {snap.totalOrphanedPods} orphaned pod ref{snap.totalOrphanedPods > 1 ? "s" : ""} would be cleared + + {snap.totalOrphanedPods} orphaned pod ref + {snap.totalOrphanedPods > 1 ? "s" : ""} would be cleared +
)}
@@ -404,10 +201,20 @@ function VariationA() { {/* Per-workspace cards */}
-

Per-Workspace Breakdown

- {snap.workspaces.map(ws => { - const toDispatch = ws.candidateTasks.filter(t => t.action === "DISPATCH"); - const toSkip = ws.candidateTasks.filter(t => t.action !== "DISPATCH"); +

+ Per-Workspace Breakdown +

+ + {snap.workspaces.length === 0 && ( + + + No workspaces with sweeps enabled. + + + )} + + {snap.workspaces.map((ws) => { + const toDispatch = ws.candidateTasks.filter((t) => t.action === "DISPATCH"); const isExpanded = expandedWs === ws.id; const canProcess = !ws.processingNote && ws.slotsAvailable > 0; @@ -419,42 +226,78 @@ function VariationA() { >
- {isExpanded ? : } + {isExpanded ? ( + + ) : ( + + )}
{ws.name} /w/{ws.slug}
- {ws.ticketSweepEnabled && Ticket sweep} - {ws.recommendationSweepEnabled && Rec sweep} - {ws.processingNote - ? Skipped - : toDispatch.length > 0 - ? {toDispatch.length} to dispatch - : No action - } + {ws.ticketSweepEnabled && ( + + Ticket sweep + + )} + {ws.recommendationSweepEnabled && ( + + Rec sweep + + )} + {ws.processingNote ? ( + + Skipped + + ) : toDispatch.length > 0 ? ( + + + {toDispatch.length} to dispatch + + ) : ( + + No action + + )}
{/* Pod bar */}
- Pods: {ws.runningPods}/{ws.totalPods} running · {ws.unusedPods} available · {ws.slotsAvailable} slots + + Pods: {ws.runningPods}/{ws.totalPods} running · {ws.unusedPods} available + · {ws.slotsAvailable} slots + {ws.queuedCount} queued
- Used - Free - Pending - Failed + + + Used + + + + Free + + + + Pending + + + + Failed +
{ws.processingNote && (
- {ws.processingNote} + + {ws.processingNote}
)} @@ -463,18 +306,38 @@ function VariationA() {
-

Candidate Tasks ({ws.candidateTasks.length})

- {ws.candidateTasks.map(task => ( -
+

+ Candidate Tasks ({ws.candidateTasks.length}) +

+ {ws.candidateTasks.map((task) => ( +
- {task.priority} + + {task.priority} + {depBadge(task.dependencyResult)} {task.dependsOnTaskIds.length > 0 && ( - {task.dependsOnTaskIds.length} dep{task.dependsOnTaskIds.length > 1 ? "s" : ""} + + + {task.dependsOnTaskIds.length} dep + {task.dependsOnTaskIds.length > 1 ? "s" : ""} + )}
-

{task.title}

+

+ {task.title} +

{(task.featureTitle || task.phase) && (

{task.featureTitle && {task.featureTitle}} @@ -487,10 +350,30 @@ function VariationA() {

))}
- {ws.pendingRecommendations > 0 && ws.candidateTasks.filter(t => t.action === "DISPATCH").length === 0 && ( + {ws.pendingRecommendations > 0 && + ws.candidateTasks.filter((t) => t.action === "DISPATCH").length === 0 && ( +
+ + + No tickets dispatched — would fall back to {ws.pendingRecommendations}{" "} + pending recommendations + +
+ )} + + )} + + {isExpanded && ws.candidateTasks.length === 0 && !ws.processingNote && ( + + +

No candidate tasks in queue.

+ {ws.pendingRecommendations > 0 && (
- No tickets dispatched — would fall back to {ws.pendingRecommendations} pending recommendations + + {ws.pendingRecommendations} pending recommendation + {ws.pendingRecommendations > 1 ? "s" : ""} available for fallback +
)}
@@ -503,358 +386,98 @@ function VariationA() { ); } -// ─── Variation B: Compact Table View ────────────────────────────────────────── - -function VariationB() { - const snap = MOCK; - const [selected, setSelected] = useState("ws-2"); - const selectedWs = snap.workspaces.find(w => w.id === selected) ?? null; - - return ( -
- {/* Summary row */} -
- {[ - { icon: Users, label: `${snap.totalWorkspacesWithSweep} eligible workspaces` }, - { icon: Server, label: `${snap.totalSlotsAvailable} open slots` }, - { icon: Ticket, label: `${snap.totalQueued} tasks queued` }, - { icon: Timer, label: `${snap.totalStaleTasks} stale tasks` }, - { icon: AlertTriangle, label: `${snap.totalOrphanedPods} orphaned pods` }, - ].map(s => ( - - {s.label} - - ))} - {new Date(snap.timestamp).toLocaleTimeString()} -
- -
- {/* Workspace list */} -
-
-

Workspaces

-
-
- {snap.workspaces.map(ws => { - const toDispatch = ws.candidateTasks.filter(t => t.action === "DISPATCH").length; - return ( - - ); - })} -
-
- - {/* Detail panel */} -
- {selectedWs ? ( - <> -
-
-

{selectedWs.name}

-

/w/{selectedWs.slug}

-
-
- {selectedWs.ticketSweepEnabled && Ticket} - {selectedWs.recommendationSweepEnabled && Recs} -
-
- -
- {/* Pod status */} -
-

Pod Status

-
- {[ - { label: "Running", v: selectedWs.runningPods, cls: "text-foreground" }, - { label: "Used", v: selectedWs.usedPods, cls: "text-orange-500" }, - { label: "Free", v: selectedWs.unusedPods, cls: "text-emerald-500" }, - { label: "Failed", v: selectedWs.failedPods, cls: "text-red-500" }, - ].map(s => ( -
-

{s.v}

-

{s.label}

-
- ))} -
-
-
- {selectedWs.slotsAvailable} slots available (unusedPods − 1) - {selectedWs.queuedCount} tasks queued -
- -
-
- - {selectedWs.processingNote ? ( -
- -

{selectedWs.processingNote}

-
- ) : selectedWs.candidateTasks.length > 0 ? ( -
-

Candidate Tasks

-
- - - - - - - - - - - {selectedWs.candidateTasks.map(task => ( - - - - - - - ))} - -
TaskPriorityDepsAction
-

{task.title}

- {task.featureTitle &&

{task.featureTitle}

} -
- {task.priority} - {depBadge(task.dependencyResult)}{actionBadge(task.action)}
-
-
- ) : ( -

No candidate tasks in queue.

- )} - - {selectedWs.staleTasks > 0 && ( -
- {selectedWs.staleTasks} stale task{selectedWs.staleTasks > 1 ? "s" : ""} would be halted before sweep -
- )} - {selectedWs.orphanedPodRefs > 0 && ( -
- {selectedWs.orphanedPodRefs} orphaned pod ref{selectedWs.orphanedPodRefs > 1 ? "s" : ""} would be cleared -
- )} -
- - ) : ( -
Select a workspace
- )} -
-
-
- ); -} - -// ─── Variation C: Pipeline / Process-Flow View ──────────────────────────────── - -function PipelineStep({ step, active, last }: { step: number; active: boolean; last: boolean }) { - return ( -
-
-
- {step} -
- {!last &&
} -
-
- ); -} - -function VariationC() { - const snap = MOCK; - - const allToDispatch = snap.workspaces.flatMap(ws => - ws.candidateTasks.filter(t => t.action === "DISPATCH").map(t => ({ ...t, workspace: ws.name })) - ); - const allPending = snap.workspaces.flatMap(ws => - ws.candidateTasks.filter(t => t.action === "SKIP_PENDING").map(t => ({ ...t, workspace: ws.name })) - ); - const allBlocked = snap.workspaces.flatMap(ws => - ws.candidateTasks.filter(t => t.action === "SKIP_BLOCKED").map(t => ({ ...t, workspace: ws.name })) - ); - const skippedWs = snap.workspaces.filter(ws => !!ws.processingNote); - - const steps = [ - { - label: "Phase 1 — Stale Pod Cleanup", - icon: Timer, - color: "text-yellow-500", - summary: `${snap.totalStaleTasks} stale IN_PROGRESS task${snap.totalStaleTasks !== 1 ? "s" : ""} halted · ${snap.totalOrphanedPods} orphaned pod ref${snap.totalOrphanedPods !== 1 ? "s" : ""} cleared`, - details: snap.totalStaleTasks === 0 && snap.totalOrphanedPods === 0 - ? [{ id: "none", label: "Nothing to clean up", sub: "", cls: "" }] - : [ - snap.totalStaleTasks > 0 - ? { id: "stale", label: `${snap.totalStaleTasks} stale task${snap.totalStaleTasks > 1 ? "s" : ""} → HALTED`, sub: "workflowStatus set to HALTED, pod released", cls: "text-yellow-500" } - : null, - snap.totalOrphanedPods > 0 - ? { id: "orphan", label: `${snap.totalOrphanedPods} orphaned pod ref${snap.totalOrphanedPods > 1 ? "s" : ""} → cleared`, sub: "podId, agentUrl, agentPassword nulled", cls: "text-orange-500" } - : null, - ].filter(Boolean) as { id: string; label: string; sub: string; cls: string }[], - }, - { - label: "Phase 2 — Workspace Discovery", - icon: Users, - color: "text-blue-500", - summary: `${snap.totalWorkspacesWithSweep} workspaces with sweeps enabled found · ${skippedWs.length} skipped (no pool / insufficient pods)`, - details: snap.workspaces.map(ws => ({ - id: ws.id, - label: ws.name, - sub: ws.processingNote ?? `${ws.slotsAvailable} slot${ws.slotsAvailable !== 1 ? "s" : ""} available · ${ws.queuedCount} queued`, - cls: ws.processingNote ? "text-muted-foreground line-through" : "", - })), - }, - { - label: "Phase 3 — Ticket Sweep (per workspace)", - icon: Ticket, - color: "text-purple-500", - summary: `${allToDispatch.length} tasks would be dispatched · ${allPending.length} waiting on deps · ${allBlocked.length} unassigned (permanently blocked)`, - details: [ - ...allToDispatch.map(t => ({ id: t.id, label: t.title, sub: `${t.workspace} · ${t.priority} · → DISPATCH via Stakwork`, cls: "text-blue-500" })), - ...allPending.map(t => ({ id: t.id, label: t.title, sub: `${t.workspace} · deps not satisfied → skip this run`, cls: "text-yellow-500" })), - ...allBlocked.map(t => ({ id: t.id, label: t.title, sub: `${t.workspace} · dep permanently cancelled → systemAssigneeType nulled`, cls: "text-red-500" })), - ], - }, - { - label: "Phase 4 — Recommendation Sweep (fallback)", - icon: Layers, - color: "text-emerald-500", - summary: `Runs only when 0 tickets were dispatched in a workspace. ${snap.workspaces.filter(ws => ws.recommendationSweepEnabled && ws.candidateTasks.filter(t => t.action === "DISPATCH").length === 0 && ws.pendingRecommendations > 0).length} workspace(s) eligible`, - details: snap.workspaces - .filter(ws => ws.recommendationSweepEnabled && ws.pendingRecommendations > 0) - .map(ws => { - const dispatched = ws.candidateTasks.filter(t => t.action === "DISPATCH").length; - return { - id: ws.id, - label: ws.name, - sub: dispatched > 0 - ? `Skipped — ${dispatched} ticket${dispatched > 1 ? "s" : ""} dispatched` - : `${ws.pendingRecommendations} pending rec${ws.pendingRecommendations > 1 ? "s" : ""} → would auto-accept top recommendation`, - cls: dispatched > 0 ? "text-muted-foreground" : "text-emerald-500", - }; - }), - }, - ]; - - return ( -
- {/* Header stats */} -
- Snapshot at {new Date(snap.timestamp).toLocaleTimeString()} - Read-only — no changes made - {allToDispatch.length} tasks would be dispatched -
- - {/* Pipeline steps */} -
- {steps.map((step, idx) => ( -
-
-
- -
- {idx < steps.length - 1 &&
} -
-
-

{step.label}

-

{step.summary}

- {step.details.length > 0 && ( -
- {step.details.map((d, di) => ( -
- {d.label} - {d.sub && {d.sub}} -
- ))} -
- )} -
-
- ))} -
-
- ); -} - // ─── Page ───────────────────────────────────────────────────────────────────── export default function TaskCoordinatorPage() { + const [snapshot, setSnapshot] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [isRefreshing, setIsRefreshing] = useState(false); + const [error, setError] = useState(null); + + const fetchSnapshot = useCallback(async (isManualRefresh = false) => { + if (isManualRefresh) { + setIsRefreshing(true); + } + setError(null); + try { + const res = await fetch("/api/admin/task-coordinator/snapshot"); + if (!res.ok) { + throw new Error(`Request failed: ${res.status}`); + } + const data: CoordinatorSnapshot = await res.json(); + setSnapshot(data); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to load snapshot"); + } finally { + setIsLoading(false); + setIsRefreshing(false); + } + }, []); + + useEffect(() => { + fetchSnapshot(false); + }, [fetchSnapshot]); + return (
{/* Page header */} -
+
-

Task Coordinator — Read-only Preview

+

Task Coordinator — Live Snapshot

- Read-only + + Read-only

- Snapshot of what the task coordinator would see and do right now — no changes are made. - Refreshing this page re-reads current DB state. + Snapshot of what the task coordinator would see and do{" "} + right now — no changes are made.

-
- - {new Date(MOCK.timestamp).toLocaleString()} -
-
- {/* Prototype label */} -
- 🧪 Prototype — using mock data. Choose a variation below, then we'll wire it to real DB reads. +
+ {snapshot && ( + + + {new Date(snapshot.timestamp).toLocaleString()} + + )} + +
- - - A — Dashboard Cards - B — Table + Detail - C — Pipeline Flow - - - -
- Variation A — Dashboard Cards: Top-level metrics with expandable per-workspace cards showing pod health bars and task-level decisions. -
- -
+ {/* Loading state */} + {isLoading && ( +
+ +
+ )} - -
- Variation B — Table + Detail: Workspace sidebar list with a detail panel showing pod status grid and a compact task table with action columns. -
- -
+ {/* Error state */} + {!isLoading && error && ( +
+ + {error} +
+ )} - -
- Variation C — Pipeline Flow: Step-by-step view mirroring the coordinator's 4-phase execution: cleanup → discovery → ticket sweep → recommendation fallback. -
- -
-
+ {/* Live data */} + {!isLoading && snapshot && }
); } diff --git a/src/app/api/admin/task-coordinator/snapshot/route.ts b/src/app/api/admin/task-coordinator/snapshot/route.ts new file mode 100644 index 0000000000..88c99f6c15 --- /dev/null +++ b/src/app/api/admin/task-coordinator/snapshot/route.ts @@ -0,0 +1,290 @@ +import { NextRequest, NextResponse } from "next/server"; +import { requireSuperAdmin } from "@/lib/auth/require-superadmin"; +import { db } from "@/lib/db"; +import { WorkflowStatus } from "@prisma/client"; +import { getPoolStatusFromPods } from "@/lib/pods/status-queries"; +import { checkDependencies } from "@/services/task-coordinator-cron"; + +export type TaskAction = "DISPATCH" | "SKIP_PENDING" | "SKIP_BLOCKED"; +export type DependencyResult = "SATISFIED" | "PENDING" | "PERMANENTLY_BLOCKED"; +export type Priority = "CRITICAL" | "HIGH" | "MEDIUM" | "LOW"; + +export interface TaskSnapshot { + id: string; + title: string; + priority: Priority; + dependsOnTaskIds: string[]; + dependencyResult: DependencyResult; + featureTitle: string | null; + phase: string | null; + action: TaskAction; +} + +export interface WorkspaceSnapshot { + id: string; + slug: string; + name: string; + swarmEnabled: boolean; + ticketSweepEnabled: boolean; + recommendationSweepEnabled: boolean; + totalPods: number; + runningPods: number; + usedPods: number; + unusedPods: number; + failedPods: number; + pendingPods: number; + queuedCount: number; + slotsAvailable: number; + candidateTasks: TaskSnapshot[]; + pendingRecommendations: number; + processingNote: string | null; +} + +export interface CoordinatorSnapshot { + timestamp: string; + totalWorkspacesWithSweep: number; + totalSlotsAvailable: number; + totalQueued: number; + totalStaleTasks: number; + totalOrphanedPods: number; + workspaces: WorkspaceSnapshot[]; +} + +export async function GET(request: NextRequest) { + const authResult = await requireSuperAdmin(request); + if (authResult instanceof NextResponse) { + return authResult; + } + + try { + // Configurable stale task threshold (default: 24 hours) + const staleHours = parseInt(process.env.STALE_TASK_HOURS || "24", 10); + const staleThreshold = new Date(Date.now() - staleHours * 60 * 60 * 1000); + + // Fetch enabled workspaces + stale task count in parallel + // Orphaned pod refs require a two-step query (no Prisma relation from Task → Pod) + const [enabledWorkspaces, staleTasks, softDeletedPods] = await Promise.all([ + db.workspace.findMany({ + where: { + deleted: false, + janitorConfig: { + OR: [{ recommendationSweepEnabled: true }, { ticketSweepEnabled: true }], + }, + }, + include: { + janitorConfig: true, + swarm: true, + }, + }), + // Stale tasks: tasks with a pod or IN_PROGRESS (not halted) older than threshold + db.task.count({ + where: { + deleted: false, + updatedAt: { lt: staleThreshold }, + OR: [ + { podId: { not: null } }, + { + status: "IN_PROGRESS", + workflowStatus: { not: WorkflowStatus.HALTED }, + }, + ], + }, + }), + // Get soft-deleted pod IDs so we can count tasks referencing them + // Task.podId stores the Pod.podId string (no Prisma relation exists) + db.pod.findMany({ + where: { deletedAt: { not: null } }, + select: { podId: true }, + }), + ]); + + // Count orphaned pod refs: tasks whose podId points at a soft-deleted pod + const softDeletedPodIds = softDeletedPods.map((p) => p.podId); + const orphanedPodRefs = + softDeletedPodIds.length > 0 + ? await db.task.count({ + where: { + podId: { in: softDeletedPodIds }, + deleted: false, + }, + }) + : 0; + + // Process each workspace in parallel + const workspaceSnapshots = await Promise.all( + enabledWorkspaces.map(async (ws): Promise => { + const ticketSweepEnabled = ws.janitorConfig?.ticketSweepEnabled ?? false; + const recommendationSweepEnabled = + ws.janitorConfig?.recommendationSweepEnabled ?? false; + + // No swarm configured — skip pool/task queries + if (!ws.swarm?.id) { + return { + id: ws.id, + slug: ws.slug, + name: ws.name, + swarmEnabled: false, + ticketSweepEnabled, + recommendationSweepEnabled, + totalPods: 0, + runningPods: 0, + usedPods: 0, + unusedPods: 0, + failedPods: 0, + pendingPods: 0, + queuedCount: 0, + slotsAvailable: 0, + candidateTasks: [], + pendingRecommendations: 0, + processingNote: "No pool configured, skipping", + }; + } + + const poolStatus = await getPoolStatusFromPods(ws.swarm.id, ws.id); + const totalPods = + poolStatus.runningVms + poolStatus.pendingVms + poolStatus.failedVms; + const slotsAvailable = + poolStatus.unusedVms <= 1 ? 0 : poolStatus.unusedVms - 1; + + if (slotsAvailable === 0) { + const pendingRecommendations = await db.janitorRecommendation.count({ + where: { + status: "PENDING", + janitorRun: { janitorConfig: { workspaceId: ws.id } }, + }, + }); + + return { + id: ws.id, + slug: ws.slug, + name: ws.name, + swarmEnabled: true, + ticketSweepEnabled, + recommendationSweepEnabled, + totalPods, + runningPods: poolStatus.runningVms, + usedPods: poolStatus.usedVms, + unusedPods: poolStatus.unusedVms, + failedPods: poolStatus.failedVms, + pendingPods: poolStatus.pendingVms, + queuedCount: poolStatus.queuedCount, + slotsAvailable: 0, + candidateTasks: [], + pendingRecommendations, + processingNote: "Insufficient available pods (need 2+), skipping", + }; + } + + // Fetch candidates + pending recommendations in parallel + const candidateLimit = Math.max(slotsAvailable * 3, 20); + const [candidateTasks, pendingRecommendations] = await Promise.all([ + db.task.findMany({ + where: { + AND: [ + { workspaceId: ws.id }, + { status: "TODO" }, + { systemAssigneeType: "TASK_COORDINATOR" }, + { deleted: false }, + { + OR: [ + { workflowStatus: WorkflowStatus.PENDING }, + { workflowStatus: null }, + ], + }, + { stakworkProjectId: null }, + { + OR: [ + { featureId: null }, + { feature: { status: { not: "CANCELLED" } } }, + ], + }, + ], + }, + select: { + id: true, + title: true, + priority: true, + dependsOnTaskIds: true, + feature: { select: { title: true } }, + phase: { select: { name: true } }, + }, + orderBy: [{ priority: "desc" }, { createdAt: "asc" }], + take: candidateLimit, + }), + db.janitorRecommendation.count({ + where: { + status: "PENDING", + janitorRun: { janitorConfig: { workspaceId: ws.id } }, + }, + }), + ]); + + // Evaluate dependencies for each candidate (read-only) + const taskSnapshots: TaskSnapshot[] = await Promise.all( + candidateTasks.map(async (task) => { + const depResult = await checkDependencies(task.dependsOnTaskIds); + const action: TaskAction = + depResult === "SATISFIED" + ? "DISPATCH" + : depResult === "PENDING" + ? "SKIP_PENDING" + : "SKIP_BLOCKED"; + + return { + id: task.id, + title: task.title, + priority: (task.priority ?? "MEDIUM") as Priority, + dependsOnTaskIds: task.dependsOnTaskIds, + dependencyResult: depResult, + featureTitle: task.feature?.title ?? null, + phase: task.phase?.name ?? null, + action, + }; + }) + ); + + return { + id: ws.id, + slug: ws.slug, + name: ws.name, + swarmEnabled: true, + ticketSweepEnabled, + recommendationSweepEnabled, + totalPods, + runningPods: poolStatus.runningVms, + usedPods: poolStatus.usedVms, + unusedPods: poolStatus.unusedVms, + failedPods: poolStatus.failedVms, + pendingPods: poolStatus.pendingVms, + queuedCount: poolStatus.queuedCount, + slotsAvailable, + candidateTasks: taskSnapshots, + pendingRecommendations, + processingNote: null, + }; + }) + ); + + const snapshot: CoordinatorSnapshot = { + timestamp: new Date().toISOString(), + totalWorkspacesWithSweep: enabledWorkspaces.length, + totalSlotsAvailable: workspaceSnapshots.reduce( + (sum, ws) => sum + ws.slotsAvailable, + 0 + ), + totalQueued: workspaceSnapshots.reduce( + (sum, ws) => sum + ws.queuedCount, + 0 + ), + totalStaleTasks: staleTasks, + totalOrphanedPods: orphanedPodRefs, + workspaces: workspaceSnapshots, + }; + + return NextResponse.json(snapshot); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error("[TaskCoordinatorSnapshot] Error:", message); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} From 4589b5b16e4513bdb18a96f181ff26f1762a1ed5 Mon Sep 17 00:00:00 2001 From: gonzaloaune Date: Tue, 12 May 2026 15:11:56 +0000 Subject: [PATCH 03/10] Generated with Hive: Fix super admin mock signIn tests by adding role --- src/__tests__/unit/lib/auth/nextauth.test.ts | 4 +++- src/__tests__/unit/lib/auth/signIn.test.ts | 2 ++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/__tests__/unit/lib/auth/nextauth.test.ts b/src/__tests__/unit/lib/auth/nextauth.test.ts index 92d3bf2757..ec4374ec7b 100644 --- a/src/__tests__/unit/lib/auth/nextauth.test.ts +++ b/src/__tests__/unit/lib/auth/nextauth.test.ts @@ -155,6 +155,7 @@ describe("nextauth.ts - signIn callback", () => { email: mockUser.email, image: mockUser.image, emailVerified: expect.any(Date), + role: "SUPER_ADMIN", }, }); expect(ensureMockWorkspaceForUser).toHaveBeenCalledWith("new-user-id"); @@ -193,11 +194,12 @@ describe("nextauth.ts - signIn callback", () => { session_state: null, }; - // Mock existing user + // Mock existing user (already SUPER_ADMIN — no update needed) (db.user.findUnique as any).mockResolvedValue({ id: "existing-user-id", email: mockUser.email, name: mockUser.name, + role: "SUPER_ADMIN", }); // Mock workspace creation diff --git a/src/__tests__/unit/lib/auth/signIn.test.ts b/src/__tests__/unit/lib/auth/signIn.test.ts index c4adaa1af5..0015084f46 100644 --- a/src/__tests__/unit/lib/auth/signIn.test.ts +++ b/src/__tests__/unit/lib/auth/signIn.test.ts @@ -110,6 +110,7 @@ describe('signIn callback', () => { email: 'mockuser@mock.dev', image: 'https://avatars.githubusercontent.com/u/1?v=4', emailVerified: expect.any(Date), + role: 'SUPER_ADMIN', }, }); // The user ID is mutated in the callback, so workspace operations use the new ID @@ -144,6 +145,7 @@ describe('signIn callback', () => { id: 'existing-user-456', email: 'existinguser@mock.dev', name: 'Existing User', + role: 'SUPER_ADMIN', }; (db.user.findUnique as any).mockResolvedValue(existingUser); From dffd4c682c3bb7ec4e113c98bbca709d728f1c02 Mon Sep 17 00:00:00 2001 From: gonzaloaune Date: Tue, 12 May 2026 16:18:39 +0000 Subject: [PATCH 04/10] Generated with Hive: Extract shared coordinator DB queries to ensure admin and task coordinator consistency --- .../admin/task-coordinator/snapshot/route.ts | 48 ++++---------- .../_components/CreateServiceCanvasDialog.tsx | 4 +- .../connections/OrgCanvasBackground.tsx | 15 ++++- .../[githubLogin]/connections/canvas-theme.ts | 2 +- src/services/task-coordinator-cron.ts | 64 +++++++++++++------ 5 files changed, 69 insertions(+), 64 deletions(-) diff --git a/src/app/api/admin/task-coordinator/snapshot/route.ts b/src/app/api/admin/task-coordinator/snapshot/route.ts index 88c99f6c15..68501815fe 100644 --- a/src/app/api/admin/task-coordinator/snapshot/route.ts +++ b/src/app/api/admin/task-coordinator/snapshot/route.ts @@ -1,9 +1,14 @@ import { NextRequest, NextResponse } from "next/server"; import { requireSuperAdmin } from "@/lib/auth/require-superadmin"; import { db } from "@/lib/db"; -import { WorkflowStatus } from "@prisma/client"; import { getPoolStatusFromPods } from "@/lib/pods/status-queries"; -import { checkDependencies } from "@/services/task-coordinator-cron"; +import { + checkDependencies, + candidateTasksWhere, + pendingRecommendationsWhere, + ENABLED_WORKSPACE_WHERE, +} from "@/services/task-coordinator-cron"; +import { WorkflowStatus } from "@prisma/client"; export type TaskAction = "DISPATCH" | "SKIP_PENDING" | "SKIP_BLOCKED"; export type DependencyResult = "SATISFIED" | "PENDING" | "PERMANENTLY_BLOCKED"; @@ -65,12 +70,7 @@ export async function GET(request: NextRequest) { // Orphaned pod refs require a two-step query (no Prisma relation from Task → Pod) const [enabledWorkspaces, staleTasks, softDeletedPods] = await Promise.all([ db.workspace.findMany({ - where: { - deleted: false, - janitorConfig: { - OR: [{ recommendationSweepEnabled: true }, { ticketSweepEnabled: true }], - }, - }, + where: ENABLED_WORKSPACE_WHERE, include: { janitorConfig: true, swarm: true, @@ -148,10 +148,7 @@ export async function GET(request: NextRequest) { if (slotsAvailable === 0) { const pendingRecommendations = await db.janitorRecommendation.count({ - where: { - status: "PENDING", - janitorRun: { janitorConfig: { workspaceId: ws.id } }, - }, + where: pendingRecommendationsWhere(ws.id), }); return { @@ -179,27 +176,7 @@ export async function GET(request: NextRequest) { const candidateLimit = Math.max(slotsAvailable * 3, 20); const [candidateTasks, pendingRecommendations] = await Promise.all([ db.task.findMany({ - where: { - AND: [ - { workspaceId: ws.id }, - { status: "TODO" }, - { systemAssigneeType: "TASK_COORDINATOR" }, - { deleted: false }, - { - OR: [ - { workflowStatus: WorkflowStatus.PENDING }, - { workflowStatus: null }, - ], - }, - { stakworkProjectId: null }, - { - OR: [ - { featureId: null }, - { feature: { status: { not: "CANCELLED" } } }, - ], - }, - ], - }, + where: candidateTasksWhere(ws.id), select: { id: true, title: true, @@ -212,10 +189,7 @@ export async function GET(request: NextRequest) { take: candidateLimit, }), db.janitorRecommendation.count({ - where: { - status: "PENDING", - janitorRun: { janitorConfig: { workspaceId: ws.id } }, - }, + where: pendingRecommendationsWhere(ws.id), }), ]); diff --git a/src/app/org/[githubLogin]/_components/CreateServiceCanvasDialog.tsx b/src/app/org/[githubLogin]/_components/CreateServiceCanvasDialog.tsx index 21207af194..911d3d15f6 100644 --- a/src/app/org/[githubLogin]/_components/CreateServiceCanvasDialog.tsx +++ b/src/app/org/[githubLogin]/_components/CreateServiceCanvasDialog.tsx @@ -28,7 +28,7 @@ import React, { useEffect, useMemo, useRef, useState } from "react"; import { Search } from "lucide-react"; -import { NodeIcon } from "system-canvas-react/primitives"; +import { NodeIcon } from "system-canvas-react"; import { Dialog, DialogContent, @@ -293,8 +293,6 @@ function PlatformGlyph({ platform }: { platform: Platform }) { size={size} color="currentColor" opacity={1} - mode="stroke" - viewBox={platform.viewBox ?? 16} // Pass the consumer-side icon map too so the same lookup // logic the canvas uses applies here — keeps the tile and // the on-canvas render in lockstep. diff --git a/src/app/org/[githubLogin]/connections/OrgCanvasBackground.tsx b/src/app/org/[githubLogin]/connections/OrgCanvasBackground.tsx index 839b02e3fd..7f6a62dd97 100644 --- a/src/app/org/[githubLogin]/connections/OrgCanvasBackground.tsx +++ b/src/app/org/[githubLogin]/connections/OrgCanvasBackground.tsx @@ -16,12 +16,12 @@ import { type CanvasData, type CanvasEdge, type CanvasNode, - type CanvasSelection, type EdgeUpdate, type NodeContextMenuConfig, type NodeUpdate, type SystemCanvasHandle, } from "system-canvas-react"; + // `getNodeLabel` isn't re-exported from `system-canvas-react`; pull // it from the core package directly. Used to resolve human-readable // labels for edge endpoints at click-time. @@ -130,6 +130,12 @@ const LINKED_EDGE_COLOR = "#a4b3cc"; type DirtyMap = Map; +// `CanvasSelection` was removed from the lib; define it locally. +type CanvasSelection = + | { kind: "node"; node: CanvasNode; canvasRef: string | undefined } + | { kind: "edge"; edge: CanvasEdge; canvasRef: string | undefined } + | null; + type LastAction = | { kind: "blob"; @@ -2804,6 +2810,7 @@ export function OrgCanvasBackground({ <>
+ {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */} )} onNodeAdd={handleNodeAdd} onNodeUpdate={handleNodeUpdate} - onNodesUpdate={handleNodesUpdate} onNodeDelete={handleNodeDelete} onEdgeAdd={handleEdgeAdd} onEdgeUpdate={handleEdgeUpdate} diff --git a/src/app/org/[githubLogin]/connections/canvas-theme.ts b/src/app/org/[githubLogin]/connections/canvas-theme.ts index ffaf63cec6..eaea7759c2 100644 --- a/src/app/org/[githubLogin]/connections/canvas-theme.ts +++ b/src/app/org/[githubLogin]/connections/canvas-theme.ts @@ -1051,7 +1051,7 @@ const serviceCategory: CategoryDefinition = { size: 18, }, }, -} as CategoryDefinition; +} as unknown as CategoryDefinition; // --------------------------------------------------------------------------- // Research card — DB-projected on root or initiative canvases. diff --git a/src/services/task-coordinator-cron.ts b/src/services/task-coordinator-cron.ts index 26391eebe4..87fe09b619 100644 --- a/src/services/task-coordinator-cron.ts +++ b/src/services/task-coordinator-cron.ts @@ -9,6 +9,48 @@ import { acceptJanitorRecommendation } from "@/services/janitor"; export type DependencyCheckResult = "SATISFIED" | "PENDING" | "PERMANENTLY_BLOCKED"; +// ─── Shared query helpers (used by both the cron and the admin snapshot) ────── + +/** + * The canonical where-clause for workspaces that have at least one coordinator + * sweep enabled. Used by both the cron runner and the admin snapshot endpoint. + */ +export const ENABLED_WORKSPACE_WHERE: Prisma.WorkspaceWhereInput = { + deleted: false, + janitorConfig: { + OR: [{ recommendationSweepEnabled: true }, { ticketSweepEnabled: true }], + }, +}; + +/** + * Build the canonical Prisma where-clause for coordinator candidate tasks + * (TODO, assigned to TASK_COORDINATOR, not yet dispatched, feature not CANCELLED). + */ +export function candidateTasksWhere(workspaceId: string) { + return { + AND: [ + { workspaceId }, + { status: "TODO" as const }, + { systemAssigneeType: "TASK_COORDINATOR" as const }, + { deleted: false }, + { OR: [{ workflowStatus: WorkflowStatus.PENDING }, { workflowStatus: null }] }, + { stakworkProjectId: null }, + { OR: [{ featureId: null }, { feature: { status: { not: "CANCELLED" as const } } }] }, + ], + }; +} + +/** + * The canonical Prisma where-clause for pending janitor recommendations in a + * workspace. Used by both the cron runner and the admin snapshot endpoint. + */ +export function pendingRecommendationsWhere(workspaceId: string) { + return { + status: "PENDING" as const, + janitorRun: { janitorConfig: { workspaceId } }, + }; +} + export interface TaskCoordinatorExecutionResult { success: boolean; workspacesProcessed: number; @@ -136,17 +178,7 @@ export async function processTicketSweep( // Fetch enough candidates to survive dependency filtering const candidateTasks = await db.task.findMany({ - where: { - AND: [ - { workspaceId }, - { status: "TODO" }, - { systemAssigneeType: "TASK_COORDINATOR" }, - { deleted: false }, - { OR: [{ workflowStatus: WorkflowStatus.PENDING }, { workflowStatus: null }] }, - { stakworkProjectId: null }, - { OR: [{ featureId: null }, { feature: { status: { not: "CANCELLED" } } }] }, - ], - }, + where: candidateTasksWhere(workspaceId), select: { id: true, title: true, @@ -640,15 +672,7 @@ export async function executeTaskCoordinatorRuns(): Promise Date: Tue, 12 May 2026 10:53:10 -0600 Subject: [PATCH 05/10] Generated with Hive: Add copy cut paste support for authored canvas elements via useCanvasClipboard hook (#4043) Co-authored-by: pitoi --- .../unit/canvas/useCanvasClipboard.test.ts | 341 ++++++++++++++++++ .../connections/OrgCanvasBackground.tsx | 32 +- .../connections/useCanvasClipboard.ts | 186 ++++++++++ 3 files changed, 557 insertions(+), 2 deletions(-) create mode 100644 src/__tests__/unit/canvas/useCanvasClipboard.test.ts create mode 100644 src/app/org/[githubLogin]/connections/useCanvasClipboard.ts diff --git a/src/__tests__/unit/canvas/useCanvasClipboard.test.ts b/src/__tests__/unit/canvas/useCanvasClipboard.test.ts new file mode 100644 index 0000000000..865813d95c --- /dev/null +++ b/src/__tests__/unit/canvas/useCanvasClipboard.test.ts @@ -0,0 +1,341 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { renderHook, act } from "@testing-library/react"; +import { + isCopyableNode, + computePastePosition, +} from "@/app/org/[githubLogin]/connections/useCanvasClipboard"; +import useCanvasClipboard from "@/app/org/[githubLogin]/connections/useCanvasClipboard"; +import { addNode, removeNode } from "system-canvas-react"; +import type { CanvasNode, CanvasData } from "system-canvas-react"; +import { useRef } from "react"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeNode(overrides: Partial = {}): CanvasNode { + return { + id: "abc123", + type: "text", + x: 100, + y: 200, + text: "Hello", + width: 220, + height: 80, + ...overrides, + } as CanvasNode; +} + +function fireKeydown( + key: string, + modifiers: Partial = {}, + target?: EventTarget, +) { + const event = new KeyboardEvent("keydown", { + key, + bubbles: true, + ctrlKey: true, + ...modifiers, + }); + if (target) { + Object.defineProperty(event, "target", { value: target }); + } + document.dispatchEvent(event); +} + +// --------------------------------------------------------------------------- +// isCopyableNode +// --------------------------------------------------------------------------- + +describe("isCopyableNode", () => { + it("returns false for ws: live-id nodes", () => { + expect(isCopyableNode(makeNode({ id: "ws:abc", type: "text" }))).toBe(false); + }); + + it("returns false for feature: live-id nodes", () => { + expect(isCopyableNode(makeNode({ id: "feature:xyz", type: "text" }))).toBe(false); + }); + + it("returns false for initiative: live-id nodes", () => { + expect(isCopyableNode(makeNode({ id: "initiative:1", type: "text" }))).toBe(false); + }); + + it("returns false for milestone: live-id nodes", () => { + expect(isCopyableNode(makeNode({ id: "milestone:1", type: "text" }))).toBe(false); + }); + + it("returns false for task: live-id nodes", () => { + expect(isCopyableNode(makeNode({ id: "task:1", type: "text" }))).toBe(false); + }); + + it("returns false for research: live-id nodes", () => { + expect(isCopyableNode(makeNode({ id: "research:1", type: "text" }))).toBe(false); + }); + + it("returns false for repo: live-id nodes", () => { + expect(isCopyableNode(makeNode({ id: "repo:1", type: "text" }))).toBe(false); + }); + + it("returns true for note category node", () => { + expect(isCopyableNode(makeNode({ id: "abc123", type: "text", category: "note" }))).toBe(true); + }); + + it("returns true for decision category node", () => { + expect( + isCopyableNode(makeNode({ id: "abc123", type: "text", category: "decision" })), + ).toBe(true); + }); + + it("returns true for text type node with no category", () => { + expect(isCopyableNode(makeNode({ id: "abc123", type: "text" }))).toBe(true); + }); + + it("returns true for group type node", () => { + expect(isCopyableNode(makeNode({ id: "abc123", type: "group" }))).toBe(true); + }); + + it("returns false for service category node (authored but excluded)", () => { + expect( + isCopyableNode(makeNode({ id: "abc123", type: "text", category: "service" })), + ).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// computePastePosition +// --------------------------------------------------------------------------- + +describe("computePastePosition", () => { + const SMALL_W = 220; + + it("same viewport: offsets to the right of the node", () => { + const node = makeNode({ x: 100, y: 200, width: 220, height: 80 }); + const vp = { x: 0, y: 0, zoom: 1 }; + const result = computePastePosition(node, vp, vp, 1000, 800); + // x = node.x + node.width + 40 + expect(result.x).toBe(100 + 220 + 40); + expect(result.y).toBe(200); + }); + + it("same viewport: uses SMALL_W when node.width is undefined", () => { + const node = makeNode({ x: 50, y: 100, width: undefined, height: 80 }); + const vp = { x: 0, y: 0, zoom: 1 }; + const result = computePastePosition(node, vp, vp, 1000, 800); + expect(result.x).toBe(50 + SMALL_W + 40); + expect(result.y).toBe(100); + }); + + it("moved viewport (x changed): centers in viewport", () => { + const node = makeNode({ x: 100, y: 100, width: 220, height: 80 }); + const viewportAtCopy = { x: 0, y: 0, zoom: 1 }; + const viewportNow = { x: -500, y: 0, zoom: 1 }; // panned 500px + const containerW = 1000; + const containerH = 800; + const result = computePastePosition(node, viewportAtCopy, viewportNow, containerW, containerH); + // centerX = (500 + 500) / 1 = 1000; x = 1000 - 220/2 = 890 + const expectedX = (-viewportNow.x + containerW / 2) / viewportNow.zoom - (node.width ?? SMALL_W) / 2; + const expectedY = (-viewportNow.y + containerH / 2) / viewportNow.zoom - (node.height ?? 80) / 2; + expect(result.x).toBeCloseTo(expectedX); + expect(result.y).toBeCloseTo(expectedY); + }); + + it("moved viewport (zoom changed): centers in viewport", () => { + const node = makeNode({ x: 100, y: 100, width: 220, height: 80 }); + const viewportAtCopy = { x: 0, y: 0, zoom: 1 }; + const viewportNow = { x: 0, y: 0, zoom: 2 }; // zoomed in + const containerW = 1000; + const containerH = 800; + const result = computePastePosition(node, viewportAtCopy, viewportNow, containerW, containerH); + const expectedX = (-viewportNow.x + containerW / 2) / viewportNow.zoom - (node.width ?? SMALL_W) / 2; + const expectedY = (-viewportNow.y + containerH / 2) / viewportNow.zoom - (node.height ?? 80) / 2; + expect(result.x).toBeCloseTo(expectedX); + expect(result.y).toBeCloseTo(expectedY); + }); + + it("uses SMALL_W/80 defaults when width/height undefined in moved viewport", () => { + const node = makeNode({ x: 100, y: 100, width: undefined, height: undefined }); + const viewportAtCopy = { x: 0, y: 0, zoom: 1 }; + const viewportNow = { x: -500, y: 0, zoom: 1 }; + const result = computePastePosition(node, viewportAtCopy, viewportNow, 1000, 800); + const expectedX = 1000 - SMALL_W / 2; + const expectedY = 400 - 80 / 2; + expect(result.x).toBeCloseTo(expectedX); + expect(result.y).toBeCloseTo(expectedY); + }); +}); + +// --------------------------------------------------------------------------- +// Hook keyboard integration +// --------------------------------------------------------------------------- + +vi.mock("system-canvas-react", async () => { + const actual = await vi.importActual("system-canvas-react"); + return { + ...actual, + addNode: vi.fn((canvas: CanvasData, node: CanvasNode) => ({ + ...canvas, + nodes: [...(canvas.nodes ?? []), node], + })), + removeNode: vi.fn((canvas: CanvasData, id: string) => ({ + ...canvas, + nodes: (canvas.nodes ?? []).filter((n: CanvasNode) => n.id !== id), + })), + }; +}); + +vi.mock("system-canvas", async () => { + const actual = await vi.importActual("system-canvas"); + return { + ...actual, + generateNodeId: vi.fn(() => "generated-id"), + }; +}); + +describe("useCanvasClipboard (keyboard integration)", () => { + let applyMutation: ReturnType; + let selectedNode: CanvasNode; + + beforeEach(() => { + applyMutation = vi.fn(); + selectedNode = makeNode({ id: "note-1", type: "text", category: "note" }); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + function renderClipboard(node: CanvasNode | null = selectedNode) { + const currentRefRef = { current: "root" }; + const currentViewportRef = { current: { x: 0, y: 0, zoom: 1 } }; + const canvasContainerRef = { current: null as HTMLDivElement | null }; + + const { result } = renderHook(() => { + const nodeRef = useRef(node); + nodeRef.current = node; + useCanvasClipboard({ + selectedNode: nodeRef.current, + currentRefRef, + applyMutation, + currentViewportRef, + canvasContainerRef, + }); + }); + + return { result, currentRefRef, currentViewportRef, canvasContainerRef }; + } + + it("Ctrl+C with a copyable node populates the clipboard (via paste working)", () => { + renderClipboard(); + + act(() => { + fireKeydown("c", { ctrlKey: true }); + }); + // Clipboard is internal; verify by pressing Ctrl+V and checking applyMutation was called + act(() => { + fireKeydown("v", { ctrlKey: true }); + }); + + expect(applyMutation).toHaveBeenCalledTimes(1); + // The mutate fn should call addNode + const mutateFn = applyMutation.mock.calls[0][1]; + const emptyCanvas: CanvasData = { nodes: [], edges: [] }; + mutateFn(emptyCanvas); + expect(addNode).toHaveBeenCalledWith( + emptyCanvas, + expect.objectContaining({ id: "generated-id", category: "note" }), + ); + }); + + it("Ctrl+X removes source node via applyMutation", () => { + renderClipboard(); + + act(() => { + fireKeydown("x", { ctrlKey: true }); + }); + + expect(applyMutation).toHaveBeenCalledTimes(1); + const mutateFn = applyMutation.mock.calls[0][1]; + const emptyCanvas: CanvasData = { nodes: [], edges: [] }; + mutateFn(emptyCanvas); + expect(removeNode).toHaveBeenCalledWith(emptyCanvas, "note-1"); + }); + + it("Ctrl+V with prior Ctrl+X calls addNode with a new ID", () => { + renderClipboard(); + + act(() => { + fireKeydown("x", { ctrlKey: true }); // cut + }); + applyMutation.mockClear(); + + act(() => { + fireKeydown("v", { ctrlKey: true }); // paste + }); + + expect(applyMutation).toHaveBeenCalledTimes(1); + const mutateFn = applyMutation.mock.calls[0][1]; + const emptyCanvas: CanvasData = { nodes: [], edges: [] }; + mutateFn(emptyCanvas); + expect(addNode).toHaveBeenCalledWith( + emptyCanvas, + expect.objectContaining({ id: "generated-id" }), + ); + }); + + it("Ctrl+C with a live-id node does not change clipboard (Ctrl+V has no effect)", () => { + const liveNode = makeNode({ id: "ws:workspace-1", type: "text" }); + renderClipboard(liveNode); + + act(() => { + fireKeydown("c", { ctrlKey: true }); + }); + act(() => { + fireKeydown("v", { ctrlKey: true }); + }); + + // applyMutation never called because clipboard is empty + expect(applyMutation).not.toHaveBeenCalled(); + }); + + it("Ctrl+C when no node selected does not change clipboard", () => { + renderClipboard(null); + + act(() => { + fireKeydown("c", { ctrlKey: true }); + }); + act(() => { + fireKeydown("v", { ctrlKey: true }); + }); + + expect(applyMutation).not.toHaveBeenCalled(); + }); + + it("Ctrl+C with INPUT as target does not change clipboard", () => { + // We test this by verifying applyMutation isn't called after a guarded copy + renderClipboard(); + + // Simulate keydown from an INPUT element + const input = document.createElement("input"); + document.body.appendChild(input); + input.focus(); + + act(() => { + const event = new KeyboardEvent("keydown", { + key: "c", + bubbles: true, + ctrlKey: true, + }); + // Dispatch from input so target.tagName is INPUT + input.dispatchEvent(event); + }); + + // Now try to paste — should not call applyMutation since clipboard is empty + act(() => { + fireKeydown("v", { ctrlKey: true }); + }); + + expect(applyMutation).not.toHaveBeenCalled(); + document.body.removeChild(input); + }); +}); diff --git a/src/app/org/[githubLogin]/connections/OrgCanvasBackground.tsx b/src/app/org/[githubLogin]/connections/OrgCanvasBackground.tsx index 7f6a62dd97..69be2b19cd 100644 --- a/src/app/org/[githubLogin]/connections/OrgCanvasBackground.tsx +++ b/src/app/org/[githubLogin]/connections/OrgCanvasBackground.tsx @@ -51,6 +51,7 @@ import type { import { categoryAllowedOnScope } from "./canvas-categories"; import { useCanvasChatStore } from "../_state/canvasChatStore"; import { useSendCanvasChatMessage } from "../_state/useSendCanvasChatMessage"; +import useCanvasClipboard from "./useCanvasClipboard"; /** * Live-id detection mirrors `src/lib/canvas/scope.ts`'s `isLiveId`. @@ -475,10 +476,12 @@ export function OrgCanvasBackground({ const handleSelectionChange = useCallback( (selection: CanvasSelection) => { if (!selection) { + selectedNodeForClipboardRef.current = null; onSelectionChange?.(null); return; } if (selection.kind === "node") { + selectedNodeForClipboardRef.current = selection.node; onSelectionChange?.({ kind: "node", node: selection.node, @@ -486,6 +489,7 @@ export function OrgCanvasBackground({ }); return; } + selectedNodeForClipboardRef.current = null; // Edge — resolve human labels off the canvas the edge lives on. // The refs lag state by one commit, but the edge's endpoints // are already in the rendered canvas (it wouldn't have been @@ -555,6 +559,19 @@ export function OrgCanvasBackground({ currentRefRef.current = currentRef; }, [currentRef]); + // Viewport tracking for clipboard paste placement (ref, not state — no re-renders). + const currentViewportRef = useRef<{ x: number; y: number; zoom: number }>({ + x: 0, + y: 0, + zoom: 1, + }); + + // Container ref for reading dimensions during paste position calculation. + const canvasContainerRef = useRef(null); + + // Selected node ref for clipboard — updated inside handleSelectionChange. + const selectedNodeForClipboardRef = useRef(null); + // ------------------------------------------------------------------- // Single source of truth for canvas scope: the library's breadcrumb // trail. @@ -2017,6 +2034,15 @@ export function OrgCanvasBackground({ return () => document.removeEventListener("keydown", onKeyDown); }, [handleUndo]); + // Copy / Cut / Paste keyboard shortcuts for authored canvas elements. + useCanvasClipboard({ + selectedNode: selectedNodeForClipboardRef.current, + currentRefRef, + applyMutation, + currentViewportRef, + canvasContainerRef, + }); + /** * Detect a user-drawn edge whose endpoints are a feature card and a * milestone card on the initiative canvas. Either direction is @@ -2809,8 +2835,7 @@ export function OrgCanvasBackground({ return ( <>
-
- {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */} +
{ + currentViewportRef.current = vp; + }} /> {/* * Restore pill — top-right of the canvas area. Shown on any diff --git a/src/app/org/[githubLogin]/connections/useCanvasClipboard.ts b/src/app/org/[githubLogin]/connections/useCanvasClipboard.ts new file mode 100644 index 0000000000..50eccf16d7 --- /dev/null +++ b/src/app/org/[githubLogin]/connections/useCanvasClipboard.ts @@ -0,0 +1,186 @@ +"use client"; + +import { useEffect, useRef } from "react"; +import { addNode, removeNode, type CanvasData, type CanvasNode } from "system-canvas-react"; +import { generateNodeId } from "system-canvas"; +import { SMALL_W } from "@/lib/canvas/geometry"; + +// --------------------------------------------------------------------------- +// Live-id detection — mirrors `src/lib/canvas/scope.ts`'s `isLiveId`. +// NOT imported from there because that module is server-side (pulls Prisma). +// --------------------------------------------------------------------------- + +const LIVE_ID_PREFIXES = [ + "ws:", + "feature:", + "repo:", + "initiative:", + "milestone:", + "task:", + "research:", +] as const; + +function isLiveId(id: string): boolean { + return LIVE_ID_PREFIXES.some((prefix) => id.startsWith(prefix)); +} + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const PASTE_SIDE_GAP = 40; + +// --------------------------------------------------------------------------- +// Exported pure helpers +// --------------------------------------------------------------------------- + +/** + * Returns `true` when the node can be copied/cut/pasted. + * Excludes DB-backed (live-id) nodes and `service` category nodes. + */ +export function isCopyableNode(node: CanvasNode): boolean { + if (isLiveId(node.id)) return false; + // service nodes are authored but intentionally excluded + if (node.category === "service") return false; + return ( + node.category === "note" || + node.category === "decision" || + node.type === "text" || + node.type === "group" + ); +} + +type ViewportState = { x: number; y: number; zoom: number }; + +/** + * Computes where a pasted node should land. + * + * - Same viewport (no significant pan/zoom since copy): offset to the right of + * the original so both are visible simultaneously. + * - Moved viewport: center of the current viewport in canvas coordinates. + */ +export function computePastePosition( + node: CanvasNode, + viewportAtCopy: ViewportState, + viewportNow: ViewportState, + containerW: number, + containerH: number, +): { x: number; y: number } { + const sameViewport = + Math.abs(viewportAtCopy.x - viewportNow.x) < 50 && + Math.abs(viewportAtCopy.y - viewportNow.y) < 50 && + Math.abs(viewportAtCopy.zoom - viewportNow.zoom) < 0.01; + + if (sameViewport) { + return { + x: node.x + (node.width ?? SMALL_W) + PASTE_SIDE_GAP, + y: node.y, + }; + } + + const centerX = (-viewportNow.x + containerW / 2) / viewportNow.zoom; + const centerY = (-viewportNow.y + containerH / 2) / viewportNow.zoom; + return { + x: centerX - (node.width ?? SMALL_W) / 2, + y: centerY - (node.height ?? 80) / 2, + }; +} + +// --------------------------------------------------------------------------- +// Hook +// --------------------------------------------------------------------------- + +interface UseCanvasClipboardParams { + selectedNode: CanvasNode | null; + currentRefRef: React.MutableRefObject; + applyMutation: (ref: string | undefined, mutate: (data: CanvasData) => CanvasData) => void; + currentViewportRef: React.MutableRefObject; + canvasContainerRef: React.RefObject; +} + +export default function useCanvasClipboard({ + selectedNode, + currentRefRef, + applyMutation, + currentViewportRef, + canvasContainerRef, +}: UseCanvasClipboardParams): void { + const clipboardRef = useRef<{ + node: CanvasNode; + viewportAtCopy: ViewportState; + sourceCanvasRef: string | undefined; + } | null>(null); + + // Keep a stable ref to selectedNode so the keydown handler always reads the + // latest value without needing to re-attach on every render. + const selectedNodeRef = useRef(selectedNode); + useEffect(() => { + selectedNodeRef.current = selectedNode; + }); + + useEffect(() => { + const onKeyDown = (e: KeyboardEvent) => { + if (!e.ctrlKey && !e.metaKey) return; + + // Don't intercept shortcuts inside text inputs / rich-text editors + const tag = (e.target as HTMLElement).tagName; + if ( + tag === "INPUT" || + tag === "TEXTAREA" || + (e.target as HTMLElement).isContentEditable + ) + return; + + const node = selectedNodeRef.current; + + if (e.key === "c") { + if (node && isCopyableNode(node)) { + clipboardRef.current = { + node, + viewportAtCopy: { ...currentViewportRef.current }, + sourceCanvasRef: currentRefRef.current || undefined, + }; + } + return; + } + + if (e.key === "x") { + if (node && isCopyableNode(node)) { + clipboardRef.current = { + node, + viewportAtCopy: { ...currentViewportRef.current }, + sourceCanvasRef: currentRefRef.current || undefined, + }; + applyMutation(currentRefRef.current || undefined, (c) => + removeNode(c, node.id), + ); + e.preventDefault(); + } + return; + } + + if (e.key === "v") { + if (!clipboardRef.current) return; + const { node: clipNode, viewportAtCopy } = clipboardRef.current; + const rect = canvasContainerRef.current?.getBoundingClientRect(); + const containerW = rect?.width ?? 0; + const containerH = rect?.height ?? 0; + const pos = computePastePosition( + clipNode, + viewportAtCopy, + currentViewportRef.current, + containerW, + containerH, + ); + applyMutation(currentRefRef.current || undefined, (c) => + addNode(c, { ...clipNode, id: generateNodeId(), x: pos.x, y: pos.y }), + ); + e.preventDefault(); + return; + } + }; + + document.addEventListener("keydown", onKeyDown); + return () => document.removeEventListener("keydown", onKeyDown); + }, [applyMutation, canvasContainerRef, currentRefRef, currentViewportRef]); +} From e41e37cd2d34fbc74e9c7a0d6c673ff2555a7c11 Mon Sep 17 00:00:00 2001 From: tomsmith8 Date: Wed, 13 May 2026 09:17:14 +0000 Subject: [PATCH 06/10] Generated with Hive: Set task status to IN_PROGRESS on workflow editor run and update routing --- .../services/workflow-editor-trigger.test.ts | 211 ++++++++++++++++++ src/services/workflow-editor.ts | 4 +- 2 files changed, 214 insertions(+), 1 deletion(-) create mode 100644 src/__tests__/unit/services/workflow-editor-trigger.test.ts diff --git a/src/__tests__/unit/services/workflow-editor-trigger.test.ts b/src/__tests__/unit/services/workflow-editor-trigger.test.ts new file mode 100644 index 0000000000..9783db34e4 --- /dev/null +++ b/src/__tests__/unit/services/workflow-editor-trigger.test.ts @@ -0,0 +1,211 @@ +/** + * Unit tests for triggerWorkflowEditorRun in src/services/workflow-editor.ts + * Focuses on the status + workflowStatus update on success / failure. + */ + +import { describe, test, expect, vi, beforeEach, afterEach } from "vitest"; + +// ─── Mocks ──────────────────────────────────────────────────────────────────── + +vi.mock("@/lib/db"); + +vi.mock("@/lib/pusher", () => ({ + pusherServer: { trigger: vi.fn().mockResolvedValue({}) }, + getTaskChannelName: vi.fn((id: string) => `task-${id}`), + PUSHER_EVENTS: { NEW_MESSAGE: "new-message" }, +})); + +vi.mock("@/lib/auth/nextauth", () => ({ + getGithubUsernameAndPAT: vi.fn().mockResolvedValue({ + username: "test-user", + token: "test-token", + }), +})); + +vi.mock("@/lib/vercel/stakwork-token", () => ({ + getStakworkTokenReference: vi.fn().mockReturnValue("{{HIVE_STAGING}}"), +})); + +vi.mock("@/lib/utils/swarm", () => ({ + transformSwarmUrlToRepo2Graph: vi.fn().mockReturnValue("http://swarm:3355"), +})); + +vi.mock("@/lib/utils", () => ({ + getBaseUrl: vi.fn().mockReturnValue("http://localhost:3000"), +})); + +vi.mock("@/lib/helpers/chat-history", () => ({ + fetchChatHistory: vi.fn().mockResolvedValue([]), +})); + +vi.mock("@/config/env", () => ({ + config: { + STAKWORK_BASE_URL: "https://api.stakwork.com/api/v1", + STAKWORK_API_KEY: "test-api-key", + STAKWORK_WORKFLOW_EDITOR_WORKFLOW_ID: "42", + }, +})); + +// ─── Subject ────────────────────────────────────────────────────────────────── + +import { triggerWorkflowEditorRun } from "@/services/workflow-editor"; +import { db } from "@/lib/db"; +import { WorkflowStatus, TaskStatus } from "@prisma/client"; + +const mockedDb = vi.mocked(db); + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +function makeTask() { + return { + id: "task-1", + workspaceId: "ws-1", + workspace: { + slug: "stakwork", + ownerId: "user-1", + members: [{ userId: "user-1" }], + swarm: { + swarmUrl: "http://swarm/api", + swarmSecretAlias: "secret", + poolName: "pool-1", + name: "swarm-1", + id: "swarm-id-1", + }, + }, + }; +} + +function mockFetchSuccess(projectId = 123) { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ success: true, data: { project_id: projectId } }), + }) as unknown as typeof fetch; +} + +function mockFetchNotOk() { + global.fetch = vi.fn().mockResolvedValue({ + ok: false, + statusText: "Internal Server Error", + json: async () => ({}), + }) as unknown as typeof fetch; +} + +function mockFetchSuccessFalse() { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ success: false }), + }) as unknown as typeof fetch; +} + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +describe("triggerWorkflowEditorRun", () => { + let originalFetch: typeof global.fetch; + + beforeEach(() => { + vi.clearAllMocks(); + originalFetch = global.fetch; + mockedDb.task.findFirst = vi.fn().mockResolvedValue(makeTask()) as never; + mockedDb.task.update = vi.fn().mockResolvedValue({}) as never; + mockedDb.chatMessage.create = vi.fn().mockResolvedValue({ id: "msg-1" }) as never; + }); + + afterEach(() => { + global.fetch = originalFetch; + }); + + test("sets status: IN_PROGRESS on successful Stakwork call", async () => { + mockFetchSuccess(456); + + const updateCalls: unknown[] = []; + mockedDb.task.update = vi.fn().mockImplementation(async (args: unknown) => { + updateCalls.push(args); + return {}; + }) as never; + + await triggerWorkflowEditorRun({ + taskId: "task-1", + userId: "user-1", + message: "Edit the workflow", + workflowTask: { workflowId: 99, workflowName: "My Workflow", workflowRefId: "ref-abc" }, + }); + + expect(updateCalls).toHaveLength(1); + const update = updateCalls[0] as { data: Record }; + expect(update.data.status).toBe(TaskStatus.IN_PROGRESS); + expect(update.data.workflowStatus).toBe(WorkflowStatus.IN_PROGRESS); + expect(update.data.haltRetryAttempted).toBe(false); + }); + + test("includes stakworkProjectId in the update on success", async () => { + mockFetchSuccess(789); + + const updateCalls: unknown[] = []; + mockedDb.task.update = vi.fn().mockImplementation(async (args: unknown) => { + updateCalls.push(args); + return {}; + }) as never; + + await triggerWorkflowEditorRun({ + taskId: "task-1", + userId: "user-1", + message: "Edit the workflow", + workflowTask: { workflowId: 99, workflowName: "My Workflow", workflowRefId: "ref-abc" }, + }); + + const update = updateCalls[0] as { data: Record }; + expect(update.data.stakworkProjectId).toBe(789); + }); + + test("does NOT set status on failed Stakwork response (!ok)", async () => { + mockFetchNotOk(); + + await expect( + triggerWorkflowEditorRun({ + taskId: "task-1", + userId: "user-1", + message: "Edit the workflow", + workflowTask: { workflowId: 99, workflowName: "My Workflow", workflowRefId: "ref-abc" }, + }), + ).rejects.toThrow(); + + const updateCalls = (mockedDb.task.update as ReturnType).mock.calls; + // Only the FAILED status update — no status field change + expect(updateCalls).toHaveLength(1); + const failUpdate = updateCalls[0][0] as { data: Record }; + expect(failUpdate.data.workflowStatus).toBe(WorkflowStatus.FAILED); + expect(failUpdate.data.status).toBeUndefined(); + }); + + test("does NOT set status on success:false Stakwork response", async () => { + mockFetchSuccessFalse(); + + await expect( + triggerWorkflowEditorRun({ + taskId: "task-1", + userId: "user-1", + message: "Edit the workflow", + workflowTask: { workflowId: 99, workflowName: "My Workflow", workflowRefId: "ref-abc" }, + }), + ).rejects.toThrow(); + + const updateCalls = (mockedDb.task.update as ReturnType).mock.calls; + expect(updateCalls).toHaveLength(1); + const failUpdate = updateCalls[0][0] as { data: Record }; + expect(failUpdate.data.workflowStatus).toBe(WorkflowStatus.FAILED); + expect(failUpdate.data.status).toBeUndefined(); + }); + + test("throws when task is not found", async () => { + mockedDb.task.findFirst = vi.fn().mockResolvedValue(null) as never; + + await expect( + triggerWorkflowEditorRun({ + taskId: "missing-task", + userId: "user-1", + message: "Edit the workflow", + workflowTask: { workflowId: 99, workflowName: "My Workflow", workflowRefId: "ref-abc" }, + }), + ).rejects.toThrow("Task missing-task not found"); + }); +}); diff --git a/src/services/workflow-editor.ts b/src/services/workflow-editor.ts index 9cf24d1d74..4f3fa2b692 100644 --- a/src/services/workflow-editor.ts +++ b/src/services/workflow-editor.ts @@ -6,7 +6,7 @@ import { db } from "@/lib/db"; import { config } from "@/config/env"; import { ChatRole, ChatStatus, ArtifactType } from "@/lib/chat"; -import { WorkflowStatus } from "@prisma/client"; +import { WorkflowStatus, TaskStatus } from "@prisma/client"; import { getBaseUrl } from "@/lib/utils"; import { transformSwarmUrlToRepo2Graph } from "@/lib/utils/swarm"; import { getGithubUsernameAndPAT } from "@/lib/auth/nextauth"; @@ -214,11 +214,13 @@ export async function triggerWorkflowEditorRun(params: { workflowStatus: WorkflowStatus; workflowStartedAt: Date; haltRetryAttempted: boolean; + status: TaskStatus; stakworkProjectId?: number; } = { workflowStatus: WorkflowStatus.IN_PROGRESS, workflowStartedAt: new Date(), haltRetryAttempted: false, + status: TaskStatus.IN_PROGRESS, }; if (result.data?.project_id) { From fb410ad5697deb4320c88c4fed22d031332ee92f Mon Sep 17 00:00:00 2001 From: tomsmith8 Date: Wed, 13 May 2026 09:36:20 +0000 Subject: [PATCH 07/10] Generated with Hive: Add Mark Complete action for workflow tasks --- .../features/CompactTasksList.test.tsx | 102 ++++++++++++++++++ .../features/CompactTasksList/index.tsx | 29 ++++- 2 files changed, 129 insertions(+), 2 deletions(-) diff --git a/src/__tests__/unit/components/features/CompactTasksList.test.tsx b/src/__tests__/unit/components/features/CompactTasksList.test.tsx index ba6d71a791..398b381450 100644 --- a/src/__tests__/unit/components/features/CompactTasksList.test.tsx +++ b/src/__tests__/unit/components/features/CompactTasksList.test.tsx @@ -928,6 +928,108 @@ describe("CompactTasksList", () => { }); }); + describe("Mark Complete action menu item", () => { + const workflowTask = { id: "wt-1", taskId: "task-wf", workflowId: "wf-1", workflowName: "My Workflow", workflowRefId: "ref-1" }; + + test("shows 'Mark Complete' for workflow task with status TODO", () => { + const task = createMockTask({ id: "task-wf-todo", status: "TODO", workflowTask }); + const feature = createMockFeature([task]); + + render( + + ); + + expect(screen.getByTestId("action-mark-complete")).toBeInTheDocument(); + }); + + test("shows 'Mark Complete' for workflow task with status IN_PROGRESS", () => { + const task = createMockTask({ id: "task-wf-ip", status: "IN_PROGRESS", workflowTask }); + const feature = createMockFeature([task]); + + render( + + ); + + expect(screen.getByTestId("action-mark-complete")).toBeInTheDocument(); + }); + + test("does NOT show 'Mark Complete' for workflow task with status DONE", () => { + const task = createMockTask({ id: "task-wf-done", status: "DONE", workflowTask }); + const feature = createMockFeature([task]); + + render( + + ); + + expect(screen.queryByTestId("action-mark-complete")).not.toBeInTheDocument(); + }); + + test("does NOT show 'Mark Complete' for non-workflow task", () => { + const task = createMockTask({ id: "task-non-wf", status: "TODO", workflowTask: null }); + const feature = createMockFeature([task]); + + render( + + ); + + expect(screen.queryByTestId("action-mark-complete")).not.toBeInTheDocument(); + }); + + test("calls PATCH /api/tasks/:id with { status: 'DONE' } on click", async () => { + const fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation((url) => { + if (typeof url === "string" && url.includes("/api/llm-models")) { + return Promise.resolve(new Response(JSON.stringify({ models: [] }), { status: 200 })); + } + return Promise.resolve(new Response(JSON.stringify({ success: true }), { status: 200 })); + }); + const task = createMockTask({ id: "task-wf-click", status: "TODO", workflowTask }); + const feature = createMockFeature([task]); + + render( + + ); + + screen.getByTestId("action-mark-complete").click(); + + await waitFor(() => { + expect(fetchSpy).toHaveBeenCalledWith( + "/api/tasks/task-wf-click", + expect.objectContaining({ + method: "PATCH", + body: JSON.stringify({ status: "DONE" }), + }) + ); + }); + + fetchSpy.mockRestore(); + }); + }); + describe("Repo SelectTrigger truncation", () => { test("SelectTrigger inner div has overflow-hidden to prevent long repo names wrapping", () => { const task = createMockTask({ diff --git a/src/components/features/CompactTasksList/index.tsx b/src/components/features/CompactTasksList/index.tsx index c24c4baaf1..699098e211 100644 --- a/src/components/features/CompactTasksList/index.tsx +++ b/src/components/features/CompactTasksList/index.tsx @@ -2,7 +2,7 @@ import React, { useState, useMemo, useCallback, useEffect } from "react"; import { useRouter } from "next/navigation"; -import { ChevronDown, ExternalLink, Play, Trash2, RefreshCw, Copy, Sparkles } from "lucide-react"; +import { ChevronDown, ExternalLink, Play, Trash2, RefreshCw, Copy, Sparkles, CheckCircle } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Collapsible, CollapsibleTrigger, CollapsibleContent } from "@/components/ui/collapsible"; import { DependencyGraph } from "@/components/features/DependencyGraph"; @@ -309,6 +309,21 @@ export function CompactTasksList({ featureId, feature, onUpdate, isGenerating }: } }; + const handleMarkComplete = async (taskId: string) => { + try { + const response = await fetch(`/api/tasks/${taskId}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ status: "DONE" }), + }); + if (!response.ok) throw new Error("Failed to mark complete"); + // Pusher real-time + updateFeatureStatusFromTasks handle visual update + } catch (error) { + console.error("Failed to mark complete:", error); + toast.error("Failed to mark task as complete"); + } + }; + const handleStartTask = async (taskId: string) => { if (startingTaskId) return; setStartingTaskId(taskId); @@ -608,8 +623,18 @@ export function CompactTasksList({ featureId, feature, onUpdate, isGenerating }: }); } - const isTerminalWorkflow = ['ERROR', 'FAILED', 'HALTED'].includes(task.workflowStatus ?? ''); const isWorkflowTask = !!task.workflowTask; + + if (isWorkflowTask && (task.status === "TODO" || task.status === "IN_PROGRESS")) { + actionMenuItems.push({ + label: "Mark Complete", + icon: CheckCircle, + variant: "default" as const, + onClick: () => handleMarkComplete(task.id), + }); + } + + const isTerminalWorkflow = ['ERROR', 'FAILED', 'HALTED'].includes(task.workflowStatus ?? ''); const isRetrying = retryingTaskId === task.id; return ( From e4c3255204d3eabb19fd248db9fd8bcd8b9fff81 Mon Sep 17 00:00:00 2001 From: tomsmith8 Date: Wed, 13 May 2026 09:46:16 +0000 Subject: [PATCH 08/10] Generated with Hive: Fix workflow task dependency enforcement in assign-all and Task Coordinator cron --- .../api/cron/task-coordinator.test.ts | 150 ++++++++++++ .../api/tickets-update-workflow.test.ts | 178 ++++++++++++++ .../services/task-coordinator-cron.test.ts | 218 +++++++++++++++++- .../[featureId]/tasks/assign-all/route.ts | 86 +++---- src/app/api/tasks/[taskId]/route.ts | 24 +- src/services/task-coordinator-cron.ts | 108 +++++++++ 6 files changed, 700 insertions(+), 64 deletions(-) diff --git a/src/__tests__/integration/api/cron/task-coordinator.test.ts b/src/__tests__/integration/api/cron/task-coordinator.test.ts index 6ebfb35301..e0a02423e4 100644 --- a/src/__tests__/integration/api/cron/task-coordinator.test.ts +++ b/src/__tests__/integration/api/cron/task-coordinator.test.ts @@ -71,6 +71,12 @@ vi.mock("@/lib/auth/workspace-resolver", () => ({ }), })); +// Mock triggerWorkflowEditorRun so workflow tasks don't hit the external API +vi.mock("@/services/workflow-editor", () => ({ + triggerWorkflowEditorRun: vi.fn().mockResolvedValue(undefined), + saveWorkflowArtifact: vi.fn().mockResolvedValue(undefined), +})); + // Helper to create authenticated request with CRON_SECRET function createAuthenticatedRequest(): NextRequest { const headers = new Headers(); @@ -988,4 +994,148 @@ describe("Integration: /api/cron/task-coordinator", () => { expect(result.timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T/); // ISO format }); }); + + describe("Phase 4: Workflow Task Sweep", () => { + test("workflow task with SATISFIED deps and zero available pods → dispatch fires", async () => { + // Override pool mock to return 0 available pods for this test + const { getPoolStatusFromPods } = await import("@/lib/pods/status-queries"); + vi.mocked(getPoolStatusFromPods).mockResolvedValueOnce({ + runningVms: 0, + pendingVms: 0, + failedVms: 0, + usedVms: 0, + unusedVms: 0, + lastCheck: new Date().toISOString(), + queuedCount: 0, + }); + + // Create feature + phase + const feature = await db.feature.create({ + data: { + title: "Workflow Feature", + workspaceId: testWorkspace.id, + createdById: testUser.id, + updatedById: testUser.id, + }, + }); + + // Create a workflow task assigned to TASK_COORDINATOR with no dependencies + const workflowTicket = await db.task.create({ + data: { + title: "Workflow Task - No Deps", + description: "Run this workflow", + workspaceId: testWorkspace.id, + featureId: feature.id, + createdById: testUser.id, + updatedById: testUser.id, + status: "TODO", + mode: "workflow_editor", + sourceType: "TASK_COORDINATOR", + systemAssigneeType: "TASK_COORDINATOR", + priority: "HIGH", + dependsOnTaskIds: [], + }, + }); + + // Attach a WorkflowTask record + await db.workflowTask.create({ + data: { + taskId: workflowTicket.id, + workflowId: 99, + workflowName: "My Workflow", + workflowRefId: "ref-99", + }, + }); + + const mockRequest = createAuthenticatedRequest(); + process.env.TASK_COORDINATOR_ENABLED = "true"; + + const response = await GET(mockRequest); + const result = await response.json(); + + expect(response.status).toBe(200); + + // triggerWorkflowEditorRun must have been called despite 0 pods + const { triggerWorkflowEditorRun } = await import("@/services/workflow-editor"); + expect(triggerWorkflowEditorRun).toHaveBeenCalledWith( + expect.objectContaining({ + taskId: workflowTicket.id, + message: "Run this workflow", + userId: testUser.id, + }) + ); + }); + + test("workflow task with PENDING dep → dispatch NOT fired", async () => { + // Create a blocking task that is still TODO (not done) + const blockingTask = await db.task.create({ + data: { + title: "Blocking Task", + workspaceId: testWorkspace.id, + createdById: testUser.id, + updatedById: testUser.id, + status: "TODO", + mode: "agent", + sourceType: "USER", + priority: "HIGH", + }, + }); + + const feature = await db.feature.create({ + data: { + title: "Workflow Feature 2", + workspaceId: testWorkspace.id, + createdById: testUser.id, + updatedById: testUser.id, + }, + }); + + // Workflow task that depends on the blocking task + const workflowTicket = await db.task.create({ + data: { + title: "Workflow Task - Has Dep", + description: "This should be blocked", + workspaceId: testWorkspace.id, + featureId: feature.id, + createdById: testUser.id, + updatedById: testUser.id, + status: "TODO", + mode: "workflow_editor", + sourceType: "TASK_COORDINATOR", + systemAssigneeType: "TASK_COORDINATOR", + priority: "HIGH", + dependsOnTaskIds: [blockingTask.id], + }, + }); + + await db.workflowTask.create({ + data: { + taskId: workflowTicket.id, + workflowId: 100, + workflowName: "Blocked Workflow", + workflowRefId: "ref-100", + }, + }); + + const mockRequest = createAuthenticatedRequest(); + process.env.TASK_COORDINATOR_ENABLED = "true"; + + vi.clearAllMocks(); + // Re-apply the triggerWorkflowEditorRun mock after clearAllMocks + const { triggerWorkflowEditorRun } = await import("@/services/workflow-editor"); + vi.mocked(triggerWorkflowEditorRun).mockResolvedValue(undefined); + + const response = await GET(mockRequest); + expect(response.status).toBe(200); + + // triggerWorkflowEditorRun must NOT have been called + expect(triggerWorkflowEditorRun).not.toHaveBeenCalledWith( + expect.objectContaining({ taskId: workflowTicket.id }) + ); + + // Task should remain assigned to coordinator (not cleared) + const after = await db.task.findUnique({ where: { id: workflowTicket.id } }); + expect(after?.systemAssigneeType).toBe("TASK_COORDINATOR"); + }); + }); }); diff --git a/src/__tests__/integration/api/tickets-update-workflow.test.ts b/src/__tests__/integration/api/tickets-update-workflow.test.ts index af2a21e58f..77bda240d5 100644 --- a/src/__tests__/integration/api/tickets-update-workflow.test.ts +++ b/src/__tests__/integration/api/tickets-update-workflow.test.ts @@ -1,14 +1,45 @@ import { describe, test, expect, beforeEach, vi } from "vitest"; import { PATCH } from "@/app/api/tickets/[ticketId]/route"; +import { POST as assignAll } from "@/app/api/features/[featureId]/tasks/assign-all/route"; import { db } from "@/lib/db"; import { createTestUser, createTestWorkspace } from "@/__tests__/support/fixtures"; import { createAuthenticatedPatchRequest, + createAuthenticatedPostRequest, expectSuccess, expectError, } from "@/__tests__/support/helpers"; import type { User, Workspace, Task, Feature } from "@prisma/client"; +// Mock workflow-editor so triggerWorkflowEditorRun doesn't hit external APIs +vi.mock("@/services/workflow-editor", () => ({ + triggerWorkflowEditorRun: vi.fn().mockResolvedValue(undefined), + saveWorkflowArtifact: vi.fn().mockResolvedValue(undefined), +})); + +// Mock coordinator sweeps so eager-start doesn't run in tests +vi.mock("@/services/task-coordinator-cron", async () => { + const actual = await vi.importActual("@/services/task-coordinator-cron"); + return { + ...actual, + processTicketSweep: vi.fn().mockResolvedValue(0), + processWorkflowTaskSweep: vi.fn().mockResolvedValue(0), + }; +}); + +// Mock pool status queries +vi.mock("@/lib/pods/status-queries", () => ({ + getPoolStatusFromPods: vi.fn().mockResolvedValue({ + unusedVms: 0, + runningVms: 0, + pendingVms: 0, + failedVms: 0, + usedVms: 0, + lastCheck: new Date().toISOString(), + queuedCount: 0, + }), +})); + describe("PATCH /api/tickets/[ticketId] — workflow fields", () => { let owner: User; let workspace: Workspace; @@ -134,3 +165,150 @@ describe("PATCH /api/tickets/[ticketId] — workflow fields", () => { expect(wt).toBeNull(); }); }); + +describe("POST /api/features/[featureId]/tasks/assign-all — workflow task behaviour", () => { + let owner: User; + let workspace: Workspace; + let feature: Feature; + let phase: any; + + beforeEach(async () => { + vi.clearAllMocks(); + + owner = await createTestUser({ email: "owner-assignall@test.com" }); + workspace = await createTestWorkspace({ + name: "AssignAll WS", + slug: "assign-all-ws", + ownerId: owner.id, + }); + + await db.workspaceMember.create({ + data: { workspaceId: workspace.id, userId: owner.id, role: "OWNER" }, + }); + + feature = await db.feature.create({ + data: { + title: "Assign All Feature", + workspaceId: workspace.id, + createdById: owner.id, + updatedById: owner.id, + }, + }); + + phase = await db.phase.create({ + data: { + name: "Phase 1", + order: 0, + featureId: feature.id, + }, + }); + }); + + test("workflow task is assigned to TASK_COORDINATOR — triggerWorkflowEditorRun NOT called inline", async () => { + const { triggerWorkflowEditorRun } = await import("@/services/workflow-editor"); + + // Create a workflow task + const workflowTask = await db.task.create({ + data: { + title: "My Workflow Task", + description: "Run the workflow", + workspaceId: workspace.id, + featureId: feature.id, + phaseId: phase.id, + createdById: owner.id, + updatedById: owner.id, + status: "TODO", + mode: "workflow_editor", + }, + }); + + await db.workflowTask.create({ + data: { + taskId: workflowTask.id, + workflowId: 55, + workflowName: "Test Workflow", + workflowRefId: "ref-55", + }, + }); + + const request = createAuthenticatedPostRequest( + `http://localhost:3000/api/features/${feature.id}/tasks/assign-all`, + owner, + {} + ); + const response = await assignAll(request, { params: Promise.resolve({ featureId: feature.id }) }); + + expect(response.status).toBe(200); + const body = await response.json(); + expect(body.success).toBe(true); + expect(body.count).toBe(1); + + // Verify task assigned to coordinator, NOT dispatched inline + const updated = await db.task.findUnique({ where: { id: workflowTask.id } }); + expect(updated?.systemAssigneeType).toBe("TASK_COORDINATOR"); + + // triggerWorkflowEditorRun must NOT have been called inline + expect(triggerWorkflowEditorRun).not.toHaveBeenCalled(); + }); + + test("both repo and workflow tasks are assigned to TASK_COORDINATOR in a single updateMany", async () => { + const { triggerWorkflowEditorRun } = await import("@/services/workflow-editor"); + + // Create a repo (coding) task + const repoTask = await db.task.create({ + data: { + title: "Repo Task", + workspaceId: workspace.id, + featureId: feature.id, + phaseId: phase.id, + createdById: owner.id, + updatedById: owner.id, + status: "TODO", + mode: "agent", + }, + }); + + // Create a workflow task + const workflowTask = await db.task.create({ + data: { + title: "Workflow Task", + workspaceId: workspace.id, + featureId: feature.id, + phaseId: phase.id, + createdById: owner.id, + updatedById: owner.id, + status: "TODO", + mode: "workflow_editor", + }, + }); + + await db.workflowTask.create({ + data: { + taskId: workflowTask.id, + workflowId: 77, + workflowName: "Bulk Workflow", + workflowRefId: "ref-77", + }, + }); + + const request = createAuthenticatedPostRequest( + `http://localhost:3000/api/features/${feature.id}/tasks/assign-all`, + owner, + {} + ); + const response = await assignAll(request, { params: Promise.resolve({ featureId: feature.id }) }); + + expect(response.status).toBe(200); + const body = await response.json(); + expect(body.count).toBe(2); + + const updatedRepo = await db.task.findUnique({ where: { id: repoTask.id } }); + const updatedWorkflow = await db.task.findUnique({ where: { id: workflowTask.id } }); + + expect(updatedRepo?.systemAssigneeType).toBe("TASK_COORDINATOR"); + expect(updatedWorkflow?.systemAssigneeType).toBe("TASK_COORDINATOR"); + + // No inline dispatch for workflow tasks + expect(triggerWorkflowEditorRun).not.toHaveBeenCalled(); + }); +}); diff --git a/src/__tests__/unit/services/task-coordinator-cron.test.ts b/src/__tests__/unit/services/task-coordinator-cron.test.ts index e01da19658..af3017906d 100644 --- a/src/__tests__/unit/services/task-coordinator-cron.test.ts +++ b/src/__tests__/unit/services/task-coordinator-cron.test.ts @@ -31,6 +31,10 @@ vi.mock("@/services/task-workflow", () => ({ startTaskWorkflow: vi.fn(), })); +vi.mock("@/services/workflow-editor", () => ({ + triggerWorkflowEditorRun: vi.fn(), +})); + vi.mock("@/lib/pods", () => ({ releaseTaskPod: vi.fn().mockResolvedValue({ success: true, podDropped: false, taskCleared: false }), })); @@ -48,9 +52,10 @@ const { db: mockDb } = await import("@/lib/db"); const { getPoolStatusFromPods: mockGetPoolStatusFromPods } = await import("@/lib/pods/status-queries"); const { acceptJanitorRecommendation: mockAcceptJanitorRecommendation } = await import("@/services/janitor"); const { startTaskWorkflow: mockStartTaskWorkflow } = await import("@/services/task-workflow"); +const { triggerWorkflowEditorRun: mockTriggerWorkflowEditorRun } = await import("@/services/workflow-editor"); // Import functions under test -const { executeTaskCoordinatorRuns, processTicketSweep, areDependenciesSatisfied } = await import("@/services/task-coordinator-cron"); +const { executeTaskCoordinatorRuns, processTicketSweep, processWorkflowTaskSweep } = await import("@/services/task-coordinator-cron"); // Test Helpers - Setup and assertion utilities const TestHelpers = { @@ -1048,11 +1053,12 @@ describe("executeTaskCoordinatorRuns", () => { // 5 eligible candidate tasks (no deps) const candidates = Array.from({ length: 5 }, () => createCandidateTask()); - // findMany called 4 times: orphan sweep, limbo sweep, stale tasks, ticket sweep candidates + // findMany called 5 times: orphan sweep, limbo sweep, stale tasks, workflow sweep, ticket sweep candidates vi.mocked(mockDb.task.findMany) .mockResolvedValueOnce([]) // orphan sweep .mockResolvedValueOnce([]) // limbo sweep (IN_PROGRESS + no stakworkProjectId) .mockResolvedValueOnce([]) // stale tasks query + .mockResolvedValueOnce([]) // workflow task sweep candidates .mockResolvedValueOnce(candidates as any); // ticket sweep candidates TestHelpers.setupRecommendations([]); @@ -1074,6 +1080,7 @@ describe("executeTaskCoordinatorRuns", () => { .mockResolvedValueOnce([]) // orphan sweep .mockResolvedValueOnce([]) // limbo sweep .mockResolvedValueOnce([]) // stale tasks query + .mockResolvedValueOnce([]) // workflow task sweep candidates .mockResolvedValueOnce(candidates as any); TestHelpers.setupRecommendations([]); @@ -1094,6 +1101,7 @@ describe("executeTaskCoordinatorRuns", () => { .mockResolvedValueOnce([]) // orphan sweep .mockResolvedValueOnce([]) // limbo sweep .mockResolvedValueOnce([]) // stale tasks + .mockResolvedValueOnce([]) // workflow task sweep candidates .mockResolvedValueOnce(candidates as any); TestHelpers.setupRecommendations([]); @@ -1124,6 +1132,9 @@ describe("executeTaskCoordinatorRuns", () => { .mockResolvedValueOnce([]) // orphan sweep .mockResolvedValueOnce([]) // limbo sweep .mockResolvedValueOnce([]) // stale tasks + // workspaces run in parallel; both workflow sweeps happen before either ticket sweep + .mockResolvedValueOnce([]) // ws-1 workflow sweep candidates (no workflow tasks) + .mockResolvedValueOnce([]) // ws-2 workflow sweep candidates (no workflow tasks) .mockResolvedValueOnce(ws1Candidates as any) // ws-1 ticket sweep .mockResolvedValueOnce(ws2Candidates as any); // ws-2 ticket sweep @@ -1146,6 +1157,7 @@ describe("executeTaskCoordinatorRuns", () => { .mockResolvedValueOnce([]) // orphan sweep .mockResolvedValueOnce([]) // limbo sweep .mockResolvedValueOnce([]) // stale tasks + .mockResolvedValueOnce([]) // workflow task sweep candidates .mockResolvedValueOnce(candidates as any); TestHelpers.setupRecommendations([]); @@ -1169,6 +1181,7 @@ describe("executeTaskCoordinatorRuns", () => { .mockResolvedValueOnce([]) // orphan sweep .mockResolvedValueOnce([]) // limbo sweep .mockResolvedValueOnce([]) // stale tasks + .mockResolvedValueOnce([]) // workflow task sweep candidates .mockResolvedValueOnce(candidates as any); const recommendation = JanitorTestDataFactory.createPendingRecommendation("HIGH"); @@ -1498,4 +1511,205 @@ describe("processTicketSweep", () => { expect(result).toBe(0); expect(mockStartTaskWorkflow).toHaveBeenCalledTimes(3); }); + + test("candidate query includes workflowTask: null filter to exclude workflow tasks", async () => { + vi.mocked(mockDb.task.findMany).mockResolvedValueOnce([] as any); + + await processTicketSweep("ws-1", "workspace-1", 5); + + expect(mockDb.task.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + AND: expect.arrayContaining([ + { workflowTask: null }, + ]), + }), + }) + ); + }); +}); + +// --- Workflow task factory --- +function createWorkflowCandidateTask(overrides: Record = {}) { + return { + id: `wf-task-${Math.random().toString(36).slice(2, 8)}`, + description: "Test workflow task description", + createdById: "user-1", + dependsOnTaskIds: [], + workflowTask: { + id: `wt-${Math.random().toString(36).slice(2, 8)}`, + taskId: `wf-task-${Math.random().toString(36).slice(2, 8)}`, + workflowId: 42, + workflowName: "Test Workflow", + workflowRefId: "ref-42", + workflowVersionId: null, + }, + feature: null, + ...overrides, + }; +} + +describe("processWorkflowTaskSweep", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(mockDb.task.findMany).mockResolvedValue([]); + vi.mocked(mockDb.task.update).mockResolvedValue({} as any); + vi.mocked(mockTriggerWorkflowEditorRun).mockResolvedValue(undefined); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + test("SATISFIED dependency → triggerWorkflowEditorRun called, returns 1", async () => { + const task = createWorkflowCandidateTask(); + vi.mocked(mockDb.task.findMany).mockResolvedValueOnce([task] as any); + + const result = await processWorkflowTaskSweep("ws-1", "workspace-1"); + + expect(result).toBe(1); + expect(mockTriggerWorkflowEditorRun).toHaveBeenCalledWith({ + taskId: task.id, + workflowTask: task.workflowTask, + message: task.description, + userId: task.createdById, + }); + expect(mockStartTaskWorkflow).not.toHaveBeenCalled(); + }); + + test("PENDING dependency → task skipped, triggerWorkflowEditorRun NOT called", async () => { + const blockerId = "blocker-task-id"; + const task = createWorkflowCandidateTask({ dependsOnTaskIds: [blockerId] }); + vi.mocked(mockDb.task.findMany) + .mockResolvedValueOnce([task] as any) // candidates + .mockResolvedValue([] as any); // dep fetch returns 0 → length mismatch → PENDING + + const result = await processWorkflowTaskSweep("ws-1", "workspace-1"); + + expect(result).toBe(0); + expect(mockTriggerWorkflowEditorRun).not.toHaveBeenCalled(); + expect(mockDb.task.update).not.toHaveBeenCalled(); + }); + + test("PERMANENTLY_BLOCKED dependency → systemAssigneeType cleared, task NOT dispatched", async () => { + const blockerId = "cancelled-task-id"; + const task = createWorkflowCandidateTask({ dependsOnTaskIds: [blockerId] }); + + // dep fetch returns a CANCELLED task with no PR artifacts → PERMANENTLY_BLOCKED + vi.mocked(mockDb.task.findMany) + .mockResolvedValueOnce([task] as any) // candidates + .mockResolvedValueOnce([{ // dep task + id: blockerId, + status: "CANCELLED", + chatMessages: [], + }] as any); + + const result = await processWorkflowTaskSweep("ws-1", "workspace-1"); + + expect(result).toBe(0); + expect(mockTriggerWorkflowEditorRun).not.toHaveBeenCalled(); + expect(mockDb.task.update).toHaveBeenCalledWith({ + where: { id: task.id }, + data: { systemAssigneeType: null }, + }); + }); + + test("multiple tasks: SATISFIED dispatched, PENDING skipped, PERMANENTLY_BLOCKED cleared", async () => { + const blockerId = "blocker-id"; + const cancelledDepId = "cancelled-dep-id"; + + const satisfiedTask = createWorkflowCandidateTask({ id: "wf-satisfied" }); + const pendingTask = createWorkflowCandidateTask({ id: "wf-pending", dependsOnTaskIds: [blockerId] }); + const blockedTask = createWorkflowCandidateTask({ id: "wf-blocked", dependsOnTaskIds: [cancelledDepId] }); + + vi.mocked(mockDb.task.findMany) + .mockResolvedValueOnce([satisfiedTask, pendingTask, blockedTask] as any) // candidates + .mockResolvedValueOnce([] as any) // dep check for pendingTask → PENDING (length mismatch) + .mockResolvedValueOnce([{ id: cancelledDepId, status: "CANCELLED", chatMessages: [] }] as any); // dep check for blockedTask + + const result = await processWorkflowTaskSweep("ws-1", "workspace-1"); + + expect(result).toBe(1); + expect(mockTriggerWorkflowEditorRun).toHaveBeenCalledTimes(1); + expect(mockTriggerWorkflowEditorRun).toHaveBeenCalledWith( + expect.objectContaining({ taskId: satisfiedTask.id }) + ); + expect(mockDb.task.update).toHaveBeenCalledWith({ + where: { id: blockedTask.id }, + data: { systemAssigneeType: null }, + }); + }); + + test("no candidates → returns 0, no dispatch calls", async () => { + vi.mocked(mockDb.task.findMany).mockResolvedValueOnce([] as any); + + const result = await processWorkflowTaskSweep("ws-1", "workspace-1"); + + expect(result).toBe(0); + expect(mockTriggerWorkflowEditorRun).not.toHaveBeenCalled(); + }); + + test("uses description as message; falls back to feature.createdById when task.createdById is null", async () => { + const task = createWorkflowCandidateTask({ + createdById: null, + description: "My workflow description", + feature: { createdById: "feature-owner" }, + }); + vi.mocked(mockDb.task.findMany).mockResolvedValueOnce([task] as any); + + await processWorkflowTaskSweep("ws-1", "workspace-1"); + + expect(mockTriggerWorkflowEditorRun).toHaveBeenCalledWith( + expect.objectContaining({ + message: "My workflow description", + userId: "feature-owner", + }) + ); + }); + + test("falls back to default message when description is null", async () => { + const task = createWorkflowCandidateTask({ description: null }); + vi.mocked(mockDb.task.findMany).mockResolvedValueOnce([task] as any); + + await processWorkflowTaskSweep("ws-1", "workspace-1"); + + expect(mockTriggerWorkflowEditorRun).toHaveBeenCalledWith( + expect.objectContaining({ + message: "Start working on this workflow task.", + }) + ); + }); + + test("dispatch error does not abort sweep — other tasks still processed", async () => { + const task1 = createWorkflowCandidateTask({ id: "wf-ok-1" }); + const task2 = createWorkflowCandidateTask({ id: "wf-fail" }); + const task3 = createWorkflowCandidateTask({ id: "wf-ok-2" }); + vi.mocked(mockDb.task.findMany).mockResolvedValueOnce([task1, task2, task3] as any); + + vi.mocked(mockTriggerWorkflowEditorRun) + .mockResolvedValueOnce(undefined) + .mockRejectedValueOnce(new Error("Stakwork error")) + .mockResolvedValueOnce(undefined); + + const result = await processWorkflowTaskSweep("ws-1", "workspace-1"); + + expect(result).toBe(2); + expect(mockTriggerWorkflowEditorRun).toHaveBeenCalledTimes(3); + }); + + test("candidate query includes workflowTask: { isNot: null } filter", async () => { + vi.mocked(mockDb.task.findMany).mockResolvedValueOnce([] as any); + + await processWorkflowTaskSweep("ws-1", "workspace-1"); + + expect(mockDb.task.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + AND: expect.arrayContaining([ + { workflowTask: { isNot: null } }, + ]), + }), + }) + ); + }); }); diff --git a/src/app/api/features/[featureId]/tasks/assign-all/route.ts b/src/app/api/features/[featureId]/tasks/assign-all/route.ts index a32363a1ec..9b97627e9d 100644 --- a/src/app/api/features/[featureId]/tasks/assign-all/route.ts +++ b/src/app/api/features/[featureId]/tasks/assign-all/route.ts @@ -6,8 +6,7 @@ import { notifyFeatureCanvasRefresh } from "@/lib/canvas"; import { db } from "@/lib/db"; import { SystemAssigneeType } from "@prisma/client"; import { getPoolStatusFromPods } from "@/lib/pods/status-queries"; -import { processTicketSweep } from "@/services/task-coordinator-cron"; -import { triggerWorkflowEditorRun } from "@/services/workflow-editor"; +import { processTicketSweep, processWorkflowTaskSweep } from "@/services/task-coordinator-cron"; interface AssignAllResponse { success: boolean; @@ -77,9 +76,9 @@ export async function POST( ); } - // Step 5: Query all unassigned TODO tasks in first phase (include workflowTask relation) + // Step 5: Count all unassigned TODO tasks in first phase // Only assign tasks that are in TODO status (not IN_PROGRESS or DONE) - const unassignedTasks = await db.task.findMany({ + const unassignedTaskIds = await db.task.findMany({ where: { phaseId: firstPhase.id, assigneeId: null, @@ -87,15 +86,11 @@ export async function POST( deleted: false, status: "TODO", }, - select: { - id: true, - description: true, - workflowTask: true, - }, + select: { id: true }, }); // Step 6: If no unassigned tasks, return early - if (unassignedTasks.length === 0) { + if (unassignedTaskIds.length === 0) { return NextResponse.json( { success: true, @@ -105,56 +100,35 @@ export async function POST( ); } - // Partition into repo tasks and workflow tasks - const repoTasks = unassignedTasks.filter((t) => t.workflowTask === null); - const workflowTasks = unassignedTasks.filter((t) => t.workflowTask !== null); - - // Step 7a: Bulk update repo tasks to assign to Task Coordinator (unchanged behaviour) - let assignedCount = 0; - if (repoTasks.length > 0) { - const result = await db.task.updateMany({ - where: { - id: { in: repoTasks.map((task) => task.id) }, - }, - data: { - assigneeId: null, // Clear regular assignee - systemAssigneeType: SystemAssigneeType.TASK_COORDINATOR, - }, - }); - assignedCount += result.count; - } - - // Step 7b: Immediately trigger workflow-editor runs for workflow tasks (bypass Task Coordinator) - for (const task of workflowTasks) { - const wt = task.workflowTask!; - console.log( - `[assign-all] Bypassing Task Coordinator for workflow task ${task.id} targeting workflow ${wt.workflowId}` - ); - try { - await triggerWorkflowEditorRun({ - taskId: task.id, - workflowTask: wt, - message: task.description || "Start working on this workflow task.", - userId: userOrResponse.id, - }); - assignedCount += 1; - } catch (err) { - console.error(`[assign-all] Failed to trigger workflow-editor run for task ${task.id}:`, err); - } - } + // Step 7: Bulk assign ALL unassigned TODO tasks (repo + workflow) to Task Coordinator + const updateResult = await db.task.updateMany({ + where: { + id: { in: unassignedTaskIds.map((t) => t.id) }, + }, + data: { + assigneeId: null, + systemAssigneeType: SystemAssigneeType.TASK_COORDINATOR, + }, + }); + const assignedCount = updateResult.count; - // Step 8: Eagerly start the highest-priority eligible repo task if a machine is available + // Step 8: Eagerly trigger sweeps const ws = feature.workspace; const swarm = ws?.swarm; - if (ws && swarm?.id && repoTasks.length > 0) { - try { - const poolStatus = await getPoolStatusFromPods(swarm.id, ws.id); - - if (poolStatus.unusedVms > 1) { - processTicketSweep(ws.id, ws.slug, poolStatus.unusedVms - 1).catch(() => {}); + if (ws) { + // Workflow sweep always runs unconditionally (no pod needed) + processWorkflowTaskSweep(ws.id, ws.slug).catch(() => {}); + + // Pod-gated repo sweep only when machines are available + if (swarm?.id) { + try { + const poolStatus = await getPoolStatusFromPods(swarm.id, ws.id); + if (poolStatus.unusedVms > 1) { + processTicketSweep(ws.id, ws.slug, poolStatus.unusedVms - 1).catch(() => {}); + } + } catch { + // Pool query failed — skip eager start } - } catch { - // Pool query failed — skip eager start } } diff --git a/src/app/api/tasks/[taskId]/route.ts b/src/app/api/tasks/[taskId]/route.ts index 8c92a79dc5..34781a120f 100644 --- a/src/app/api/tasks/[taskId]/route.ts +++ b/src/app/api/tasks/[taskId]/route.ts @@ -3,6 +3,7 @@ import { getMiddlewareContext, requireAuth } from "@/lib/middleware/utils"; import { db } from "@/lib/db"; import { startTaskWorkflow } from "@/services/task-workflow"; import { executeWorkflowEditorRetry } from "@/services/workflow-editor-retry"; +import { triggerWorkflowEditorRun } from "@/services/workflow-editor"; import { TaskStatus, WorkflowStatus } from "@prisma/client"; import { sanitizeTask } from "@/lib/helpers/tasks"; import { pusherServer, getWorkspaceChannelName, getTaskChannelName, PUSHER_EVENTS } from "@/lib/pusher"; @@ -29,6 +30,7 @@ export async function PATCH( deleted: false, }, include: { + workflowTask: true, workspace: { select: { id: true, @@ -64,11 +66,22 @@ export async function PATCH( // Start workflow if requested if (startWorkflow) { - const workflowResult = await startTaskWorkflow({ - taskId, - userId: userOrResponse.id, - mode: mode || "live", - }); + if (task.workflowTask) { + // Workflow task: route through workflow-editor flow (not pod-based Stakwork) + await triggerWorkflowEditorRun({ + taskId, + workflowTask: task.workflowTask, + message: task.description ?? task.title, + userId: userOrResponse.id, + }); + } else { + // Repo/coding task: standard Stakwork workflow + await startTaskWorkflow({ + taskId, + userId: userOrResponse.id, + mode: mode || "live", + }); + } // Fetch updated task with workflow status const updatedTask = await db.task.findUnique({ @@ -89,7 +102,6 @@ export async function PATCH( { success: true, task: updatedTask, - workflow: workflowResult?.stakworkData, }, { status: 200 } ); diff --git a/src/services/task-coordinator-cron.ts b/src/services/task-coordinator-cron.ts index 87fe09b619..882a7bb79d 100644 --- a/src/services/task-coordinator-cron.ts +++ b/src/services/task-coordinator-cron.ts @@ -1,6 +1,7 @@ import { db } from "@/lib/db"; import { Prisma, WorkflowStatus } from "@prisma/client"; import { startTaskWorkflow } from "@/services/task-workflow"; +import { triggerWorkflowEditorRun } from "@/services/workflow-editor"; import { releaseTaskPod } from "@/lib/pods"; import { updateTaskWorkflowStatus } from "@/lib/helpers/workflow-status"; import { getPoolStatusFromPods } from "@/lib/pods/status-queries"; @@ -265,6 +266,100 @@ export async function processTicketSweep( return dispatched; } +/** + * Process workflow task sweep — dispatch eligible TASK_COORDINATOR workflow tasks + * independently of pod availability. Dependencies are enforced via the tri-state check. + * No slot limit: all eligible workflow tasks are dispatched in a single run. + */ +export async function processWorkflowTaskSweep( + workspaceId: string, + workspaceSlug: string +): Promise { + console.log(`[TaskCoordinator][Workflow] Processing workflow task sweep for workspace ${workspaceSlug}`); + + const candidateTasks = await db.task.findMany({ + where: { + AND: [ + { workspaceId }, + { status: "TODO" }, + { systemAssigneeType: "TASK_COORDINATOR" }, + { deleted: false }, + { OR: [{ workflowStatus: WorkflowStatus.PENDING }, { workflowStatus: null }] }, + { stakworkProjectId: null }, + { workflowTask: { isNot: null } }, // Workflow tasks only + { OR: [{ featureId: null }, { feature: { status: { not: "CANCELLED" } } }] }, + ], + }, + select: { + id: true, + description: true, + createdById: true, + dependsOnTaskIds: true, + workflowTask: true, + feature: { + select: { createdById: true }, + }, + }, + orderBy: [ + { priority: "desc" }, + { createdAt: "asc" }, + ], + }); + + if (candidateTasks.length === 0) { + console.log(`[TaskCoordinator][Workflow] No candidate workflow tasks found for workspace ${workspaceSlug}`); + return 0; + } + + console.log(`[TaskCoordinator][Workflow] Found ${candidateTasks.length} candidate workflow tasks, checking dependencies...`); + + let dispatched = 0; + + for (const task of candidateTasks) { + const depResult = await checkDependencies(task.dependsOnTaskIds); + + if (depResult === "PERMANENTLY_BLOCKED") { + console.log(`[TaskCoordinator][Workflow] Unassigning task ${task.id} - dependency permanently blocked`); + await db.task.update({ + where: { id: task.id }, + data: { systemAssigneeType: null }, + }); + continue; + } + + if (depResult === "PENDING") { + console.log(`[TaskCoordinator][Workflow] Skipping task ${task.id} - dependencies not satisfied`); + continue; + } + + // SATISFIED — dispatch via workflow-editor run + const userId = task.createdById ?? task.feature?.createdById; + if (!userId) { + console.warn(`[TaskCoordinator][Workflow] Skipping task ${task.id} - no userId available`); + continue; + } + + try { + await triggerWorkflowEditorRun({ + taskId: task.id, + workflowTask: task.workflowTask!, + message: task.description ?? "Start working on this workflow task.", + userId, + }); + dispatched++; + console.log(`[TaskCoordinator][Workflow] Successfully dispatched workflow task ${task.id}`); + } catch (error) { + console.error(`[TaskCoordinator][Workflow] Error dispatching workflow task ${task.id}:`, error); + } + } + + if (dispatched === 0) { + console.log(`[TaskCoordinator][Workflow] No workflow tasks with satisfied dependencies found for workspace ${workspaceSlug}`); + } + + return dispatched; +} + /** * Halt a specific task by updating its workflow status to HALTED * Can be called by cron job or manually @@ -535,6 +630,19 @@ async function processWorkspace(workspace: EnabledWorkspace): Promise Date: Thu, 14 May 2026 11:46:05 +0000 Subject: [PATCH 09/10] Generated with Hive: Restore workflowTask null filter for candidateTasksWhere --- src/services/task-coordinator-cron.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/services/task-coordinator-cron.ts b/src/services/task-coordinator-cron.ts index 882a7bb79d..dc87bae25b 100644 --- a/src/services/task-coordinator-cron.ts +++ b/src/services/task-coordinator-cron.ts @@ -36,6 +36,7 @@ export function candidateTasksWhere(workspaceId: string) { { deleted: false }, { OR: [{ workflowStatus: WorkflowStatus.PENDING }, { workflowStatus: null }] }, { stakworkProjectId: null }, + { workflowTask: null }, // Repo/coding tasks only — workflow tasks handled by processWorkflowTaskSweep { OR: [{ featureId: null }, { feature: { status: { not: "CANCELLED" as const } } }] }, ], }; From df29a2fd7437d614356cba0ba7e095829f52d6a5 Mon Sep 17 00:00:00 2001 From: gonzaloaune Date: Sat, 16 May 2026 01:50:25 +0000 Subject: [PATCH 10/10] Generated with Hive: Fix deployment URL extraction in PR deploy workflow for Vercel outputs --- .github/workflows/deployPR.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/deployPR.yml b/.github/workflows/deployPR.yml index 5afde46212..f3a2c57824 100644 --- a/.github/workflows/deployPR.yml +++ b/.github/workflows/deployPR.yml @@ -389,7 +389,7 @@ jobs: --env "PREVIEW_LINK_BRANCH=${{ github.event.pull_request.head.ref }}" \ --yes 2>&1); then # Extract the Preview URL from the output - DEPLOYMENT_URL=$(echo "$DEPLOY_OUTPUT" | grep -oP 'Preview: \Khttps://[^\s]+' | head -1) + DEPLOYMENT_URL=$(echo "$DEPLOY_OUTPUT" | grep -oP 'Preview[:\s]+\Khttps://[^\s]+' | head -1) if [ -z "$DEPLOYMENT_URL" ]; then echo "Failed to extract deployment URL from output:" echo "$DEPLOY_OUTPUT"