Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
1dec057
Add read-only task coordinator summary prototype at /prototype/task-c…
undefined May 12, 2026
b971c5c
Generated with Hive: Build task coordinator snapshot API, connect adm…
gonzaloaune May 12, 2026
f34cb36
Merge remote-tracking branch 'origin/master' into feat/task-coordinat…
gonzaloaune May 12, 2026
4589b5b
Generated with Hive: Fix super admin mock signIn tests by adding role
gonzaloaune May 12, 2026
dffd4c6
Generated with Hive: Extract shared coordinator DB queries to ensure …
gonzaloaune May 12, 2026
98f770f
Merge branch 'master' into feat/task-coordinator-summary-view
tomsmith8 May 12, 2026
c602b20
Generated with Hive: Add copy cut paste support for authored canvas e…
pitoi May 12, 2026
5a751b8
Merge remote-tracking branch 'origin/master' into feat/task-coordinat…
gonzaloaune May 12, 2026
45e7c3c
Merge branch 'master' into feat/task-coordinator-summary-view
tomsmith8 May 12, 2026
193ff14
Merge branch 'master' into feat/task-coordinator-summary-view
tomsmith8 May 12, 2026
edb8e63
Merge branch 'master' into feat/task-coordinator-summary-view
tomsmith8 May 13, 2026
e41e37c
Generated with Hive: Set task status to IN_PROGRESS on workflow edito…
tomsmith8 May 13, 2026
fb410ad
Generated with Hive: Add Mark Complete action for workflow tasks
tomsmith8 May 13, 2026
e4c3255
Generated with Hive: Fix workflow task dependency enforcement in assi…
tomsmith8 May 13, 2026
250fe00
Merge remote-tracking branch 'origin/master' into feat/task-coordinat…
tomsmith8 May 14, 2026
9b4ae95
Generated with Hive: Restore workflowTask null filter for candidateTa…
gonzaloaune May 14, 2026
a9c490c
Merge remote-tracking branch 'origin/master' into feat/task-coordinat…
gonzaloaune May 14, 2026
3e54319
Merge branch 'master' into feat/task-coordinator-summary-view
tomsmith8 May 14, 2026
85b9ace
Merge branch 'master' into feat/task-coordinator-summary-view
tomsmith8 May 15, 2026
6340528
Merge branch 'master' into feat/task-coordinator-summary-view
tomsmith8 May 15, 2026
9cd8d36
Merge branch 'master' into feat/task-coordinator-summary-view
tomsmith8 May 15, 2026
5ce6c1c
Merge branch 'master' into feat/task-coordinator-summary-view
tomsmith8 May 15, 2026
96fe38f
Merge branch 'master' into feat/task-coordinator-summary-view
tomsmith8 May 16, 2026
df29a2f
Generated with Hive: Fix deployment URL extraction in PR deploy workf…
gonzaloaune May 16, 2026
01de7cd
Merge branch 'master' into feat/task-coordinator-summary-view
tomsmith8 May 16, 2026
7e3fc1f
Merge branch 'master' into feat/task-coordinator-summary-view
tomsmith8 May 16, 2026
04358b4
Merge branch 'master' into feat/task-coordinator-summary-view
tomsmith8 May 18, 2026
d459386
Merge branch 'master' into feat/task-coordinator-summary-view
tomsmith8 May 18, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/deployPR.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
195 changes: 195 additions & 0 deletions src/__tests__/integration/api/admin/task-coordinator-snapshot.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
177 changes: 177 additions & 0 deletions src/__tests__/unit/api/admin/task-coordinator-snapshot.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof vi.fn>;

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");
});
});
Loading
Loading