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" 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/__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); 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: TaskAction) { + if (a === "DISPATCH") + return ( + + + Dispatch + + ); + if (a === "SKIP_PENDING") + return ( + + + Skip (deps) + + ); + return ( + + + Unassign + + ); +} + +function PodBar({ ws }: { ws: WorkspaceSnapshot }) { + const total = ws.totalPods || 1; + return ( +
+
+
+
+
+
+ ); +} + +// ─── Variation A: Dashboard Cards ───────────────────────────────────────────── + +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 + ); + + 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.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; + + 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 + +
+ )} +
+ )} + + {isExpanded && ws.candidateTasks.length === 0 && !ws.processingNote && ( + + +

No candidate tasks in queue.

+ {ws.pendingRecommendations > 0 && ( +
+ + + {ws.pendingRecommendations} pending recommendation + {ws.pendingRecommendations > 1 ? "s" : ""} available for fallback + +
+ )} +
+ )} +
+ ); + })} +
+
+ ); +} + +// ─── 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 — Live Snapshot

+ + + Read-only + +
+

+ Snapshot of what the task coordinator would see and do{" "} + right now — no changes are made. +

+
+ +
+ {snapshot && ( + + + {new Date(snapshot.timestamp).toLocaleString()} + + )} + +
+
+ + {/* Loading state */} + {isLoading && ( +
+ +
+ )} + + {/* Error state */} + {!isLoading && error && ( +
+ + {error} +
+ )} + + {/* 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..68501815fe --- /dev/null +++ b/src/app/api/admin/task-coordinator/snapshot/route.ts @@ -0,0 +1,264 @@ +import { NextRequest, NextResponse } from "next/server"; +import { requireSuperAdmin } from "@/lib/auth/require-superadmin"; +import { db } from "@/lib/db"; +import { getPoolStatusFromPods } from "@/lib/pods/status-queries"; +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"; +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: ENABLED_WORKSPACE_WHERE, + 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: pendingRecommendationsWhere(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: candidateTasksWhere(ws.id), + 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: pendingRecommendationsWhere(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 }); + } +} 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 a340575d4a..7772770b73 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. @@ -131,6 +131,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"; @@ -2842,10 +2848,12 @@ export function OrgCanvasBackground({ zoomNavigation onResolveCanvas={onResolveCanvas} onBreadcrumbsChange={handleBreadcrumbsChange} - onSelectionChange={handleSelectionChange} + {...({ + onSelectionChange: handleSelectionChange, + onNodesUpdate: handleNodesUpdate, + } as Record)} 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/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 diff --git a/src/services/task-coordinator-cron.ts b/src/services/task-coordinator-cron.ts index e884679329..dc87bae25b 100644 --- a/src/services/task-coordinator-cron.ts +++ b/src/services/task-coordinator-cron.ts @@ -10,6 +10,49 @@ 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 }, + { workflowTask: null }, // Repo/coding tasks only — workflow tasks handled by processWorkflowTaskSweep + { 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; @@ -137,18 +180,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 }, - { workflowTask: null }, // Repo/coding tasks only — workflow tasks handled by processWorkflowTaskSweep - { OR: [{ featureId: null }, { feature: { status: { not: "CANCELLED" } } }] }, - ], - }, + where: candidateTasksWhere(workspaceId), select: { id: true, title: true, @@ -749,15 +781,7 @@ export async function executeTaskCoordinatorRuns(): Promise