From 176e76a87dfdacf6f9fa7f0f234324f3ad2fb3f9 Mon Sep 17 00:00:00 2001 From: tomsmith8 Date: Thu, 25 Jun 2026 12:28:39 +0000 Subject: [PATCH 1/5] Generated with Hive: Implement daily-recap cron pipeline with batch Stakwork client, agent tool, and seed data --- scripts/helpers/seed-database.ts | 35 +++ .../integration/api/cron/daily-recap.test.ts | 156 ++++++++++ .../unit/lib/ai/get-daily-recap-tool.test.ts | 130 ++++++++ .../unit/services/daily-recap-cron.test.ts | 281 ++++++++++++++++++ src/app/api/cron/daily-recap/route.ts | 63 ++++ src/lib/ai/initiativeTools.ts | 26 ++ src/services/daily-recap-cron.ts | 241 +++++++++++++++ src/services/stakwork-run.ts | 12 + src/services/stakwork/index.ts | 47 +++ vercel.json | 4 + 10 files changed, 995 insertions(+) create mode 100644 src/__tests__/integration/api/cron/daily-recap.test.ts create mode 100644 src/__tests__/unit/lib/ai/get-daily-recap-tool.test.ts create mode 100644 src/__tests__/unit/services/daily-recap-cron.test.ts create mode 100644 src/app/api/cron/daily-recap/route.ts create mode 100644 src/services/daily-recap-cron.ts diff --git a/scripts/helpers/seed-database.ts b/scripts/helpers/seed-database.ts index c4ee821210..be25acf4a2 100644 --- a/scripts/helpers/seed-database.ts +++ b/scripts/helpers/seed-database.ts @@ -1748,6 +1748,40 @@ async function seedDeferredChatAction( console.log("✓ Seeded 1 DeferredChatAction (fireAt = now + 2min)"); } +async function seedDailyRecapRun( + users: Array<{ id: string; email: string }>, +) { + const workspaces = await prisma.workspace.findMany({ take: 1 }); + if (workspaces.length === 0 || users.length === 0) { + console.log("Skipping daily recap seed: no workspaces or users"); + return; + } + + const existing = await prisma.stakworkRun.findFirst({ + where: { userId: users[0].id, type: StakworkRunType.DAILY_RECAP }, + }); + + if (existing) { + console.log("✓ Daily recap seed run already exists — skipping"); + return; + } + + await prisma.stakworkRun.create({ + data: { + type: StakworkRunType.DAILY_RECAP, + userId: users[0].id, + workspaceId: workspaces[0].id, + status: WorkflowStatus.COMPLETED, + webhookUrl: "https://example.com/webhook/daily-recap/seed", + result: "You merged 2 PRs and created 3 tasks yesterday — solid progress on the auth refactor.", + dataType: "string", + autoAccept: false, + }, + }); + + console.log("✓ Seeded 1 DAILY_RECAP StakworkRun"); +} + async function main() { await prisma.$connect(); @@ -1765,6 +1799,7 @@ async function main() { await seedWorkflowTask(); await seedStakworkSecrets(); await seedDeferredChatAction(users); + await seedDailyRecapRun(users); console.log("Seed completed."); } diff --git a/src/__tests__/integration/api/cron/daily-recap.test.ts b/src/__tests__/integration/api/cron/daily-recap.test.ts new file mode 100644 index 0000000000..c312575ec9 --- /dev/null +++ b/src/__tests__/integration/api/cron/daily-recap.test.ts @@ -0,0 +1,156 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { NextRequest } from "next/server"; + +/** + * Integration tests for GET /api/cron/daily-recap endpoint + * + * Tests verify: + * - Authentication via CRON_SECRET (401 when missing/invalid) + * - Feature flag gating (DAILY_RECAP_CRON_ENABLED) + * - Response shape when cron is disabled + * - Response shape when cron is enabled and runs successfully + */ + +// ── Mocks ──────────────────────────────────────────────────────────────────── + +const { mockExecuteScheduledDailyRecapRuns } = vi.hoisted(() => ({ + mockExecuteScheduledDailyRecapRuns: vi.fn(), +})); + +vi.mock("@/services/daily-recap-cron", () => ({ + executeScheduledDailyRecapRuns: mockExecuteScheduledDailyRecapRuns, +})); + +import { GET } from "@/app/api/cron/daily-recap/route"; + +// ── Helpers ────────────────────────────────────────────────────────────────── + +function createRequest(authHeader?: string): NextRequest { + const headers = new Headers(); + if (authHeader) headers.set("authorization", authHeader); + return new NextRequest("http://localhost:3000/api/cron/daily-recap", { headers }); +} + +function createAuthenticatedRequest(): NextRequest { + return createRequest("Bearer test-cron-secret"); +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe("GET /api/cron/daily-recap", () => { + let originalCronSecret: string | undefined; + let originalEnabled: string | undefined; + + beforeEach(() => { + originalCronSecret = process.env.CRON_SECRET; + originalEnabled = process.env.DAILY_RECAP_CRON_ENABLED; + + process.env.CRON_SECRET = "test-cron-secret"; + + vi.clearAllMocks(); + }); + + afterEach(() => { + process.env.CRON_SECRET = originalCronSecret; + process.env.DAILY_RECAP_CRON_ENABLED = originalEnabled; + }); + + // ── Auth ────────────────────────────────────────────────────────────────── + + it("returns 401 when Authorization header is missing", async () => { + const res = await GET(createRequest()); + expect(res.status).toBe(401); + const body = await res.json(); + expect(body.error).toBe("Unauthorized"); + }); + + it("returns 401 when Authorization header has wrong secret", async () => { + const res = await GET(createRequest("Bearer wrong-secret")); + expect(res.status).toBe(401); + const body = await res.json(); + expect(body.error).toBe("Unauthorized"); + }); + + // ── Disabled flag ───────────────────────────────────────────────────────── + + it("returns 200 with disabled message when DAILY_RECAP_CRON_ENABLED is not 'true'", async () => { + process.env.DAILY_RECAP_CRON_ENABLED = "false"; + + const res = await GET(createAuthenticatedRequest()); + expect(res.status).toBe(200); + + const body = await res.json(); + expect(body.success).toBe(true); + expect(body.message).toMatch(/disabled/i); + expect(body.usersProcessed).toBe(0); + expect(body.dispatched).toBe(0); + expect(mockExecuteScheduledDailyRecapRuns).not.toHaveBeenCalled(); + }); + + it("returns 200 with disabled message when DAILY_RECAP_CRON_ENABLED is unset", async () => { + delete process.env.DAILY_RECAP_CRON_ENABLED; + + const res = await GET(createAuthenticatedRequest()); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.success).toBe(true); + expect(mockExecuteScheduledDailyRecapRuns).not.toHaveBeenCalled(); + }); + + // ── Enabled ─────────────────────────────────────────────────────────────── + + it("calls executeScheduledDailyRecapRuns and returns summary JSON when enabled", async () => { + process.env.DAILY_RECAP_CRON_ENABLED = "true"; + mockExecuteScheduledDailyRecapRuns.mockResolvedValue({ + usersProcessed: 5, + dispatched: 4, + skipped: 1, + errors: [], + }); + + const res = await GET(createAuthenticatedRequest()); + expect(res.status).toBe(200); + + const body = await res.json(); + expect(body).toMatchObject({ + success: true, + usersProcessed: 5, + dispatched: 4, + skipped: 1, + errorCount: 0, + errors: [], + }); + expect(typeof body.timestamp).toBe("string"); + expect(mockExecuteScheduledDailyRecapRuns).toHaveBeenCalledOnce(); + }); + + it("returns success=false when errors are present", async () => { + process.env.DAILY_RECAP_CRON_ENABLED = "true"; + mockExecuteScheduledDailyRecapRuns.mockResolvedValue({ + usersProcessed: 3, + dispatched: 2, + skipped: 0, + errors: [{ userId: "user-1", error: "network failure" }], + }); + + const res = await GET(createAuthenticatedRequest()); + const body = await res.json(); + + expect(body.success).toBe(false); + expect(body.errorCount).toBe(1); + expect(body.errors).toHaveLength(1); + expect(body.errors[0]).toMatchObject({ userId: "user-1", error: "network failure" }); + }); + + it("returns 500 when executeScheduledDailyRecapRuns throws", async () => { + process.env.DAILY_RECAP_CRON_ENABLED = "true"; + mockExecuteScheduledDailyRecapRuns.mockRejectedValue(new Error("unexpected crash")); + + const res = await GET(createAuthenticatedRequest()); + expect(res.status).toBe(500); + + const body = await res.json(); + expect(body.success).toBe(false); + expect(body.error).toBe("Internal server error"); + }); +}); diff --git a/src/__tests__/unit/lib/ai/get-daily-recap-tool.test.ts b/src/__tests__/unit/lib/ai/get-daily-recap-tool.test.ts new file mode 100644 index 0000000000..a039eb7435 --- /dev/null +++ b/src/__tests__/unit/lib/ai/get-daily-recap-tool.test.ts @@ -0,0 +1,130 @@ +/** + * Unit tests for the `get_daily_recap` tool inside `buildInitiativeTools`. + * + * Verifies: + * - Returns `{ recap: null }` when no completed DAILY_RECAP run exists + * - Returns `{ recap, generatedAt }` when a completed run exists + * - Returns `{ error }` when the DB query throws + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { StakworkRunType, WorkflowStatus } from "@prisma/client"; + +// ── Mocks ──────────────────────────────────────────────────────────────────── + +vi.mock("@/lib/db", () => ({ + db: { + initiative: { findFirst: vi.fn() }, + milestone: { findFirst: vi.fn() }, + workspace: { findFirst: vi.fn() }, + feature: { findUnique: vi.fn(), findMany: vi.fn() }, + user: { findUnique: vi.fn() }, + stakworkRun: { findFirst: vi.fn() }, + }, +})); + +vi.mock("@/services/roadmap", () => ({ updateFeature: vi.fn() })); +vi.mock("@/lib/canvas", () => ({ + notifyFeatureReassignmentRefresh: vi.fn(), + notifyFeatureAssignmentRefreshByOrg: vi.fn(), + assignFeatureOnCanvas: vi.fn(), + unassignFeatureOnCanvas: vi.fn(), +})); +vi.mock("@/services/orgs/nodeDetail", () => ({ loadNodeDetail: vi.fn() })); +vi.mock("@/services/roadmap/feature-chat", () => ({ sendFeatureChatMessage: vi.fn() })); +vi.mock("@/services/roadmap/user-activity", () => ({ getUserActivityFeed: vi.fn() })); + +import { db } from "@/lib/db"; +import { buildInitiativeTools } from "@/lib/ai/initiativeTools"; + +const ORG_ID = "org-1"; +const USER_ID = "user-1"; + +function getTools() { + (db.user.findUnique as ReturnType).mockResolvedValue({ + canvasAutonomousTurns: false, + }); + return buildInitiativeTools(ORG_ID, USER_ID, undefined); +} + +async function callGetDailyRecap(tools: ReturnType) { + const t = tools.get_daily_recap; + if (!t?.execute) throw new Error("get_daily_recap tool missing execute function"); + return t.execute({} as Parameters>[0], { + toolCallId: "tc-test", + messages: [], + }); +} + +describe("get_daily_recap tool", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns { recap: null } when no completed DAILY_RECAP run exists", async () => { + vi.mocked(db.stakworkRun.findFirst).mockResolvedValue(null); + + const tools = getTools(); + const result = await callGetDailyRecap(tools); + + expect(result).toEqual({ recap: null }); + + expect(db.stakworkRun.findFirst).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + userId: USER_ID, + type: StakworkRunType.DAILY_RECAP, + status: WorkflowStatus.COMPLETED, + }), + }), + ); + }); + + it("returns { recap: null } when run exists but result is null", async () => { + vi.mocked(db.stakworkRun.findFirst).mockResolvedValue({ + result: null, + createdAt: new Date(), + } as any); + + const tools = getTools(); + const result = await callGetDailyRecap(tools); + + expect(result).toEqual({ recap: null }); + }); + + it("returns { recap, generatedAt } when a completed run with a result exists", async () => { + const createdAt = new Date("2026-01-15T09:00:00Z"); + vi.mocked(db.stakworkRun.findFirst).mockResolvedValue({ + result: "You merged 2 PRs and created 3 tasks yesterday — solid progress on the auth refactor.", + createdAt, + } as any); + + const tools = getTools(); + const result = await callGetDailyRecap(tools); + + expect(result).toEqual({ + recap: "You merged 2 PRs and created 3 tasks yesterday — solid progress on the auth refactor.", + generatedAt: createdAt, + }); + }); + + it("returns { error } when the DB query throws", async () => { + vi.mocked(db.stakworkRun.findFirst).mockRejectedValue(new Error("DB connection lost")); + + const tools = getTools(); + const result = await callGetDailyRecap(tools); + + expect(result).toEqual({ error: "Failed to load daily recap" }); + }); + + it("queries by the userId from the closure — not a caller-supplied value", async () => { + vi.mocked(db.stakworkRun.findFirst).mockResolvedValue(null); + + const tools = getTools(); + await callGetDailyRecap(tools); + + const call = vi.mocked(db.stakworkRun.findFirst).mock.calls[0][0] as { + where: { userId: string }; + }; + expect(call.where.userId).toBe(USER_ID); + }); +}); diff --git a/src/__tests__/unit/services/daily-recap-cron.test.ts b/src/__tests__/unit/services/daily-recap-cron.test.ts new file mode 100644 index 0000000000..a8681f292f --- /dev/null +++ b/src/__tests__/unit/services/daily-recap-cron.test.ts @@ -0,0 +1,281 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import fs from "fs"; +import path from "path"; +import { executeScheduledDailyRecapRuns } from "@/services/daily-recap-cron"; +import { db } from "@/lib/db"; +import { StakworkRunType, WorkflowStatus } from "@prisma/client"; + +// ── Mocks ──────────────────────────────────────────────────────────────────── + +vi.mock("@/lib/db"); + +const mockCreateBatchProjects = vi.fn(); +vi.mock("@/lib/service-factory", () => ({ + stakworkService: () => ({ + createBatchProjects: mockCreateBatchProjects, + }), +})); + +vi.mock("@/config/env", () => ({ + config: { + STAKWORK_DAILY_RECAP_WORKFLOW_ID: "42", + }, +})); + +vi.mock("@/lib/utils", () => ({ + getBaseUrl: () => "https://hive.example.com", +})); + +vi.mock("@/services/roadmap/user-activity", () => ({ + getUserActivityFeed: vi.fn(), +})); + +import { getUserActivityFeed } from "@/services/roadmap/user-activity"; + +const mockedDb = vi.mocked(db); +const mockedGetUserActivityFeed = vi.mocked(getUserActivityFeed); + +// ── Helpers ────────────────────────────────────────────────────────────────── + +const makeRun = (id: string) => ({ + id, + type: StakworkRunType.DAILY_RECAP, + status: WorkflowStatus.PENDING, + userId: `user-${id}`, + workspaceId: "ws-1", + webhookUrl: "", + projectId: null, + featureId: null, + taskId: null, + result: null, + dataType: "string", + feedback: null, + decision: null, + createdAt: new Date(), + updatedAt: new Date(), + autoAccept: false, + promptVersionId: null, + evalSetId: null, +}); + +const makeActivityItems = (n = 3) => + Array.from({ length: n }, (_, i) => ({ + id: `item-${i}`, + kind: "task" as const, + category: "task" as const, + action: "created" as const, + title: `Task ${i}`, + link: "/tasks/x", + workspaceName: "Test WS", + orgName: "Org", + timestamp: new Date().toISOString(), + completed: false, + })); + +function setupDb(overrides: Partial<{ + users: unknown[]; + ownedWorkspace: unknown; + membership: unknown; + lastRun: unknown; + stakworkRunCreate: unknown; + stakworkRunUpdate: unknown; +}> = {}) { + const { + users = [{ id: "user-1" }], + ownedWorkspace = { id: "ws-1" }, + membership = null, + lastRun = null, + stakworkRunCreate = makeRun("run-1"), + stakworkRunUpdate = makeRun("run-1"), + } = overrides; + + Object.assign(db, { + user: { + findMany: vi.fn().mockResolvedValue(users), + }, + workspace: { + findFirst: vi.fn().mockResolvedValue(ownedWorkspace), + }, + workspaceMember: { + findFirst: vi.fn().mockResolvedValue(membership), + }, + stakworkRun: { + findFirst: vi.fn().mockResolvedValue(lastRun), + create: vi.fn().mockResolvedValue(stakworkRunCreate), + update: vi.fn().mockResolvedValue(stakworkRunUpdate), + }, + }); +} + +// ── vercel.json ────────────────────────────────────────────────────────────── + +describe("Daily Recap Cron — vercel.json configuration", () => { + it("should have daily-recap cron job configured", () => { + const vercelPath = path.join(process.cwd(), "vercel.json"); + expect(fs.existsSync(vercelPath)).toBe(true); + + const vercelConfig = JSON.parse(fs.readFileSync(vercelPath, "utf8")); + expect(vercelConfig.crons).toBeDefined(); + + const dailyCron = vercelConfig.crons.find( + (c: { path: string }) => c.path === "/api/cron/daily-recap", + ); + expect(dailyCron).toBeDefined(); + expect(typeof dailyCron.schedule).toBe("string"); + + const parts = dailyCron.schedule.split(" "); + expect(parts).toHaveLength(5); + }); +}); + +// ── executeScheduledDailyRecapRuns ─────────────────────────────────────────── + +describe("executeScheduledDailyRecapRuns", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("skips users when no workspace can be attributed", async () => { + setupDb({ ownedWorkspace: null, membership: null }); + mockedGetUserActivityFeed.mockResolvedValue([]); + + const result = await executeScheduledDailyRecapRuns(); + + expect(result.skipped).toBe(1); + expect(result.dispatched).toBe(0); + expect(mockedDb.stakworkRun.create).not.toHaveBeenCalled(); + }); + + it("skips users with empty activity feed", async () => { + setupDb(); + mockedGetUserActivityFeed.mockResolvedValue([]); + + const result = await executeScheduledDailyRecapRuns(); + + expect(result.skipped).toBe(1); + expect(result.dispatched).toBe(0); + expect(mockedDb.stakworkRun.create).not.toHaveBeenCalled(); + }); + + it("creates a PENDING row and queues a batch entry for users with activity", async () => { + setupDb(); + mockedGetUserActivityFeed.mockResolvedValue(makeActivityItems(3)); + mockCreateBatchProjects.mockResolvedValue({ + data: { + ref_id: "ref-1", + projects: [{ name: "daily-recap-run-1", project_id: 999 }], + }, + }); + + const result = await executeScheduledDailyRecapRuns(); + + expect(mockedDb.stakworkRun.create).toHaveBeenCalledOnce(); + const createCall = vi.mocked(mockedDb.stakworkRun.create).mock.calls[0][0]; + expect(createCall.data).toMatchObject({ + type: StakworkRunType.DAILY_RECAP, + userId: "user-1", + workspaceId: "ws-1", + status: WorkflowStatus.PENDING, + dataType: "string", + autoAccept: false, + }); + + expect(mockCreateBatchProjects).toHaveBeenCalledOnce(); + + // Back-fill: sets projectId + IN_PROGRESS + const updateCalls = vi.mocked(mockedDb.stakworkRun.update).mock.calls; + const backFill = updateCalls.find((c) => c[0].data?.projectId === 999); + expect(backFill).toBeDefined(); + expect(backFill![0].data).toMatchObject({ + projectId: 999, + status: WorkflowStatus.IN_PROGRESS, + }); + + expect(result.dispatched).toBe(1); + expect(result.errors).toHaveLength(0); + }); + + it("splits >500 eligible users into exactly 2 batch calls", async () => { + const users = Array.from({ length: 501 }, (_, i) => ({ id: `user-${i}` })); + + setupDb({ users }); + // Each user gets a unique run id so back-fill works + users.forEach((_, i) => { + vi.mocked(mockedDb.stakworkRun.create).mockResolvedValueOnce(makeRun(`run-${i}`) as any); + }); + vi.mocked(mockedDb.stakworkRun.update).mockResolvedValue(makeRun("x") as any); + mockedGetUserActivityFeed.mockResolvedValue(makeActivityItems(1)); + + // Build per-batch responses + mockCreateBatchProjects.mockImplementation(async (payload: Array<{ name: string }>) => ({ + data: { + ref_id: "ref", + projects: payload.map((p) => ({ name: p.name, project_id: 1 })), + }, + })); + + const result = await executeScheduledDailyRecapRuns(); + + expect(mockCreateBatchProjects).toHaveBeenCalledTimes(2); + expect(result.usersProcessed).toBe(501); + expect(result.dispatched).toBe(501); + }); + + it("back-fills projectId and sets IN_PROGRESS on success", async () => { + setupDb(); + mockedGetUserActivityFeed.mockResolvedValue(makeActivityItems(2)); + mockCreateBatchProjects.mockResolvedValue({ + data: { + ref_id: "ref-1", + projects: [{ name: "daily-recap-run-1", project_id: 777 }], + }, + }); + + await executeScheduledDailyRecapRuns(); + + const updateCalls = vi.mocked(mockedDb.stakworkRun.update).mock.calls; + const backFillCall = updateCalls.find((c) => c[0].data?.projectId === 777); + expect(backFillCall).toBeDefined(); + expect(backFillCall![0].data.status).toBe(WorkflowStatus.IN_PROGRESS); + }); + + it("marks the row as FAILED and logs an error when batch item has no project_id", async () => { + setupDb(); + mockedGetUserActivityFeed.mockResolvedValue(makeActivityItems(1)); + mockCreateBatchProjects.mockResolvedValue({ + data: { + ref_id: "ref-1", + projects: [{ name: "daily-recap-run-1", error: "workflow error" }], + }, + }); + + const result = await executeScheduledDailyRecapRuns(); + + // Row set to FAILED + const updateCalls = vi.mocked(mockedDb.stakworkRun.update).mock.calls; + const failCall = updateCalls.find((c) => c[0].data?.status === WorkflowStatus.FAILED); + expect(failCall).toBeDefined(); + + // Error recorded, but loop continues (dispatched = 0) + expect(result.dispatched).toBe(0); + expect(result.errors.length).toBeGreaterThan(0); + }); + + it("marks all rows in a chunk FAILED if the batch call throws, without aborting other chunks", async () => { + const users = [{ id: "user-A" }, { id: "user-B" }]; + setupDb({ users }); + vi.mocked(mockedDb.stakworkRun.create) + .mockResolvedValueOnce(makeRun("run-A") as any) + .mockResolvedValueOnce(makeRun("run-B") as any); + vi.mocked(mockedDb.stakworkRun.update).mockResolvedValue(makeRun("x") as any); + mockedGetUserActivityFeed.mockResolvedValue(makeActivityItems(1)); + + mockCreateBatchProjects.mockRejectedValue(new Error("network failure")); + + const result = await executeScheduledDailyRecapRuns(); + + // Both users error-logged + expect(result.errors.length).toBe(2); + expect(result.dispatched).toBe(0); + }); +}); diff --git a/src/app/api/cron/daily-recap/route.ts b/src/app/api/cron/daily-recap/route.ts new file mode 100644 index 0000000000..c4e0078061 --- /dev/null +++ b/src/app/api/cron/daily-recap/route.ts @@ -0,0 +1,63 @@ +import { executeScheduledDailyRecapRuns } from "@/services/daily-recap-cron"; +import { NextRequest, NextResponse } from "next/server"; + +/** + * GET /api/cron/daily-recap + * + * Vercel cron job — runs at 09:00 UTC daily. + * Fans out one Stakwork daily-recap workflow per opted-in active user. + */ +export async function GET(request: NextRequest) { + try { + const authHeader = request.headers.get("authorization"); + if (authHeader !== `Bearer ${process.env.CRON_SECRET}`) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const cronEnabled = process.env.DAILY_RECAP_CRON_ENABLED === "true"; + if (!cronEnabled) { + console.log("[DailyRecapCron] Cron disabled via DAILY_RECAP_CRON_ENABLED"); + return NextResponse.json({ + success: true, + message: "Daily recap cron is disabled", + usersProcessed: 0, + dispatched: 0, + skipped: 0, + errorCount: 0, + errors: [], + timestamp: new Date().toISOString(), + }); + } + + console.log("[DailyRecapCron] Starting scheduled execution"); + + const result = await executeScheduledDailyRecapRuns(); + + console.log( + `[DailyRecapCron] Completed. Processed=${result.usersProcessed} ` + + `Dispatched=${result.dispatched} Skipped=${result.skipped} Errors=${result.errors.length}`, + ); + + return NextResponse.json({ + success: result.errors.length === 0, + usersProcessed: result.usersProcessed, + dispatched: result.dispatched, + skipped: result.skipped, + errorCount: result.errors.length, + errors: result.errors, + timestamp: new Date().toISOString(), + }); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + console.error("[DailyRecapCron] Unhandled error:", errorMessage); + + return NextResponse.json( + { + success: false, + error: "Internal server error", + timestamp: new Date().toISOString(), + }, + { status: 500 }, + ); + } +} diff --git a/src/lib/ai/initiativeTools.ts b/src/lib/ai/initiativeTools.ts index 13b0ffff70..2a2b3ea1f4 100644 --- a/src/lib/ai/initiativeTools.ts +++ b/src/lib/ai/initiativeTools.ts @@ -1,6 +1,7 @@ import { tool, ToolSet } from "ai"; import { z } from "zod"; import { db } from "@/lib/db"; +import { StakworkRunType, WorkflowStatus } from "@prisma/client"; import { updateFeature } from "@/services/roadmap"; import { sendFeatureChatMessage } from "@/services/roadmap/feature-chat"; import { getUserActivityFeed } from "@/services/roadmap/user-activity"; @@ -1490,6 +1491,31 @@ export function buildInitiativeTools( }, }), + // ─── get_daily_recap ───────────────────────────────────────────────── + get_daily_recap: tool({ + description: + "Returns the user's most recent AI-generated daily recap if one exists. " + + "Call this when the user asks about their daily summary or what they accomplished recently.", + inputSchema: z.object({}), + execute: async () => { + try { + const run = await db.stakworkRun.findFirst({ + where: { + userId, + type: StakworkRunType.DAILY_RECAP, + status: WorkflowStatus.COMPLETED, + }, + orderBy: { createdAt: "desc" }, + select: { result: true, createdAt: true }, + }); + if (!run?.result) return { recap: null }; + return { recap: run.result, generatedAt: run.createdAt }; + } catch { + return { error: "Failed to load daily recap" }; + } + }, + }), + // ─── read_user_activity ─────────────────────────────────────────────── read_user_activity: tool({ description: diff --git a/src/services/daily-recap-cron.ts b/src/services/daily-recap-cron.ts new file mode 100644 index 0000000000..b7c4598181 --- /dev/null +++ b/src/services/daily-recap-cron.ts @@ -0,0 +1,241 @@ +import { db } from "@/lib/db"; +import { StakworkRunType, WorkflowStatus } from "@prisma/client"; +import { getUserActivityFeed } from "@/services/roadmap/user-activity"; +import { stakworkService } from "@/lib/service-factory"; +import { config } from "@/config/env"; +import { getBaseUrl } from "@/lib/utils"; + +export interface DailyRecapCronResult { + usersProcessed: number; + dispatched: number; + skipped: number; + errors: Array<{ userId: string; error: string }>; +} + +/** + * Split an array into chunks of at most `size` items. + */ +function chunkArray(arr: T[], size: number): T[][] { + const chunks: T[][] = []; + for (let i = 0; i < arr.length; i += size) { + chunks.push(arr.slice(i, i + size)); + } + return chunks; +} + +/** + * Execute the scheduled daily-recap fan-out. + * + * For each opted-in user with recent activity: + * 1. Resolve a workspace to attribute the run to. + * 2. Compute the activity window since the last recap. + * 3. Skip users with zero activity. + * 4. Create a PENDING StakworkRun row stamped with userId. + * 5. Batch-dispatch via POST /api/v1/projects/batch (≤500 per chunk). + * 6. Back-fill projectId / status on the rows. + */ +export async function executeScheduledDailyRecapRuns(): Promise { + const result: DailyRecapCronResult = { + usersProcessed: 0, + dispatched: 0, + skipped: 0, + errors: [], + }; + + const workflowId = config.STAKWORK_DAILY_RECAP_WORKFLOW_ID; + if (!workflowId) { + console.error("[DailyRecapCron] STAKWORK_DAILY_RECAP_WORKFLOW_ID not configured — aborting"); + result.errors.push({ userId: "SYSTEM", error: "STAKWORK_DAILY_RECAP_WORKFLOW_ID not configured" }); + return result; + } + + const baseUrl = getBaseUrl(); + console.log(`[DailyRecapCron] Starting at ${new Date().toISOString()}`); + + // ── 1. Query eligible users ────────────────────────────────────────────── + const users = await db.user.findMany({ + where: { dailyRecapEnabled: true, deleted: false }, + select: { id: true }, + }); + + console.log(`[DailyRecapCron] Found ${users.length} eligible user(s)`); + result.usersProcessed = users.length; + + // Pending runs to batch-dispatch: { run, workflowWebhookUrl, since, activity, webhookUrl } + const pendingRuns: Array<{ + run: { id: string }; + userId: string; + workflowWebhookUrl: string; + since: Date; + activity: string; + webhookUrl: string; + }> = []; + + // ── Per-user preparation ───────────────────────────────────────────────── + for (const { id: userId } of users) { + try { + // 2. Resolve workspace + let workspaceId: string | null = null; + + const ownedWorkspace = await db.workspace.findFirst({ + where: { ownerId: userId, deleted: false }, + select: { id: true }, + }); + + if (ownedWorkspace) { + workspaceId = ownedWorkspace.id; + } else { + const membership = await db.workspaceMember.findFirst({ + where: { userId }, + orderBy: { joinedAt: "asc" }, + select: { workspaceId: true }, + }); + workspaceId = membership?.workspaceId ?? null; + } + + if (!workspaceId) { + console.warn(`[DailyRecapCron] Skipping user ${userId}: no workspace found`); + result.skipped++; + continue; + } + + // 3. Compute activity window + const lastRun = await db.stakworkRun.findFirst({ + where: { userId, type: StakworkRunType.DAILY_RECAP }, + orderBy: { createdAt: "desc" }, + select: { createdAt: true }, + }); + + const since = lastRun?.createdAt ?? new Date(Date.now() - 24 * 60 * 60 * 1000); + const days = Math.min(30, Math.max(1, Math.ceil((Date.now() - since.getTime()) / 86_400_000))); + + // 4. Fetch activity (skip check + digest in one call) + const items = await getUserActivityFeed({ userId, days, limit: 40 }); + + if (items.length === 0) { + console.log(`[DailyRecapCron] Skipping user ${userId}: no activity in last ${days} day(s)`); + result.skipped++; + continue; + } + + // 5. Build 5-field digest + const digest = items.map(({ kind, action, title, timestamp, workspaceName }) => ({ + kind, + action, + title, + timestamp, + workspaceName, + })); + const activity = JSON.stringify(digest); + + // 6. Create PENDING row + const run = await db.stakworkRun.create({ + data: { + type: StakworkRunType.DAILY_RECAP, + userId, + workspaceId, + status: WorkflowStatus.PENDING, + webhookUrl: "", + dataType: "string", + autoAccept: false, + }, + }); + + const workflowWebhookUrl = `${baseUrl}/api/stakwork/webhook?run_id=${run.id}`; + const webhookUrl = `${baseUrl}/api/webhook/stakwork/response?type=DAILY_RECAP&workspace_id=${workspaceId}`; + + await db.stakworkRun.update({ + where: { id: run.id }, + data: { webhookUrl }, + }); + + pendingRuns.push({ run, userId, workflowWebhookUrl, since, activity, webhookUrl }); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + console.error(`[DailyRecapCron] Error preparing user ${userId}: ${msg}`); + result.errors.push({ userId, error: msg }); + } + } + + if (pendingRuns.length === 0) { + console.log("[DailyRecapCron] No runs to dispatch"); + return result; + } + + // ── 7. Batch dispatch ──────────────────────────────────────────────────── + const chunks = chunkArray(pendingRuns, 500); + console.log(`[DailyRecapCron] Dispatching ${pendingRuns.length} run(s) in ${chunks.length} batch(es)`); + + for (let chunkIdx = 0; chunkIdx < chunks.length; chunkIdx++) { + const chunk = chunks[chunkIdx]; + + const batchPayload = chunk.map(({ run, workflowWebhookUrl, since, activity, webhookUrl }) => ({ + name: `daily-recap-${run.id}`, + workflow_id: parseInt(workflowId), + webhook_url: workflowWebhookUrl, + workflow_params: { + set_var: { + attributes: { + vars: { + since: since.toISOString(), + activity, + webhookUrl, + }, + }, + }, + }, + })); + + try { + const response = await stakworkService().createBatchProjects(batchPayload); + const projects = response.data.projects; + + console.log( + `[DailyRecapCron] Chunk ${chunkIdx + 1}/${chunks.length}: ` + + `${projects.filter((p) => p.project_id).length} succeeded, ` + + `${projects.filter((p) => !p.project_id).length} failed`, + ); + + // Back-fill projectId + for (const item of projects) { + const runId = item.name.replace("daily-recap-", ""); + + if (item.project_id) { + await db.stakworkRun.update({ + where: { id: runId }, + data: { projectId: item.project_id, status: WorkflowStatus.IN_PROGRESS }, + }); + result.dispatched++; + } else { + const errMsg = item.error ?? "unknown batch error"; + console.error(`[DailyRecapCron] Batch item ${item.name} failed: ${errMsg}`); + await db.stakworkRun.update({ + where: { id: runId }, + data: { status: WorkflowStatus.FAILED }, + }); + // Resolve userId from the chunk for richer error context + const pending = chunk.find((p) => p.run.id === runId); + result.errors.push({ userId: pending?.userId ?? runId, error: errMsg }); + } + } + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + console.error(`[DailyRecapCron] Chunk ${chunkIdx + 1} dispatch failed: ${msg}`); + + // Mark all runs in this chunk as FAILED + for (const { run, userId } of chunk) { + await db.stakworkRun + .update({ where: { id: run.id }, data: { status: WorkflowStatus.FAILED } }) + .catch(() => {/* best-effort */}); + result.errors.push({ userId, error: msg }); + } + } + } + + console.log( + `[DailyRecapCron] Done. Processed=${result.usersProcessed} Dispatched=${result.dispatched} ` + + `Skipped=${result.skipped} Errors=${result.errors.length}`, + ); + + return result; +} diff --git a/src/services/stakwork-run.ts b/src/services/stakwork-run.ts index 5a5ed06e8f..5114e436b7 100644 --- a/src/services/stakwork-run.ts +++ b/src/services/stakwork-run.ts @@ -270,6 +270,18 @@ export async function createStakworkRun( try { // Step 2: Build Stakwork payload + if (input.type === StakworkRunType.DAILY_RECAP) { + const recapWorkflowId = config.STAKWORK_DAILY_RECAP_WORKFLOW_ID; + if (!recapWorkflowId) { + throw new Error("STAKWORK_DAILY_RECAP_WORKFLOW_ID not configured"); + } + // DAILY_RECAP runs are dispatched via the cron's batch path. + // This branch exists for forward-compatibility only. + throw new Error( + "DAILY_RECAP runs must be dispatched via the daily-recap cron batch pipeline", + ); + } + const workflowId = config.STAKWORK_AI_GENERATION_WORKFLOW_ID; if (!workflowId) { throw new Error("STAKWORK_AI_GENERATION_WORKFLOW_ID not configured"); diff --git a/src/services/stakwork/index.ts b/src/services/stakwork/index.ts index 063ef4862c..e5809a400e 100644 --- a/src/services/stakwork/index.ts +++ b/src/services/stakwork/index.ts @@ -183,6 +183,53 @@ export class StakworkService extends BaseServiceClass { }; } + /** + * Create multiple projects in a single batch request. + * The caller is responsible for chunking to ≤500 projects per call. + * + * @param projects - Array of project definitions (max 500 per call) + * @returns Batch response with ref_id and per-project results + */ + async createBatchProjects( + projects: Array<{ + name: string; + workflow_id: number; + webhook_url: string; + workflow_params: { set_var: { attributes: { vars: unknown } } }; + }>, + ): Promise<{ + data: { + ref_id: string; + projects: Array<{ name: string; project_id?: number; error?: string }>; + }; + }> { + const endpoint = `/projects/batch`; + + const headers = { + "Content-Type": "application/json", + Authorization: `Token token=${encryptionService.decryptField( + "stakworkApiKey", + this.config.apiKey, + )}`, + }; + + const client = this.getClient(); + const requestFn = () => { + return client.post<{ + data: { + ref_id: string; + projects: Array<{ + name: string; + project_id?: number; + error?: string; + }>; + }; + }>(endpoint, { projects }, headers, this.serviceName); + }; + + return this.handleRequest(requestFn, `stakworkRequest ${endpoint}`); + } + /** * Stop a running project * @param projectId - The Stakwork project ID to stop diff --git a/vercel.json b/vercel.json index df1fbb08e8..485ecc4a81 100644 --- a/vercel.json +++ b/vercel.json @@ -7,6 +7,10 @@ } }, "crons": [{ + "path": "/api/cron/daily-recap", + "schedule": "0 9 * * *" + }, + { "path": "/api/cron/janitors", "schedule": "0 * * * *" }, From a80184da82c0cca436f49b589cd01e1496a32982 Mon Sep 17 00:00:00 2001 From: tomsmith8 Date: Thu, 25 Jun 2026 12:59:40 +0000 Subject: [PATCH 2/5] Generated with Hive: Add daily recap API, UI card component, sidebar and profile surfacing, and unit tests --- .../unit/api/user/daily-recap.test.ts | 90 +++++++++++++ .../daily-recap/DailyRecapCard.test.tsx | 120 ++++++++++++++++++ src/app/api/user/daily-recap/route.ts | 33 +++++ .../[githubLogin]/_components/SidebarChat.tsx | 2 + src/app/profile/page.tsx | 2 + src/components/daily-recap/DailyRecapCard.tsx | 50 ++++++++ 6 files changed, 297 insertions(+) create mode 100644 src/__tests__/unit/api/user/daily-recap.test.ts create mode 100644 src/__tests__/unit/components/daily-recap/DailyRecapCard.test.tsx create mode 100644 src/app/api/user/daily-recap/route.ts create mode 100644 src/components/daily-recap/DailyRecapCard.tsx diff --git a/src/__tests__/unit/api/user/daily-recap.test.ts b/src/__tests__/unit/api/user/daily-recap.test.ts new file mode 100644 index 0000000000..00db193d82 --- /dev/null +++ b/src/__tests__/unit/api/user/daily-recap.test.ts @@ -0,0 +1,90 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +// ── Mocks ──────────────────────────────────────────────────────────────────── + +const { mockGetServerSession } = vi.hoisted(() => ({ + mockGetServerSession: vi.fn(), +})); + +vi.mock("next-auth", () => ({ getServerSession: mockGetServerSession })); +vi.mock("@/lib/auth/nextauth", () => ({ authOptions: {} })); + +const mockStakworkRunFindFirst = vi.fn(); + +vi.mock("@/lib/db", () => ({ + db: { + stakworkRun: { + findFirst: (...args: unknown[]) => mockStakworkRunFindFirst(...args), + }, + }, +})); + +import { GET } from "@/app/api/user/daily-recap/route"; +import { StakworkRunType, WorkflowStatus } from "@prisma/client"; + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe("GET /api/user/daily-recap", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockGetServerSession.mockResolvedValue({ user: { id: "user-1" } }); + }); + + it("returns 401 when not authenticated", async () => { + mockGetServerSession.mockResolvedValue(null); + const res = await GET(); + expect(res.status).toBe(401); + const body = await res.json(); + expect(body.error).toBe("Unauthorized"); + }); + + it("returns { recap: null, generatedAt: null } when no completed run exists", async () => { + mockStakworkRunFindFirst.mockResolvedValue(null); + + const res = await GET(); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body).toEqual({ recap: null, generatedAt: null }); + }); + + it("returns recap text and generatedAt when a completed run exists", async () => { + const createdAt = new Date("2026-01-15T09:00:00Z"); + mockStakworkRunFindFirst.mockResolvedValue({ + result: "You merged 2 PRs and created 3 tasks yesterday.", + createdAt, + }); + + const res = await GET(); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.recap).toBe("You merged 2 PRs and created 3 tasks yesterday."); + expect(body.generatedAt).toBe(createdAt.toISOString()); + }); + + it("queries by the session userId — not a caller-supplied value", async () => { + mockStakworkRunFindFirst.mockResolvedValue(null); + + await GET(); + + expect(mockStakworkRunFindFirst).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + userId: "user-1", + type: StakworkRunType.DAILY_RECAP, + status: WorkflowStatus.COMPLETED, + }), + }), + ); + }); + + it("orders by createdAt desc to return the most recent run", async () => { + mockStakworkRunFindFirst.mockResolvedValue(null); + await GET(); + + expect(mockStakworkRunFindFirst).toHaveBeenCalledWith( + expect.objectContaining({ + orderBy: { createdAt: "desc" }, + }), + ); + }); +}); diff --git a/src/__tests__/unit/components/daily-recap/DailyRecapCard.test.tsx b/src/__tests__/unit/components/daily-recap/DailyRecapCard.test.tsx new file mode 100644 index 0000000000..0214c3e31a --- /dev/null +++ b/src/__tests__/unit/components/daily-recap/DailyRecapCard.test.tsx @@ -0,0 +1,120 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeEach } from "vitest"; +import React from "react"; +import { render, screen, waitFor } from "@testing-library/react"; + +// ── Mocks ──────────────────────────────────────────────────────────────────── + +vi.mock("date-fns", () => ({ + formatDistanceToNow: vi.fn(() => "2 hours ago"), +})); + +vi.mock("lucide-react", () => ({ + Sparkles: () => React.createElement("svg", { "data-testid": "sparkles-icon" }), +})); + +const fetchMock = vi.fn(); +vi.stubGlobal("fetch", fetchMock); + +// ── Import after mocks ──────────────────────────────────────────────────────── + +import { DailyRecapCard } from "@/components/daily-recap/DailyRecapCard"; + +// ── Helpers ────────────────────────────────────────────────────────────────── + +function mockFetch(body: { recap: string | null; generatedAt: string | null }) { + fetchMock.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(body), + }); +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe("DailyRecapCard", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("renders null (nothing in DOM) while loading", () => { + // fetch never resolves during this test + fetchMock.mockReturnValueOnce(new Promise(() => {})); + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + it("renders null when API returns { recap: null }", async () => { + mockFetch({ recap: null, generatedAt: null }); + const { container } = render(); + // After the promise resolves, the card should still not appear + await waitFor(() => expect(fetchMock).toHaveBeenCalledOnce()); + expect(container.firstChild).toBeNull(); + expect(screen.queryByTestId("daily-recap-card")).toBeNull(); + }); + + it("renders recap text when a recap is present", async () => { + mockFetch({ + recap: "You merged 2 PRs and created 3 tasks yesterday.", + generatedAt: new Date().toISOString(), + }); + + render(); + + await waitFor(() => + expect(screen.getByTestId("daily-recap-card")).toBeInTheDocument(), + ); + + expect( + screen.getByText("You merged 2 PRs and created 3 tasks yesterday."), + ).toBeInTheDocument(); + }); + + it("shows the relative timestamp when generatedAt is present", async () => { + mockFetch({ + recap: "Solid day.", + generatedAt: new Date().toISOString(), + }); + + render(); + + await waitFor(() => + expect(screen.getByTestId("daily-recap-card")).toBeInTheDocument(), + ); + + expect(screen.getByText(/Generated 2 hours ago/)).toBeInTheDocument(); + }); + + it("does not show a timestamp line when generatedAt is null", async () => { + mockFetch({ recap: "Solid day.", generatedAt: null }); + + render(); + + await waitFor(() => + expect(screen.getByTestId("daily-recap-card")).toBeInTheDocument(), + ); + + expect(screen.queryByText(/Generated/)).toBeNull(); + }); + + it("renders null when fetch throws (network error)", async () => { + fetchMock.mockRejectedValueOnce(new Error("network down")); + const { container } = render(); + // Give any microtasks a chance to settle + await new Promise((r) => setTimeout(r, 0)); + expect(container.firstChild).toBeNull(); + }); + + it("renders null when fetch returns a non-ok response", async () => { + fetchMock.mockResolvedValueOnce({ ok: false }); + const { container } = render(); + await new Promise((r) => setTimeout(r, 0)); + expect(container.firstChild).toBeNull(); + }); + + it("calls the correct endpoint", async () => { + mockFetch({ recap: null, generatedAt: null }); + render(); + await waitFor(() => expect(fetchMock).toHaveBeenCalledOnce()); + expect(fetchMock).toHaveBeenCalledWith("/api/user/daily-recap"); + }); +}); diff --git a/src/app/api/user/daily-recap/route.ts b/src/app/api/user/daily-recap/route.ts new file mode 100644 index 0000000000..332643d2dc --- /dev/null +++ b/src/app/api/user/daily-recap/route.ts @@ -0,0 +1,33 @@ +import { NextResponse } from "next/server"; +import { getServerSession } from "next-auth"; +import { authOptions } from "@/lib/auth/nextauth"; +import { db } from "@/lib/db"; +import { StakworkRunType, WorkflowStatus } from "@prisma/client"; + +/** + * GET /api/user/daily-recap + * + * Returns the authenticated user's most recent completed daily-recap result. + * `userId` is always sourced from the session — never from query params. + */ +export async function GET() { + const session = await getServerSession(authOptions); + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const run = await db.stakworkRun.findFirst({ + where: { + userId: session.user.id, + type: StakworkRunType.DAILY_RECAP, + status: WorkflowStatus.COMPLETED, + }, + orderBy: { createdAt: "desc" }, + select: { result: true, createdAt: true }, + }); + + return NextResponse.json({ + recap: run?.result ?? null, + generatedAt: run?.createdAt?.toISOString() ?? null, + }); +} diff --git a/src/app/org/[githubLogin]/_components/SidebarChat.tsx b/src/app/org/[githubLogin]/_components/SidebarChat.tsx index 08f32891e2..8520a20381 100644 --- a/src/app/org/[githubLogin]/_components/SidebarChat.tsx +++ b/src/app/org/[githubLogin]/_components/SidebarChat.tsx @@ -38,6 +38,7 @@ import { PlannerFormSlot } from "./PlannerFormSlot"; import { StartTasksSlot } from "./StartTasksSlot"; import { MyActivityPanel } from "./MyActivityPanel"; import { DeferredCheckCard } from "./DeferredCheckCard"; +import { DailyRecapCard } from "@/components/daily-recap/DailyRecapCard"; import type { ActivityItem } from "@/app/api/profile/activity/route"; import { useCanvasChatStore, @@ -344,6 +345,7 @@ export function SidebarChat({ githubLogin }: SidebarChatProps) { )}
+ {messages.map((message, index) => { const isLastMessage = index === messages.length - 1; const isMessageStreaming = isLastMessage && isLoading; diff --git a/src/app/profile/page.tsx b/src/app/profile/page.tsx index 237f5ee274..5878fe2266 100644 --- a/src/app/profile/page.tsx +++ b/src/app/profile/page.tsx @@ -4,6 +4,7 @@ import { redirect } from "next/navigation"; import Image from "next/image"; import { ActivityFeed } from "./_components/ActivityFeed"; import { BackButton } from "./_components/BackButton"; +import { DailyRecapCard } from "@/components/daily-recap/DailyRecapCard"; export default async function ProfilePage() { const session = await getServerSession(authOptions); @@ -49,6 +50,7 @@ export default async function ProfilePage() {
+ diff --git a/src/components/daily-recap/DailyRecapCard.tsx b/src/components/daily-recap/DailyRecapCard.tsx new file mode 100644 index 0000000000..d5a73c7a5d --- /dev/null +++ b/src/components/daily-recap/DailyRecapCard.tsx @@ -0,0 +1,50 @@ +"use client"; + +import React, { useEffect, useState } from "react"; +import { formatDistanceToNow } from "date-fns"; +import { Sparkles } from "lucide-react"; + +interface DailyRecapData { + recap: string | null; + generatedAt: string | null; +} + +/** + * Compact daily-recap card. + * Fetches GET /api/user/daily-recap on mount. + * Returns null while loading or when no completed recap exists. + */ +export function DailyRecapCard() { + const [data, setData] = useState(null); + + useEffect(() => { + fetch("/api/user/daily-recap") + .then((r) => (r.ok ? r.json() : null)) + .then((json: DailyRecapData | null) => { + if (json?.recap) setData(json); + }) + .catch(() => {/* silent — card simply doesn't render */}); + }, []); + + if (!data?.recap) return null; + + const relativeTime = data.generatedAt + ? formatDistanceToNow(new Date(data.generatedAt), { addSuffix: true }) + : null; + + return ( +
+
+ + Daily Recap +
+

{data.recap}

+ {relativeTime && ( +

Generated {relativeTime}

+ )} +
+ ); +} From 88dcbec35cafa6fae19c6c6025d6c2c34f062353 Mon Sep 17 00:00:00 2001 From: tomsmith8 Date: Thu, 25 Jun 2026 13:46:51 +0000 Subject: [PATCH 3/5] Generated with Hive: Remove get_daily_recap tool and related tests from initiativeTools --- .../unit/lib/ai/get-daily-recap-tool.test.ts | 130 ------------------ src/lib/ai/initiativeTools.ts | 26 ---- 2 files changed, 156 deletions(-) delete mode 100644 src/__tests__/unit/lib/ai/get-daily-recap-tool.test.ts diff --git a/src/__tests__/unit/lib/ai/get-daily-recap-tool.test.ts b/src/__tests__/unit/lib/ai/get-daily-recap-tool.test.ts deleted file mode 100644 index a039eb7435..0000000000 --- a/src/__tests__/unit/lib/ai/get-daily-recap-tool.test.ts +++ /dev/null @@ -1,130 +0,0 @@ -/** - * Unit tests for the `get_daily_recap` tool inside `buildInitiativeTools`. - * - * Verifies: - * - Returns `{ recap: null }` when no completed DAILY_RECAP run exists - * - Returns `{ recap, generatedAt }` when a completed run exists - * - Returns `{ error }` when the DB query throws - */ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { StakworkRunType, WorkflowStatus } from "@prisma/client"; - -// ── Mocks ──────────────────────────────────────────────────────────────────── - -vi.mock("@/lib/db", () => ({ - db: { - initiative: { findFirst: vi.fn() }, - milestone: { findFirst: vi.fn() }, - workspace: { findFirst: vi.fn() }, - feature: { findUnique: vi.fn(), findMany: vi.fn() }, - user: { findUnique: vi.fn() }, - stakworkRun: { findFirst: vi.fn() }, - }, -})); - -vi.mock("@/services/roadmap", () => ({ updateFeature: vi.fn() })); -vi.mock("@/lib/canvas", () => ({ - notifyFeatureReassignmentRefresh: vi.fn(), - notifyFeatureAssignmentRefreshByOrg: vi.fn(), - assignFeatureOnCanvas: vi.fn(), - unassignFeatureOnCanvas: vi.fn(), -})); -vi.mock("@/services/orgs/nodeDetail", () => ({ loadNodeDetail: vi.fn() })); -vi.mock("@/services/roadmap/feature-chat", () => ({ sendFeatureChatMessage: vi.fn() })); -vi.mock("@/services/roadmap/user-activity", () => ({ getUserActivityFeed: vi.fn() })); - -import { db } from "@/lib/db"; -import { buildInitiativeTools } from "@/lib/ai/initiativeTools"; - -const ORG_ID = "org-1"; -const USER_ID = "user-1"; - -function getTools() { - (db.user.findUnique as ReturnType).mockResolvedValue({ - canvasAutonomousTurns: false, - }); - return buildInitiativeTools(ORG_ID, USER_ID, undefined); -} - -async function callGetDailyRecap(tools: ReturnType) { - const t = tools.get_daily_recap; - if (!t?.execute) throw new Error("get_daily_recap tool missing execute function"); - return t.execute({} as Parameters>[0], { - toolCallId: "tc-test", - messages: [], - }); -} - -describe("get_daily_recap tool", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it("returns { recap: null } when no completed DAILY_RECAP run exists", async () => { - vi.mocked(db.stakworkRun.findFirst).mockResolvedValue(null); - - const tools = getTools(); - const result = await callGetDailyRecap(tools); - - expect(result).toEqual({ recap: null }); - - expect(db.stakworkRun.findFirst).toHaveBeenCalledWith( - expect.objectContaining({ - where: expect.objectContaining({ - userId: USER_ID, - type: StakworkRunType.DAILY_RECAP, - status: WorkflowStatus.COMPLETED, - }), - }), - ); - }); - - it("returns { recap: null } when run exists but result is null", async () => { - vi.mocked(db.stakworkRun.findFirst).mockResolvedValue({ - result: null, - createdAt: new Date(), - } as any); - - const tools = getTools(); - const result = await callGetDailyRecap(tools); - - expect(result).toEqual({ recap: null }); - }); - - it("returns { recap, generatedAt } when a completed run with a result exists", async () => { - const createdAt = new Date("2026-01-15T09:00:00Z"); - vi.mocked(db.stakworkRun.findFirst).mockResolvedValue({ - result: "You merged 2 PRs and created 3 tasks yesterday — solid progress on the auth refactor.", - createdAt, - } as any); - - const tools = getTools(); - const result = await callGetDailyRecap(tools); - - expect(result).toEqual({ - recap: "You merged 2 PRs and created 3 tasks yesterday — solid progress on the auth refactor.", - generatedAt: createdAt, - }); - }); - - it("returns { error } when the DB query throws", async () => { - vi.mocked(db.stakworkRun.findFirst).mockRejectedValue(new Error("DB connection lost")); - - const tools = getTools(); - const result = await callGetDailyRecap(tools); - - expect(result).toEqual({ error: "Failed to load daily recap" }); - }); - - it("queries by the userId from the closure — not a caller-supplied value", async () => { - vi.mocked(db.stakworkRun.findFirst).mockResolvedValue(null); - - const tools = getTools(); - await callGetDailyRecap(tools); - - const call = vi.mocked(db.stakworkRun.findFirst).mock.calls[0][0] as { - where: { userId: string }; - }; - expect(call.where.userId).toBe(USER_ID); - }); -}); diff --git a/src/lib/ai/initiativeTools.ts b/src/lib/ai/initiativeTools.ts index 2a2b3ea1f4..13b0ffff70 100644 --- a/src/lib/ai/initiativeTools.ts +++ b/src/lib/ai/initiativeTools.ts @@ -1,7 +1,6 @@ import { tool, ToolSet } from "ai"; import { z } from "zod"; import { db } from "@/lib/db"; -import { StakworkRunType, WorkflowStatus } from "@prisma/client"; import { updateFeature } from "@/services/roadmap"; import { sendFeatureChatMessage } from "@/services/roadmap/feature-chat"; import { getUserActivityFeed } from "@/services/roadmap/user-activity"; @@ -1491,31 +1490,6 @@ export function buildInitiativeTools( }, }), - // ─── get_daily_recap ───────────────────────────────────────────────── - get_daily_recap: tool({ - description: - "Returns the user's most recent AI-generated daily recap if one exists. " + - "Call this when the user asks about their daily summary or what they accomplished recently.", - inputSchema: z.object({}), - execute: async () => { - try { - const run = await db.stakworkRun.findFirst({ - where: { - userId, - type: StakworkRunType.DAILY_RECAP, - status: WorkflowStatus.COMPLETED, - }, - orderBy: { createdAt: "desc" }, - select: { result: true, createdAt: true }, - }); - if (!run?.result) return { recap: null }; - return { recap: run.result, generatedAt: run.createdAt }; - } catch { - return { error: "Failed to load daily recap" }; - } - }, - }), - // ─── read_user_activity ─────────────────────────────────────────────── read_user_activity: tool({ description: From b191cd1a95b079b461b09a18ae3278392dc2510d Mon Sep 17 00:00:00 2001 From: tomsmith8 Date: Fri, 26 Jun 2026 09:21:57 +0000 Subject: [PATCH 4/5] Generated with Hive: Refactor daily recap cron gating to use workflow ID and remove DAILY_RECAP_CRON_ENABLED --- .../integration/api/cron/daily-recap.test.ts | 38 +++++++------------ src/app/api/cron/daily-recap/route.ts | 7 ++-- 2 files changed, 17 insertions(+), 28 deletions(-) diff --git a/src/__tests__/integration/api/cron/daily-recap.test.ts b/src/__tests__/integration/api/cron/daily-recap.test.ts index c312575ec9..0959188343 100644 --- a/src/__tests__/integration/api/cron/daily-recap.test.ts +++ b/src/__tests__/integration/api/cron/daily-recap.test.ts @@ -6,9 +6,9 @@ import { NextRequest } from "next/server"; * * Tests verify: * - Authentication via CRON_SECRET (401 when missing/invalid) - * - Feature flag gating (DAILY_RECAP_CRON_ENABLED) - * - Response shape when cron is disabled - * - Response shape when cron is enabled and runs successfully + * - Workflow ID gating (STAKWORK_DAILY_RECAP_WORKFLOW_ID) + * - Response shape when workflow ID is missing + * - Response shape when cron runs successfully */ // ── Mocks ──────────────────────────────────────────────────────────────────── @@ -39,11 +39,11 @@ function createAuthenticatedRequest(): NextRequest { describe("GET /api/cron/daily-recap", () => { let originalCronSecret: string | undefined; - let originalEnabled: string | undefined; + let originalWorkflowId: string | undefined; beforeEach(() => { originalCronSecret = process.env.CRON_SECRET; - originalEnabled = process.env.DAILY_RECAP_CRON_ENABLED; + originalWorkflowId = process.env.STAKWORK_DAILY_RECAP_WORKFLOW_ID; process.env.CRON_SECRET = "test-cron-secret"; @@ -52,7 +52,7 @@ describe("GET /api/cron/daily-recap", () => { afterEach(() => { process.env.CRON_SECRET = originalCronSecret; - process.env.DAILY_RECAP_CRON_ENABLED = originalEnabled; + process.env.STAKWORK_DAILY_RECAP_WORKFLOW_ID = originalWorkflowId; }); // ── Auth ────────────────────────────────────────────────────────────────── @@ -71,36 +71,26 @@ describe("GET /api/cron/daily-recap", () => { expect(body.error).toBe("Unauthorized"); }); - // ── Disabled flag ───────────────────────────────────────────────────────── + // ── Workflow ID gate ────────────────────────────────────────────────────── - it("returns 200 with disabled message when DAILY_RECAP_CRON_ENABLED is not 'true'", async () => { - process.env.DAILY_RECAP_CRON_ENABLED = "false"; + it("returns 200 with skip message when STAKWORK_DAILY_RECAP_WORKFLOW_ID is unset", async () => { + delete process.env.STAKWORK_DAILY_RECAP_WORKFLOW_ID; const res = await GET(createAuthenticatedRequest()); expect(res.status).toBe(200); const body = await res.json(); expect(body.success).toBe(true); - expect(body.message).toMatch(/disabled/i); + expect(body.message).toMatch(/STAKWORK_DAILY_RECAP_WORKFLOW_ID not configured/i); expect(body.usersProcessed).toBe(0); expect(body.dispatched).toBe(0); expect(mockExecuteScheduledDailyRecapRuns).not.toHaveBeenCalled(); }); - it("returns 200 with disabled message when DAILY_RECAP_CRON_ENABLED is unset", async () => { - delete process.env.DAILY_RECAP_CRON_ENABLED; - - const res = await GET(createAuthenticatedRequest()); - expect(res.status).toBe(200); - const body = await res.json(); - expect(body.success).toBe(true); - expect(mockExecuteScheduledDailyRecapRuns).not.toHaveBeenCalled(); - }); - // ── Enabled ─────────────────────────────────────────────────────────────── - it("calls executeScheduledDailyRecapRuns and returns summary JSON when enabled", async () => { - process.env.DAILY_RECAP_CRON_ENABLED = "true"; + it("calls executeScheduledDailyRecapRuns and returns summary JSON when workflow ID is set", async () => { + process.env.STAKWORK_DAILY_RECAP_WORKFLOW_ID = "42"; mockExecuteScheduledDailyRecapRuns.mockResolvedValue({ usersProcessed: 5, dispatched: 4, @@ -125,7 +115,7 @@ describe("GET /api/cron/daily-recap", () => { }); it("returns success=false when errors are present", async () => { - process.env.DAILY_RECAP_CRON_ENABLED = "true"; + process.env.STAKWORK_DAILY_RECAP_WORKFLOW_ID = "42"; mockExecuteScheduledDailyRecapRuns.mockResolvedValue({ usersProcessed: 3, dispatched: 2, @@ -143,7 +133,7 @@ describe("GET /api/cron/daily-recap", () => { }); it("returns 500 when executeScheduledDailyRecapRuns throws", async () => { - process.env.DAILY_RECAP_CRON_ENABLED = "true"; + process.env.STAKWORK_DAILY_RECAP_WORKFLOW_ID = "42"; mockExecuteScheduledDailyRecapRuns.mockRejectedValue(new Error("unexpected crash")); const res = await GET(createAuthenticatedRequest()); diff --git a/src/app/api/cron/daily-recap/route.ts b/src/app/api/cron/daily-recap/route.ts index c4e0078061..c291b2b749 100644 --- a/src/app/api/cron/daily-recap/route.ts +++ b/src/app/api/cron/daily-recap/route.ts @@ -14,12 +14,11 @@ export async function GET(request: NextRequest) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } - const cronEnabled = process.env.DAILY_RECAP_CRON_ENABLED === "true"; - if (!cronEnabled) { - console.log("[DailyRecapCron] Cron disabled via DAILY_RECAP_CRON_ENABLED"); + if (!process.env.STAKWORK_DAILY_RECAP_WORKFLOW_ID) { + console.log("[DailyRecapCron] STAKWORK_DAILY_RECAP_WORKFLOW_ID not configured, skipping"); return NextResponse.json({ success: true, - message: "Daily recap cron is disabled", + message: "STAKWORK_DAILY_RECAP_WORKFLOW_ID not configured, skipping", usersProcessed: 0, dispatched: 0, skipped: 0, From c847783b22d12e01146762b66a76fb0575fac57e Mon Sep 17 00:00:00 2001 From: Paul Itoi <814886+pitoi@users.noreply.github.com> Date: Fri, 26 Jun 2026 08:47:48 -0600 Subject: [PATCH 5/5] Generated with Hive: Remove incorrect namespace param from GET lingo nodes handler and update test assertion (#4549) Co-authored-by: pitoi Co-authored-by: Tom Smith <142233216+tomsmith8@users.noreply.github.com> --- src/__tests__/unit/api/workspaces/lingo-routes.test.ts | 2 +- src/app/api/workspaces/[slug]/lingo/nodes/route.ts | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/src/__tests__/unit/api/workspaces/lingo-routes.test.ts b/src/__tests__/unit/api/workspaces/lingo-routes.test.ts index c9520017da..b6a8d11e95 100644 --- a/src/__tests__/unit/api/workspaces/lingo-routes.test.ts +++ b/src/__tests__/unit/api/workspaces/lingo-routes.test.ts @@ -189,7 +189,7 @@ describe("GET /api/workspaces/[slug]/lingo/nodes", () => { expect(calledUrl).toContain("limit=10"); expect(calledUrl).toContain("offset=20"); expect(calledUrl).toContain("type=Lingo"); - expect(calledUrl).toContain("namespace=testswarm"); + expect(calledUrl).not.toContain("namespace="); }); test("sets hasMore=true when response length equals limit", async () => { diff --git a/src/app/api/workspaces/[slug]/lingo/nodes/route.ts b/src/app/api/workspaces/[slug]/lingo/nodes/route.ts index a74c5d6320..758e255c61 100644 --- a/src/app/api/workspaces/[slug]/lingo/nodes/route.ts +++ b/src/app/api/workspaces/[slug]/lingo/nodes/route.ts @@ -53,7 +53,6 @@ export async function GET( const queryParams = new URLSearchParams({ type: "Lingo", - namespace: swarmName, limit: String(limit), offset: String(offset), sort: "created_at:desc",