From b9daa3bafa1279df270e808a8e953583cff133c1 Mon Sep 17 00:00:00 2001 From: tomsmith8 Date: Fri, 3 Jul 2026 10:05:18 +0000 Subject: [PATCH 1/4] Generated with Hive: Rebrand Daily Recap to Activity Recap in components, API, and services --- prisma/schema.prisma | 2 +- .../integration/api/cron/daily-recap.test.ts | 20 +++---- .../api/stakwork/daily-recap-webhook.test.ts | 4 +- .../unit/api/user/preferences.test.ts | 58 +++++++++---------- ...rd.test.tsx => ActivityRecapCard.test.tsx} | 34 +++++------ ...est.tsx => ActivityRecapSettings.test.tsx} | 56 +++++++++--------- .../unit/services/daily-recap-cron.test.ts | 42 +++++++------- src/app/api/cron/daily-recap/route.ts | 12 ++-- src/app/api/user/preferences/route.ts | 18 +++--- .../[githubLogin]/_components/SidebarChat.tsx | 4 +- src/app/profile/page.tsx | 15 +++-- ...ilyRecapCard.tsx => ActivityRecapCard.tsx} | 10 ++-- ...Settings.tsx => ActivityRecapSettings.tsx} | 19 +++--- src/services/daily-recap-cron.ts | 32 +++++----- 14 files changed, 165 insertions(+), 161 deletions(-) rename src/__tests__/unit/components/daily-recap/{DailyRecapCard.test.tsx => ActivityRecapCard.test.tsx} (91%) rename src/__tests__/unit/components/settings/{DailyRecapSettings.test.tsx => ActivityRecapSettings.test.tsx} (62%) rename src/components/daily-recap/{DailyRecapCard.tsx => ActivityRecapCard.tsx} (91%) rename src/components/settings/{DailyRecapSettings.tsx => ActivityRecapSettings.tsx} (73%) diff --git a/prisma/schema.prisma b/prisma/schema.prisma index ffa6411bb5..20d5c66c6b 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -58,7 +58,7 @@ model User { // `CANVAS_AUTONOMOUS_TURNS_ENABLED` env var is a master kill-switch // layered above this flag. canvasAutonomousTurns Boolean @default(false) @map("canvas_autonomous_turns") - dailyRecapEnabled Boolean @default(true) @map("daily_recap_enabled") + activityRecapEnabled Boolean @default(true) @map("daily_recap_enabled") voiceLearningEnabled Boolean @default(false) @map("voice_learning_enabled") // Per-user default model for the canvas Agent chat, stored in // `getModelValue()` "provider/name" form (e.g. "anthropic/claude-sonnet-4"). diff --git a/src/__tests__/integration/api/cron/daily-recap.test.ts b/src/__tests__/integration/api/cron/daily-recap.test.ts index aaabe0ebc2..26ff689231 100644 --- a/src/__tests__/integration/api/cron/daily-recap.test.ts +++ b/src/__tests__/integration/api/cron/daily-recap.test.ts @@ -13,12 +13,12 @@ import { NextRequest } from "next/server"; // ── Mocks ──────────────────────────────────────────────────────────────────── -const { mockExecuteScheduledDailyRecapRuns } = vi.hoisted(() => ({ - mockExecuteScheduledDailyRecapRuns: vi.fn(), +const { mockExecuteScheduledActivityRecapRuns } = vi.hoisted(() => ({ + mockExecuteScheduledActivityRecapRuns: vi.fn(), })); vi.mock("@/services/daily-recap-cron", () => ({ - executeScheduledDailyRecapRuns: mockExecuteScheduledDailyRecapRuns, + executeScheduledActivityRecapRuns: mockExecuteScheduledActivityRecapRuns, })); import { GET } from "@/app/api/cron/daily-recap/route"; @@ -82,14 +82,14 @@ describe("GET /api/cron/daily-recap", () => { const body = await res.json(); expect(body.success).toBe(true); expect(body.message).toMatch(/not configured/i); - expect(mockExecuteScheduledDailyRecapRuns).not.toHaveBeenCalled(); + expect(mockExecuteScheduledActivityRecapRuns).not.toHaveBeenCalled(); }); // ── Enabled ─────────────────────────────────────────────────────────────── - it("calls executeScheduledDailyRecapRuns and returns summary JSON when workflow ID is set", async () => { + it("calls executeScheduledActivityRecapRuns and returns summary JSON when workflow ID is set", async () => { process.env.STAKWORK_DAILY_RECAP_WORKFLOW_ID = "12345"; - mockExecuteScheduledDailyRecapRuns.mockResolvedValue({ + mockExecuteScheduledActivityRecapRuns.mockResolvedValue({ usersProcessed: 5, dispatched: 4, skipped: 1, @@ -109,12 +109,12 @@ describe("GET /api/cron/daily-recap", () => { errors: [], }); expect(typeof body.timestamp).toBe("string"); - expect(mockExecuteScheduledDailyRecapRuns).toHaveBeenCalledOnce(); + expect(mockExecuteScheduledActivityRecapRuns).toHaveBeenCalledOnce(); }); it("returns success=false when errors are present", async () => { process.env.STAKWORK_DAILY_RECAP_WORKFLOW_ID = "12345"; - mockExecuteScheduledDailyRecapRuns.mockResolvedValue({ + mockExecuteScheduledActivityRecapRuns.mockResolvedValue({ usersProcessed: 3, dispatched: 2, skipped: 0, @@ -130,9 +130,9 @@ describe("GET /api/cron/daily-recap", () => { expect(body.errors[0]).toMatchObject({ userId: "user-1", error: "network failure" }); }); - it("returns 500 when executeScheduledDailyRecapRuns throws", async () => { + it("returns 500 when executeScheduledActivityRecapRuns throws", async () => { process.env.STAKWORK_DAILY_RECAP_WORKFLOW_ID = "12345"; - mockExecuteScheduledDailyRecapRuns.mockRejectedValue(new Error("unexpected crash")); + mockExecuteScheduledActivityRecapRuns.mockRejectedValue(new Error("unexpected crash")); const res = await GET(createAuthenticatedRequest()); expect(res.status).toBe(500); diff --git a/src/__tests__/integration/api/stakwork/daily-recap-webhook.test.ts b/src/__tests__/integration/api/stakwork/daily-recap-webhook.test.ts index 59698bbcf0..0e10a36d5c 100644 --- a/src/__tests__/integration/api/stakwork/daily-recap-webhook.test.ts +++ b/src/__tests__/integration/api/stakwork/daily-recap-webhook.test.ts @@ -44,13 +44,13 @@ vi.mock("@/lib/vercel/stakwork-token", () => ({ // ─── Helpers ───────────────────────────────────────────────────────────────── -async function createUser(opts: { dailyRecapEnabled?: boolean } = {}) { +async function createUser(opts: { activityRecapEnabled?: boolean } = {}) { return db.user.create({ data: { id: generateUniqueId("user"), email: `user-${generateUniqueId()}@test.com`, name: "Test User", - dailyRecapEnabled: opts.dailyRecapEnabled ?? false, + activityRecapEnabled: opts.activityRecapEnabled ?? false, }, }); } diff --git a/src/__tests__/unit/api/user/preferences.test.ts b/src/__tests__/unit/api/user/preferences.test.ts index a197e90bc4..7b7e780ca4 100644 --- a/src/__tests__/unit/api/user/preferences.test.ts +++ b/src/__tests__/unit/api/user/preferences.test.ts @@ -51,7 +51,7 @@ describe("GET /api/user/preferences", () => { canvasAutonomousTurns: true, chatAgentModel: null, timezone: "America/Chicago", - dailyRecapEnabled: true, + activityRecapEnabled: true, }); const res = await GET(); @@ -66,7 +66,7 @@ describe("GET /api/user/preferences", () => { canvasAutonomousTurns: false, chatAgentModel: null, timezone: null, - dailyRecapEnabled: true, + activityRecapEnabled: true, }); const res = await GET(); @@ -75,33 +75,33 @@ describe("GET /api/user/preferences", () => { expect(body.timezone).toBe("UTC"); }); - test("returns dailyRecapEnabled in response", async () => { + test("returns activityRecapEnabled in response", async () => { mockUserFindUnique.mockResolvedValue({ canvasAutonomousTurns: false, chatAgentModel: null, timezone: "UTC", - dailyRecapEnabled: false, + activityRecapEnabled: false, }); const res = await GET(); const body = await res.json(); expect(res.status).toBe(200); - expect(body.dailyRecapEnabled).toBe(false); + expect(body.activityRecapEnabled).toBe(false); }); - test("returns dailyRecapEnabled true by default", async () => { + test("returns activityRecapEnabled true by default", async () => { mockUserFindUnique.mockResolvedValue({ canvasAutonomousTurns: false, chatAgentModel: null, timezone: "UTC", - dailyRecapEnabled: true, + activityRecapEnabled: true, }); const res = await GET(); const body = await res.json(); - expect(body.dailyRecapEnabled).toBe(true); + expect(body.activityRecapEnabled).toBe(true); }); test("returns 401 when unauthenticated", async () => { @@ -123,7 +123,7 @@ describe("PATCH /api/user/preferences — timezone", () => { canvasAutonomousTurns: false, chatAgentModel: null, timezone: "America/Chicago", - dailyRecapEnabled: true, + activityRecapEnabled: true, }); const req = makeRequest({ timezone: "America/Chicago" }); @@ -161,7 +161,7 @@ describe("PATCH /api/user/preferences — timezone", () => { canvasAutonomousTurns: true, chatAgentModel: null, timezone: "UTC", - dailyRecapEnabled: true, + activityRecapEnabled: true, }); const req = makeRequest({ canvasAutonomousTurns: true }); @@ -186,65 +186,65 @@ describe("PATCH /api/user/preferences — timezone", () => { }); }); -describe("PATCH /api/user/preferences — dailyRecapEnabled", () => { +describe("PATCH /api/user/preferences — activityRecapEnabled", () => { beforeEach(() => { vi.clearAllMocks(); mockGetServerSession.mockResolvedValue({ user: { id: "user-1" } }); }); - test("updates dailyRecapEnabled to false and returns it in the response", async () => { + test("updates activityRecapEnabled to false and returns it in the response", async () => { mockUserUpdate.mockResolvedValue({ canvasAutonomousTurns: false, chatAgentModel: null, timezone: "UTC", - dailyRecapEnabled: false, + activityRecapEnabled: false, }); - const req = makeRequest({ dailyRecapEnabled: false }); + const req = makeRequest({ activityRecapEnabled: false }); const res = await PATCH(req); const body = await res.json(); expect(res.status).toBe(200); - expect(body.dailyRecapEnabled).toBe(false); + expect(body.activityRecapEnabled).toBe(false); expect(mockUserUpdate).toHaveBeenCalledWith( expect.objectContaining({ - data: expect.objectContaining({ dailyRecapEnabled: false }), + data: expect.objectContaining({ activityRecapEnabled: false }), }), ); }); - test("updates dailyRecapEnabled to true", async () => { + test("updates activityRecapEnabled to true", async () => { mockUserUpdate.mockResolvedValue({ canvasAutonomousTurns: false, chatAgentModel: null, timezone: "UTC", - dailyRecapEnabled: true, + activityRecapEnabled: true, }); - const req = makeRequest({ dailyRecapEnabled: true }); + const req = makeRequest({ activityRecapEnabled: true }); const res = await PATCH(req); const body = await res.json(); expect(res.status).toBe(200); - expect(body.dailyRecapEnabled).toBe(true); + expect(body.activityRecapEnabled).toBe(true); }); - test("rejects non-boolean dailyRecapEnabled with 400", async () => { - const req = makeRequest({ dailyRecapEnabled: "yes" }); + test("rejects non-boolean activityRecapEnabled with 400", async () => { + const req = makeRequest({ activityRecapEnabled: "yes" }); const res = await PATCH(req); const body = await res.json(); expect(res.status).toBe(400); - expect(body.error).toBe("dailyRecapEnabled must be a boolean"); + expect(body.error).toBe("activityRecapEnabled must be a boolean"); expect(mockUserUpdate).not.toHaveBeenCalled(); }); - test("does not include dailyRecapEnabled in update when not provided", async () => { + test("does not include activityRecapEnabled in update when not provided", async () => { mockUserUpdate.mockResolvedValue({ canvasAutonomousTurns: false, chatAgentModel: null, timezone: "UTC", - dailyRecapEnabled: true, + activityRecapEnabled: true, }); const req = makeRequest({ canvasAutonomousTurns: false }); @@ -253,7 +253,7 @@ describe("PATCH /api/user/preferences — dailyRecapEnabled", () => { expect(res.status).toBe(200); expect(mockUserUpdate).toHaveBeenCalledWith( expect.objectContaining({ - data: expect.not.objectContaining({ dailyRecapEnabled: expect.anything() }), + data: expect.not.objectContaining({ activityRecapEnabled: expect.anything() }), }), ); }); @@ -280,7 +280,7 @@ describe("PATCH /api/user/preferences — voiceLearningEnabled", () => { canvasAutonomousTurns: false, chatAgentModel: null, timezone: "UTC", - dailyRecapEnabled: true, + activityRecapEnabled: true, voiceLearningEnabled: true, }); @@ -302,7 +302,7 @@ describe("PATCH /api/user/preferences — voiceLearningEnabled", () => { canvasAutonomousTurns: false, chatAgentModel: null, timezone: "UTC", - dailyRecapEnabled: true, + activityRecapEnabled: true, voiceLearningEnabled: false, }); @@ -319,7 +319,7 @@ describe("PATCH /api/user/preferences — voiceLearningEnabled", () => { canvasAutonomousTurns: false, chatAgentModel: null, timezone: "UTC", - dailyRecapEnabled: true, + activityRecapEnabled: true, voiceLearningEnabled: false, }); diff --git a/src/__tests__/unit/components/daily-recap/DailyRecapCard.test.tsx b/src/__tests__/unit/components/daily-recap/ActivityRecapCard.test.tsx similarity index 91% rename from src/__tests__/unit/components/daily-recap/DailyRecapCard.test.tsx rename to src/__tests__/unit/components/daily-recap/ActivityRecapCard.test.tsx index 10154cd7a6..687d2f49ef 100644 --- a/src/__tests__/unit/components/daily-recap/DailyRecapCard.test.tsx +++ b/src/__tests__/unit/components/daily-recap/ActivityRecapCard.test.tsx @@ -48,7 +48,7 @@ afterEach(() => { // ── Import after mocks ──────────────────────────────────────────────────────── -import { DailyRecapCard } from "@/components/daily-recap/DailyRecapCard"; +import { ActivityRecapCard } from "@/components/daily-recap/ActivityRecapCard"; // ── Helpers ────────────────────────────────────────────────────────────────── @@ -64,7 +64,7 @@ const GENERATED_AT = new Date().toISOString(); // ── Tests ───────────────────────────────────────────────────────────────────── -describe("DailyRecapCard", () => { +describe("ActivityRecapCard", () => { beforeEach(() => { vi.clearAllMocks(); }); @@ -73,13 +73,13 @@ describe("DailyRecapCard", () => { it("renders null (nothing in DOM) while loading", () => { fetchMock.mockReturnValueOnce(new Promise(() => {})); - const { container } = render(); + const { container } = render(); expect(container.firstChild).toBeNull(); }); it("renders null when API returns { recap: null }", async () => { mockFetch({ recap: null, generatedAt: null }); - const { container } = render(); + const { container } = render(); await waitFor(() => expect(fetchMock).toHaveBeenCalledOnce()); expect(container.firstChild).toBeNull(); expect(screen.queryByTestId("daily-recap-card")).toBeNull(); @@ -87,7 +87,7 @@ describe("DailyRecapCard", () => { it("renders recap text when a recap is present", async () => { mockFetch({ recap: RECAP_TEXT, generatedAt: GENERATED_AT }); - render(); + render(); await waitFor(() => expect(screen.getByTestId("daily-recap-card")).toBeInTheDocument()); expect(screen.getByText(RECAP_TEXT)).toBeInTheDocument(); }); @@ -96,7 +96,7 @@ describe("DailyRecapCard", () => { it("heading reads \"Recap\" (not \"Daily Recap\")", async () => { mockFetch({ recap: RECAP_TEXT, generatedAt: GENERATED_AT }); - render(); + render(); await waitFor(() => expect(screen.getByTestId("daily-recap-card")).toBeInTheDocument()); expect(screen.getByText("Recap")).toBeInTheDocument(); expect(screen.queryByText(/Daily Recap/i)).toBeNull(); @@ -106,7 +106,7 @@ describe("DailyRecapCard", () => { it("shows relative timestamp without \"Generated\" prefix", async () => { mockFetch({ recap: "Solid day.", generatedAt: GENERATED_AT }); - render(); + render(); await waitFor(() => expect(screen.getByTestId("daily-recap-card")).toBeInTheDocument()); expect(screen.getByText(/2 hours ago/)).toBeInTheDocument(); expect(screen.queryByText(/Generated/)).toBeNull(); @@ -114,7 +114,7 @@ describe("DailyRecapCard", () => { it("does not show a timestamp line when generatedAt is null", async () => { mockFetch({ recap: "Solid day.", generatedAt: null }); - render(); + render(); await waitFor(() => expect(screen.getByTestId("daily-recap-card")).toBeInTheDocument()); expect(screen.queryByText(/Generated/)).toBeNull(); expect(screen.queryByText(/hours ago/)).toBeNull(); @@ -124,21 +124,21 @@ describe("DailyRecapCard", () => { it("renders X dismiss button when dismissible prop is set", async () => { mockFetch({ recap: RECAP_TEXT, generatedAt: GENERATED_AT }); - render(); + render(); await waitFor(() => expect(screen.getByTestId("daily-recap-card")).toBeInTheDocument()); expect(screen.getByRole("button", { name: /dismiss/i })).toBeInTheDocument(); }); it("does not render X button when dismissible is not set", async () => { mockFetch({ recap: RECAP_TEXT, generatedAt: GENERATED_AT }); - render(); + render(); await waitFor(() => expect(screen.getByTestId("daily-recap-card")).toBeInTheDocument()); expect(screen.queryByRole("button", { name: /dismiss/i })).toBeNull(); }); it("clicking X hides the card and sets sessionStorage flag", async () => { mockFetch({ recap: RECAP_TEXT, generatedAt: GENERATED_AT }); - render(); + render(); await waitFor(() => expect(screen.getByTestId("daily-recap-card")).toBeInTheDocument()); fireEvent.click(screen.getByRole("button", { name: /dismiss/i })); @@ -150,7 +150,7 @@ describe("DailyRecapCard", () => { it("stays hidden when sessionStorage flag is pre-set", async () => { sessionStorageMock.getItem.mockReturnValue("1"); mockFetch({ recap: RECAP_TEXT, generatedAt: GENERATED_AT }); - const { container } = render(); + const { container } = render(); // Give async effects time to settle await new Promise((r) => setTimeout(r, 20)); expect(container.firstChild).toBeNull(); @@ -161,7 +161,7 @@ describe("DailyRecapCard", () => { it("renders \"My Activity\" link to /profile when showActivityLink is set", async () => { mockFetch({ recap: RECAP_TEXT, generatedAt: GENERATED_AT }); - render(); + render(); await waitFor(() => expect(screen.getByTestId("daily-recap-card")).toBeInTheDocument()); const link = screen.getByRole("link", { name: /My Activity/i }); expect(link).toBeInTheDocument(); @@ -170,7 +170,7 @@ describe("DailyRecapCard", () => { it("does not render \"My Activity\" link when showActivityLink is not set", async () => { mockFetch({ recap: RECAP_TEXT, generatedAt: GENERATED_AT }); - render(); + render(); await waitFor(() => expect(screen.getByTestId("daily-recap-card")).toBeInTheDocument()); expect(screen.queryByRole("link", { name: /My Activity/i })).toBeNull(); }); @@ -179,21 +179,21 @@ describe("DailyRecapCard", () => { it("renders null when fetch throws (network error)", async () => { fetchMock.mockRejectedValueOnce(new Error("network down")); - const { container } = render(); + const { container } = render(); 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(); + 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(); + render(); await waitFor(() => expect(fetchMock).toHaveBeenCalledOnce()); expect(fetchMock).toHaveBeenCalledWith("/api/user/daily-recap"); }); diff --git a/src/__tests__/unit/components/settings/DailyRecapSettings.test.tsx b/src/__tests__/unit/components/settings/ActivityRecapSettings.test.tsx similarity index 62% rename from src/__tests__/unit/components/settings/DailyRecapSettings.test.tsx rename to src/__tests__/unit/components/settings/ActivityRecapSettings.test.tsx index fbcee6f67a..cc36e58451 100644 --- a/src/__tests__/unit/components/settings/DailyRecapSettings.test.tsx +++ b/src/__tests__/unit/components/settings/ActivityRecapSettings.test.tsx @@ -2,7 +2,7 @@ import React from "react"; import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { DailyRecapSettings } from "@/components/settings/DailyRecapSettings"; +import { ActivityRecapSettings } from "@/components/settings/ActivityRecapSettings"; import { toast } from "sonner"; vi.mock("sonner"); @@ -23,7 +23,7 @@ vi.mock("@/components/ui/switch", () => ({ aria-checked={checked} onClick={() => onCheckedChange(!checked)} disabled={disabled} - data-testid="daily-recap-switch" + data-testid="activity-recap-switch" /> ), })); @@ -34,69 +34,69 @@ vi.mock("@/components/ui/label", () => ({ ), })); -describe("DailyRecapSettings", () => { +describe("ActivityRecapSettings", () => { beforeEach(() => { vi.clearAllMocks(); global.fetch = vi.fn(); }); - it("renders with toggle off when dailyRecapEnabled=false from the API", async () => { + it("renders with toggle off when activityRecapEnabled=false from the API", async () => { (global.fetch as ReturnType).mockResolvedValueOnce({ ok: true, - json: async () => ({ dailyRecapEnabled: false }), + json: async () => ({ activityRecapEnabled: false }), }); - render(); + render(); await waitFor(() => { - const toggle = screen.getByTestId("daily-recap-switch"); + const toggle = screen.getByTestId("activity-recap-switch"); expect(toggle).toHaveAttribute("aria-checked", "false"); }); expect(screen.getByText("Disabled")).toBeInTheDocument(); }); - it("renders with toggle on when dailyRecapEnabled=true from the API", async () => { + it("renders with toggle on when activityRecapEnabled=true from the API", async () => { (global.fetch as ReturnType).mockResolvedValueOnce({ ok: true, - json: async () => ({ dailyRecapEnabled: true }), + json: async () => ({ activityRecapEnabled: true }), }); - render(); + render(); await waitFor(() => { - const toggle = screen.getByTestId("daily-recap-switch"); + const toggle = screen.getByTestId("activity-recap-switch"); expect(toggle).toHaveAttribute("aria-checked", "true"); }); expect(screen.getByText("Enabled")).toBeInTheDocument(); }); - it("clicking toggle calls PATCH /api/user/preferences with { dailyRecapEnabled: true }", async () => { + it("clicking toggle calls PATCH /api/user/preferences with { activityRecapEnabled: true }", async () => { const user = userEvent.setup(); (global.fetch as ReturnType) .mockResolvedValueOnce({ ok: true, - json: async () => ({ dailyRecapEnabled: false }), + json: async () => ({ activityRecapEnabled: false }), }) .mockResolvedValueOnce({ ok: true, - json: async () => ({ dailyRecapEnabled: true }), + json: async () => ({ activityRecapEnabled: true }), }); - render(); + render(); await waitFor(() => { - expect(screen.getByTestId("daily-recap-switch")).not.toBeDisabled(); + expect(screen.getByTestId("activity-recap-switch")).not.toBeDisabled(); }); - await user.click(screen.getByTestId("daily-recap-switch")); + await user.click(screen.getByTestId("activity-recap-switch")); await waitFor(() => { expect(global.fetch).toHaveBeenCalledWith( "/api/user/preferences", expect.objectContaining({ method: "PATCH", - body: JSON.stringify({ dailyRecapEnabled: true }), + body: JSON.stringify({ activityRecapEnabled: true }), }) ); }); @@ -108,20 +108,20 @@ describe("DailyRecapSettings", () => { (global.fetch as ReturnType) .mockResolvedValueOnce({ ok: true, - json: async () => ({ dailyRecapEnabled: false }), + json: async () => ({ activityRecapEnabled: false }), }) .mockResolvedValueOnce({ ok: true, - json: async () => ({ dailyRecapEnabled: true }), + json: async () => ({ activityRecapEnabled: true }), }); - render(); + render(); await waitFor(() => { - expect(screen.getByTestId("daily-recap-switch")).not.toBeDisabled(); + expect(screen.getByTestId("activity-recap-switch")).not.toBeDisabled(); }); - await user.click(screen.getByTestId("daily-recap-switch")); + await user.click(screen.getByTestId("activity-recap-switch")); await waitFor(() => { expect(toast.success).toHaveBeenCalledWith("Preference saved"); @@ -134,24 +134,24 @@ describe("DailyRecapSettings", () => { (global.fetch as ReturnType) .mockResolvedValueOnce({ ok: true, - json: async () => ({ dailyRecapEnabled: false }), + json: async () => ({ activityRecapEnabled: false }), }) .mockResolvedValueOnce({ ok: false, json: async () => ({}), }); - render(); + render(); await waitFor(() => { - expect(screen.getByTestId("daily-recap-switch")).not.toBeDisabled(); + expect(screen.getByTestId("activity-recap-switch")).not.toBeDisabled(); }); - await user.click(screen.getByTestId("daily-recap-switch")); + await user.click(screen.getByTestId("activity-recap-switch")); await waitFor(() => { expect(toast.error).toHaveBeenCalledWith("Failed to save preference"); - expect(screen.getByTestId("daily-recap-switch")).toHaveAttribute( + expect(screen.getByTestId("activity-recap-switch")).toHaveAttribute( "aria-checked", "false" ); diff --git a/src/__tests__/unit/services/daily-recap-cron.test.ts b/src/__tests__/unit/services/daily-recap-cron.test.ts index 3f2f90a27b..d6c016ca42 100644 --- a/src/__tests__/unit/services/daily-recap-cron.test.ts +++ b/src/__tests__/unit/services/daily-recap-cron.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import fs from "fs"; import path from "path"; -import { executeScheduledDailyRecapRuns } from "@/services/daily-recap-cron"; +import { executeScheduledActivityRecapRuns } from "@/services/daily-recap-cron"; import { db } from "@/lib/db"; import { StakworkRunType, WorkflowStatus } from "@prisma/client"; @@ -136,9 +136,9 @@ describe("Daily Recap Cron — vercel.json configuration", () => { }); }); -// ── executeScheduledDailyRecapRuns ─────────────────────────────────────────── +// ── executeScheduledActivityRecapRuns ─────────────────────────────────────────── -describe("executeScheduledDailyRecapRuns", () => { +describe("executeScheduledActivityRecapRuns", () => { beforeEach(() => { vi.clearAllMocks(); }); @@ -147,7 +147,7 @@ describe("executeScheduledDailyRecapRuns", () => { setupDb({ ownedWorkspace: null, membership: null }); mockedGetUserActivityFeed.mockResolvedValue([]); - const result = await executeScheduledDailyRecapRuns(); + const result = await executeScheduledActivityRecapRuns(); expect(result.skipped).toBe(1); expect(result.dispatched).toBe(0); @@ -158,7 +158,7 @@ describe("executeScheduledDailyRecapRuns", () => { setupDb(); mockedGetUserActivityFeed.mockResolvedValue([]); - const result = await executeScheduledDailyRecapRuns(); + const result = await executeScheduledActivityRecapRuns(); expect(result.skipped).toBe(1); expect(result.dispatched).toBe(0); @@ -175,7 +175,7 @@ describe("executeScheduledDailyRecapRuns", () => { }, }); - const result = await executeScheduledDailyRecapRuns(); + const result = await executeScheduledActivityRecapRuns(); expect(mockedDb.stakworkRun.create).toHaveBeenCalledOnce(); const createCall = vi.mocked(mockedDb.stakworkRun.create).mock.calls[0][0]; @@ -222,7 +222,7 @@ describe("executeScheduledDailyRecapRuns", () => { }, })); - const result = await executeScheduledDailyRecapRuns(); + const result = await executeScheduledActivityRecapRuns(); expect(mockCreateBatchProjects).toHaveBeenCalledTimes(2); expect(result.usersProcessed).toBe(501); @@ -239,7 +239,7 @@ describe("executeScheduledDailyRecapRuns", () => { }, }); - await executeScheduledDailyRecapRuns(); + await executeScheduledActivityRecapRuns(); const updateCalls = vi.mocked(mockedDb.stakworkRun.update).mock.calls; const backFillCall = updateCalls.find((c) => c[0].data?.projectId === 777); @@ -257,7 +257,7 @@ describe("executeScheduledDailyRecapRuns", () => { }, }); - const result = await executeScheduledDailyRecapRuns(); + const result = await executeScheduledActivityRecapRuns(); // Row set to FAILED const updateCalls = vi.mocked(mockedDb.stakworkRun.update).mock.calls; @@ -286,7 +286,7 @@ describe("executeScheduledDailyRecapRuns", () => { details: { body: "param is missing or the value is empty: project" }, }); - const result = await executeScheduledDailyRecapRuns(); + const result = await executeScheduledActivityRecapRuns(); // Both users error-logged with the real message (not '[object Object]') expect(result.errors.length).toBe(2); @@ -311,7 +311,7 @@ describe("Staleness reaper", () => { setupDb(); mockedGetUserActivityFeed.mockResolvedValue([]); - await executeScheduledDailyRecapRuns(); + await executeScheduledActivityRecapRuns(); expect(mockedDb.stakworkRun.updateMany).toHaveBeenCalledOnce(); const call = vi.mocked(mockedDb.stakworkRun.updateMany).mock.calls[0][0]; @@ -328,7 +328,7 @@ describe("Staleness reaper", () => { mockedGetUserActivityFeed.mockResolvedValue([]); const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); - await executeScheduledDailyRecapRuns(); + await executeScheduledActivityRecapRuns(); expect(warnSpy).toHaveBeenCalledWith( expect.stringContaining("Reaped 3 stale DAILY_RECAP run(s)"), @@ -341,7 +341,7 @@ describe("Staleness reaper", () => { mockedGetUserActivityFeed.mockResolvedValue([]); const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); - await executeScheduledDailyRecapRuns(); + await executeScheduledActivityRecapRuns(); expect(warnSpy).not.toHaveBeenCalledWith(expect.stringContaining("Reaped")); warnSpy.mockRestore(); @@ -359,7 +359,7 @@ describe("COMPLETED-only cursor", () => { setupDb(); mockedGetUserActivityFeed.mockResolvedValue([]); - await executeScheduledDailyRecapRuns(); + await executeScheduledActivityRecapRuns(); // First findFirst call is the cursor const cursorCall = vi.mocked(mockedDb.stakworkRun.findFirst).mock.calls[0][0]; @@ -374,7 +374,7 @@ describe("COMPLETED-only cursor", () => { }); const before = Date.now(); - await executeScheduledDailyRecapRuns(); + await executeScheduledActivityRecapRuns(); const after = Date.now(); // The `since` value passed to getUserActivityFeed is derived from days (1–30) @@ -394,7 +394,7 @@ describe("COMPLETED-only cursor", () => { data: { ref_id: "r", projects: [{ name: "daily-recap-run-1", project_id: 1 }] }, }); - await executeScheduledDailyRecapRuns(); + await executeScheduledActivityRecapRuns(); const activityCall = mockedGetUserActivityFeed.mock.calls[0][0]; expect(activityCall.days).toBe(2); @@ -413,7 +413,7 @@ describe("In-flight guard", () => { setupDb({ inflightRun: freshRun }); mockedGetUserActivityFeed.mockResolvedValue(makeActivityItems(3)); - const result = await executeScheduledDailyRecapRuns(); + const result = await executeScheduledActivityRecapRuns(); expect(result.skipped).toBe(1); expect(result.dispatched).toBe(0); @@ -427,7 +427,7 @@ describe("In-flight guard", () => { data: { ref_id: "r", projects: [{ name: "daily-recap-run-1", project_id: 1 }] }, }); - const result = await executeScheduledDailyRecapRuns(); + const result = await executeScheduledActivityRecapRuns(); expect(result.skipped).toBe(0); expect(mockedDb.stakworkRun.create).toHaveBeenCalledOnce(); @@ -439,7 +439,7 @@ describe("In-flight guard", () => { mockedGetUserActivityFeed.mockResolvedValue(makeActivityItems(1)); const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); - await executeScheduledDailyRecapRuns(); + await executeScheduledActivityRecapRuns(); expect(logSpy).toHaveBeenCalledWith( expect.stringContaining("Skipping user user-1"), @@ -455,7 +455,7 @@ describe("In-flight guard", () => { setupDb({ inflightRun: freshRun }); mockedGetUserActivityFeed.mockResolvedValue(makeActivityItems(3)); - await executeScheduledDailyRecapRuns(); + await executeScheduledActivityRecapRuns(); expect(mockedDb.stakworkRun.create).not.toHaveBeenCalled(); }); @@ -467,7 +467,7 @@ describe("In-flight guard", () => { data: { ref_id: "r", projects: [{ name: "daily-recap-run-1", project_id: 1 }] }, }); - await executeScheduledDailyRecapRuns(); + await executeScheduledActivityRecapRuns(); // Second findFirst call is the guard const guardCall = vi.mocked(mockedDb.stakworkRun.findFirst).mock.calls[1][0]; diff --git a/src/app/api/cron/daily-recap/route.ts b/src/app/api/cron/daily-recap/route.ts index 1820ee2b51..058cb7b309 100644 --- a/src/app/api/cron/daily-recap/route.ts +++ b/src/app/api/cron/daily-recap/route.ts @@ -1,4 +1,4 @@ -import { executeScheduledDailyRecapRuns } from "@/services/daily-recap-cron"; +import { executeScheduledActivityRecapRuns } from "@/services/daily-recap-cron"; import { NextRequest, NextResponse } from "next/server"; /** @@ -15,16 +15,16 @@ export async function GET(request: NextRequest) { } if (!process.env.STAKWORK_DAILY_RECAP_WORKFLOW_ID) { - console.log("[DailyRecapCron] STAKWORK_DAILY_RECAP_WORKFLOW_ID not configured, skipping"); + console.log("[ActivityRecapCron] STAKWORK_DAILY_RECAP_WORKFLOW_ID not configured, skipping"); return NextResponse.json({ success: true, message: "Daily recap cron not configured" }); } - console.log("[DailyRecapCron] Starting scheduled execution"); + console.log("[ActivityRecapCron] Starting scheduled execution"); - const result = await executeScheduledDailyRecapRuns(); + const result = await executeScheduledActivityRecapRuns(); console.log( - `[DailyRecapCron] Completed. Processed=${result.usersProcessed} ` + + `[ActivityRecapCron] Completed. Processed=${result.usersProcessed} ` + `Dispatched=${result.dispatched} Skipped=${result.skipped} Errors=${result.errors.length}`, ); @@ -39,7 +39,7 @@ export async function GET(request: NextRequest) { }); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); - console.error("[DailyRecapCron] Unhandled error:", errorMessage); + console.error("[ActivityRecapCron] Unhandled error:", errorMessage); return NextResponse.json( { diff --git a/src/app/api/user/preferences/route.ts b/src/app/api/user/preferences/route.ts index e8b84d72de..283ce82c3d 100644 --- a/src/app/api/user/preferences/route.ts +++ b/src/app/api/user/preferences/route.ts @@ -24,7 +24,7 @@ export async function GET() { const user = await db.user.findUnique({ where: { id: session.user.id }, - select: { canvasAutonomousTurns: true, chatAgentModel: true, timezone: true, dailyRecapEnabled: true, voiceLearningEnabled: true }, + select: { canvasAutonomousTurns: true, chatAgentModel: true, timezone: true, activityRecapEnabled: true, voiceLearningEnabled: true }, }); if (!user) { return NextResponse.json({ error: "User not found" }, { status: 404 }); @@ -34,7 +34,7 @@ export async function GET() { canvasAutonomousTurns: user.canvasAutonomousTurns, chatAgentModel: user.chatAgentModel, timezone: user.timezone ?? "UTC", - dailyRecapEnabled: user.dailyRecapEnabled, + activityRecapEnabled: user.activityRecapEnabled, voiceLearningEnabled: user.voiceLearningEnabled, }); } @@ -48,7 +48,7 @@ export async function PATCH(request: NextRequest) { } const body = await request.json(); - const { canvasAutonomousTurns, chatAgentModel, timezone, dailyRecapEnabled, voiceLearningEnabled } = body; + const { canvasAutonomousTurns, chatAgentModel, timezone, activityRecapEnabled, voiceLearningEnabled } = body; if ( canvasAutonomousTurns !== undefined && @@ -80,9 +80,9 @@ export async function PATCH(request: NextRequest) { } } - if (dailyRecapEnabled !== undefined && typeof dailyRecapEnabled !== "boolean") { + if (activityRecapEnabled !== undefined && typeof activityRecapEnabled !== "boolean") { return NextResponse.json( - { error: "dailyRecapEnabled must be a boolean" }, + { error: "activityRecapEnabled must be a boolean" }, { status: 400 }, ); } @@ -100,10 +100,10 @@ export async function PATCH(request: NextRequest) { ...(canvasAutonomousTurns !== undefined && { canvasAutonomousTurns }), ...(chatAgentModel !== undefined && { chatAgentModel }), ...(timezone !== undefined && { timezone }), - ...(dailyRecapEnabled !== undefined && { dailyRecapEnabled }), + ...(activityRecapEnabled !== undefined && { activityRecapEnabled }), ...(voiceLearningEnabled !== undefined && { voiceLearningEnabled }), }, - select: { canvasAutonomousTurns: true, chatAgentModel: true, timezone: true, dailyRecapEnabled: true, voiceLearningEnabled: true }, + select: { canvasAutonomousTurns: true, chatAgentModel: true, timezone: true, activityRecapEnabled: true, voiceLearningEnabled: true }, }); logger.info("User preferences updated", "USER_PREFERENCES_UPDATE", { @@ -111,7 +111,7 @@ export async function PATCH(request: NextRequest) { canvasAutonomousTurns: updated.canvasAutonomousTurns, chatAgentModel: updated.chatAgentModel, timezone: updated.timezone, - dailyRecapEnabled: updated.dailyRecapEnabled, + activityRecapEnabled: updated.activityRecapEnabled, voiceLearningEnabled: updated.voiceLearningEnabled, }); @@ -119,7 +119,7 @@ export async function PATCH(request: NextRequest) { canvasAutonomousTurns: updated.canvasAutonomousTurns, chatAgentModel: updated.chatAgentModel, timezone: updated.timezone ?? "UTC", - dailyRecapEnabled: updated.dailyRecapEnabled, + activityRecapEnabled: updated.activityRecapEnabled, voiceLearningEnabled: updated.voiceLearningEnabled, }); } catch (error) { diff --git a/src/app/org/[githubLogin]/_components/SidebarChat.tsx b/src/app/org/[githubLogin]/_components/SidebarChat.tsx index 2bec1b0ce0..a601adeb75 100644 --- a/src/app/org/[githubLogin]/_components/SidebarChat.tsx +++ b/src/app/org/[githubLogin]/_components/SidebarChat.tsx @@ -39,7 +39,7 @@ import { import { PlannerFormSlot } from "./PlannerFormSlot"; import { StartTasksSlot } from "./StartTasksSlot"; import { DeferredCheckCard } from "./DeferredCheckCard"; -import { DailyRecapCard } from "@/components/daily-recap/DailyRecapCard"; +import { ActivityRecapCard } from "@/components/daily-recap/ActivityRecapCard"; import { useCanvasChatStore, @@ -349,7 +349,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 ff581fa632..00a624fc8d 100644 --- a/src/app/profile/page.tsx +++ b/src/app/profile/page.tsx @@ -2,10 +2,10 @@ import { getServerSession } from "next-auth/next"; import { authOptions } from "@/lib/auth/nextauth"; import { redirect } from "next/navigation"; import Image from "next/image"; +import Link from "next/link"; import { ActivityFeed } from "./_components/ActivityFeed"; import { BackButton } from "./_components/BackButton"; -import { DailyRecapCard } from "@/components/daily-recap/DailyRecapCard"; -import { DailyRecapSettings } from "@/components/settings/DailyRecapSettings"; +import { ActivityRecapCard } from "@/components/daily-recap/ActivityRecapCard"; export default async function ProfilePage() { const session = await getServerSession(authOptions); @@ -51,9 +51,14 @@ export default async function ProfilePage() {
- -
- + +
+ + Manage Activity Recap in Settings → +
diff --git a/src/components/daily-recap/DailyRecapCard.tsx b/src/components/daily-recap/ActivityRecapCard.tsx similarity index 91% rename from src/components/daily-recap/DailyRecapCard.tsx rename to src/components/daily-recap/ActivityRecapCard.tsx index 6c7ebc08de..3bdb3a7450 100644 --- a/src/components/daily-recap/DailyRecapCard.tsx +++ b/src/components/daily-recap/ActivityRecapCard.tsx @@ -5,12 +5,12 @@ import { formatDistanceToNow } from "date-fns"; import { Sparkles, X } from "lucide-react"; import Link from "next/link"; -interface DailyRecapData { +interface ActivityRecapData { recap: string | null; generatedAt: string | null; } -interface DailyRecapCardProps { +interface ActivityRecapCardProps { /** When true, renders an X button that hides the card for the session. */ dismissible?: boolean; /** When true, renders a "My Activity →" link to /profile. */ @@ -24,8 +24,8 @@ const SESSION_KEY = "hive:daily-recap-dismissed"; * Fetches GET /api/user/daily-recap on mount. * Returns null while loading or when no completed recap exists. */ -export function DailyRecapCard({ dismissible, showActivityLink }: DailyRecapCardProps = {}) { - const [data, setData] = useState(null); +export function ActivityRecapCard({ dismissible, showActivityLink }: ActivityRecapCardProps = {}) { + const [data, setData] = useState(null); const [dismissed, setDismissed] = useState(false); // Check session-dismissed flag on mount (dismissible mode only). @@ -43,7 +43,7 @@ export function DailyRecapCard({ dismissible, showActivityLink }: DailyRecapCard useEffect(() => { fetch("/api/user/daily-recap") .then((r) => (r.ok ? r.json() : null)) - .then((json: DailyRecapData | null) => { + .then((json: ActivityRecapData | null) => { if (json?.recap) setData(json); }) .catch(() => {/* silent — card simply doesn't render */}); diff --git a/src/components/settings/DailyRecapSettings.tsx b/src/components/settings/ActivityRecapSettings.tsx similarity index 73% rename from src/components/settings/DailyRecapSettings.tsx rename to src/components/settings/ActivityRecapSettings.tsx index 224a68aea2..6ace7222ad 100644 --- a/src/components/settings/DailyRecapSettings.tsx +++ b/src/components/settings/ActivityRecapSettings.tsx @@ -12,7 +12,7 @@ import { import { Switch } from "@/components/ui/switch"; import { Label } from "@/components/ui/label"; -export function DailyRecapSettings() { +export function ActivityRecapSettings() { const [enabled, setEnabled] = useState(false); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); @@ -21,7 +21,7 @@ export function DailyRecapSettings() { fetch("/api/user/preferences") .then((r) => r.json()) .then((d) => { - setEnabled(!!d.dailyRecapEnabled); + setEnabled(!!d.activityRecapEnabled); }) .catch(() => {}) .finally(() => setLoading(false)); @@ -36,7 +36,7 @@ export function DailyRecapSettings() { const res = await fetch("/api/user/preferences", { method: "PATCH", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ dailyRecapEnabled: newValue }), + body: JSON.stringify({ activityRecapEnabled: newValue }), }); if (!res.ok) throw new Error("Failed"); @@ -53,23 +53,22 @@ export function DailyRecapSettings() { return ( - Daily Recap + Activity Recap - When enabled, you will receive a daily AI-generated recap of your - activity across your workspaces. The recap is delivered each morning - and surfaced here on your profile. Applies across all your workspaces. + When enabled, you will receive an AI-generated recap of your activity + across your workspaces. Applies across all your workspaces.
-
diff --git a/src/services/daily-recap-cron.ts b/src/services/daily-recap-cron.ts index 24a5b576e3..134469ac41 100644 --- a/src/services/daily-recap-cron.ts +++ b/src/services/daily-recap-cron.ts @@ -41,7 +41,7 @@ function chunkArray(arr: T[], size: number): T[][] { * 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 { +export async function executeScheduledActivityRecapRuns(): Promise { const result: DailyRecapCronResult = { usersProcessed: 0, dispatched: 0, @@ -51,13 +51,13 @@ export async function executeScheduledDailyRecapRuns(): Promise 0) { - console.warn(`[DailyRecapCron] Reaped ${reaped.count} stale DAILY_RECAP run(s) → FAILED`); + console.warn(`[ActivityRecapCron] Reaped ${reaped.count} stale DAILY_RECAP run(s) → FAILED`); } // ── 1. Query eligible users ────────────────────────────────────────────── const users = await db.user.findMany({ - where: { dailyRecapEnabled: true, deleted: false }, + where: { activityRecapEnabled: true, deleted: false }, select: { id: true }, }); - console.log(`[DailyRecapCron] Found ${users.length} eligible user(s)`); + console.log(`[ActivityRecapCron] Found ${users.length} eligible user(s)`); result.usersProcessed = users.length; // Pending runs to batch-dispatch: { run, workflowWebhookUrl, since, activity, webhookUrl } @@ -115,7 +115,7 @@ export async function executeScheduledDailyRecapRuns(): Promise p.project_id).length} succeeded, ` + `${projects.filter((p) => !p.project_id).length} failed`, ); @@ -249,7 +249,7 @@ export async function executeScheduledDailyRecapRuns(): Promise Date: Fri, 3 Jul 2026 10:15:34 +0000 Subject: [PATCH 2/4] Generated with Hive: Fix ESLint warnings and errors from CI lint check --- src/app/org/[githubLogin]/_components/SidebarChat.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/app/org/[githubLogin]/_components/SidebarChat.tsx b/src/app/org/[githubLogin]/_components/SidebarChat.tsx index e2e8f920f1..b7499b89dc 100644 --- a/src/app/org/[githubLogin]/_components/SidebarChat.tsx +++ b/src/app/org/[githubLogin]/_components/SidebarChat.tsx @@ -343,14 +343,13 @@ export function SidebarChat({ githubLogin }: SidebarChatProps) {
- + {!hasMessages && activeToolCalls.length === 0 && (
Ask the agent about anything on this canvas.
)}
- {messages.map((message, index) => { const isLastMessage = index === messages.length - 1; const isMessageStreaming = isLastMessage && isLoading; From e33a3ac976c73d2dd84b1b25d281284b5cee8808 Mon Sep 17 00:00:00 2001 From: tomsmith8 Date: Fri, 3 Jul 2026 10:31:13 +0000 Subject: [PATCH 3/4] Generated with Hive: Update SidebarChat tests to mock ActivityRecapCard instead of DailyRecapCard --- src/__tests__/unit/components/SidebarChat.test.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/__tests__/unit/components/SidebarChat.test.tsx b/src/__tests__/unit/components/SidebarChat.test.tsx index e8a68ad5f5..5558943744 100644 --- a/src/__tests__/unit/components/SidebarChat.test.tsx +++ b/src/__tests__/unit/components/SidebarChat.test.tsx @@ -164,8 +164,8 @@ vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() }, })); -vi.mock("@/components/daily-recap/DailyRecapCard", () => ({ - DailyRecapCard: () =>
, +vi.mock("@/components/daily-recap/ActivityRecapCard", () => ({ + ActivityRecapCard: () =>
, })); vi.mock("@/components/dashboard/DashboardChat/StreamScrollIndicator", () => ({ From 2358ab6d71e597453de15eda80fc5130e09eaaf8 Mon Sep 17 00:00:00 2001 From: tomsmith8 Date: Fri, 3 Jul 2026 20:36:17 +0000 Subject: [PATCH 4/4] Carry forward: rework daily recap cron to activity recap with incremental dispatch --- .../api/stakwork/daily-recap-webhook.test.ts | 87 ++++++++++ .../unit/api/user/daily-recap.test.ts | 28 ++++ .../unit/services/daily-recap-cron.test.ts | 152 +++++++++++++++--- .../services/roadmap/user-activity.test.ts | 73 +++++++++ src/app/api/user/daily-recap/route.ts | 1 + src/services/daily-recap-cron.ts | 37 ++++- src/services/roadmap/user-activity.ts | 5 +- src/services/stakwork-run.ts | 18 ++- src/types/stakwork.ts | 1 + vercel.json | 2 +- 10 files changed, 371 insertions(+), 33 deletions(-) diff --git a/src/__tests__/integration/api/stakwork/daily-recap-webhook.test.ts b/src/__tests__/integration/api/stakwork/daily-recap-webhook.test.ts index 0e10a36d5c..ab2309fd00 100644 --- a/src/__tests__/integration/api/stakwork/daily-recap-webhook.test.ts +++ b/src/__tests__/integration/api/stakwork/daily-recap-webhook.test.ts @@ -190,6 +190,93 @@ describe("processStakworkRunWebhook — DAILY_RECAP exact-run targeting", () => expect(afterRun?.result).not.toBeNull(); }); + it("recap_unchanged: true — marks run COMPLETED but leaves result null", async () => { + const user = await createUser(); + const workspace = await createWorkspace(user.id); + + const run = await createDailyRecapRun(user.id, workspace.id); + + await processStakworkRunWebhook( + { result: null, project_status: "completed", recap_unchanged: true }, + { type: "DAILY_RECAP", workspace_id: workspace.id, run_id: run.id }, + ); + + const afterRun = await db.stakworkRun.findUnique({ where: { id: run.id } }); + + expect(afterRun?.status).toBe(WorkflowStatus.COMPLETED); + expect(afterRun?.result).toBeNull(); + }); + + it("recap_unchanged: true — cursor advances; GET /daily-recap still returns prior non-null result", async () => { + const user = await createUser(); + const workspace = await createWorkspace(user.id); + + // 1. First run completes with real result — this is the "prior recap" + const firstRun = await createDailyRecapRun(user.id, workspace.id); + await processStakworkRunWebhook( + { result: "Prior recap content", project_status: "completed" }, + { type: "DAILY_RECAP", workspace_id: workspace.id, run_id: firstRun.id }, + ); + + const afterFirst = await db.stakworkRun.findUnique({ where: { id: firstRun.id } }); + expect(afterFirst?.status).toBe(WorkflowStatus.COMPLETED); + expect(afterFirst?.result).toBe("Prior recap content"); + + // 2. Second run comes back recap_unchanged: true — completes but no result written + const secondRun = await createDailyRecapRun(user.id, workspace.id); + await processStakworkRunWebhook( + { result: null, project_status: "completed", recap_unchanged: true }, + { type: "DAILY_RECAP", workspace_id: workspace.id, run_id: secondRun.id }, + ); + + const afterSecond = await db.stakworkRun.findUnique({ where: { id: secondRun.id } }); + expect(afterSecond?.status).toBe(WorkflowStatus.COMPLETED); + expect(afterSecond?.result).toBeNull(); + + // 3. The GET endpoint (result: { not: null } filter) still surfaces the first run's result + const latestWithResult = await db.stakworkRun.findFirst({ + where: { + userId: user.id, + type: StakworkRunType.DAILY_RECAP, + status: WorkflowStatus.COMPLETED, + result: { not: null }, + }, + orderBy: { createdAt: "desc" }, + select: { result: true, createdAt: true }, + }); + expect(latestWithResult?.result).toBe("Prior recap content"); + }); + + it("recap_unchanged: true — cursor (createdAt) of second run is newer than first, advancing the since window", async () => { + const user = await createUser(); + const workspace = await createWorkspace(user.id); + + // First run completes normally + const firstRun = await createDailyRecapRun(user.id, workspace.id); + await processStakworkRunWebhook( + { result: "Initial recap", project_status: "completed" }, + { type: "DAILY_RECAP", workspace_id: workspace.id, run_id: firstRun.id }, + ); + + // Slight delay ensures second run's updatedAt > first run's + await new Promise((r) => setTimeout(r, 10)); + + // Second run is recap_unchanged + const secondRun = await createDailyRecapRun(user.id, workspace.id); + await processStakworkRunWebhook( + { result: null, project_status: "completed", recap_unchanged: true }, + { type: "DAILY_RECAP", workspace_id: workspace.id, run_id: secondRun.id }, + ); + + // The COMPLETED cursor (latest by createdAt) should now be the second run + const cursorRun = await db.stakworkRun.findFirst({ + where: { userId: user.id, type: StakworkRunType.DAILY_RECAP, status: WorkflowStatus.COMPLETED }, + orderBy: { createdAt: "desc" }, + select: { id: true }, + }); + expect(cursorRun?.id).toBe(secondRun.id); + }); + it("three users in one workspace: each webhook with its own run_id completes only the correct run", async () => { const userA = await createUser(); const workspace = await createWorkspace(userA.id); diff --git a/src/__tests__/unit/api/user/daily-recap.test.ts b/src/__tests__/unit/api/user/daily-recap.test.ts index 00db193d82..d65a23d136 100644 --- a/src/__tests__/unit/api/user/daily-recap.test.ts +++ b/src/__tests__/unit/api/user/daily-recap.test.ts @@ -87,4 +87,32 @@ describe("GET /api/user/daily-recap", () => { }), ); }); + + it("filters result: { not: null } so recap_unchanged (null-result) runs are skipped", async () => { + mockStakworkRunFindFirst.mockResolvedValue(null); + await GET(); + + expect(mockStakworkRunFindFirst).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + result: { not: null }, + }), + }), + ); + }); + + it("returns the last non-null result even when a newer null-result run exists", async () => { + // Simulate: findFirst skips null-result run and returns the last real recap + const createdAt = new Date("2026-01-15T08:00:00Z"); + mockStakworkRunFindFirst.mockResolvedValue({ + result: "My prior recap", + createdAt, + }); + + const res = await GET(); + const body = await res.json(); + + expect(body.recap).toBe("My prior recap"); + expect(body.generatedAt).toBe(createdAt.toISOString()); + }); }); diff --git a/src/__tests__/unit/services/daily-recap-cron.test.ts b/src/__tests__/unit/services/daily-recap-cron.test.ts index d6c016ca42..b75947cee5 100644 --- a/src/__tests__/unit/services/daily-recap-cron.test.ts +++ b/src/__tests__/unit/services/daily-recap-cron.test.ts @@ -72,11 +72,20 @@ const makeActivityItems = (n = 3) => completed: false, })); +/** + * Per-user findFirst call order (happy path — activity found): + * 0: cursor — COMPLETED run for `since` + * 1: lastResultRun — COMPLETED run with result != null for previous_recap + * 2: inflightRun — guard check + * + * When no activity found (skip path), only call 0 (cursor) fires. + */ function setupDb(overrides: Partial<{ users: unknown[]; ownedWorkspace: unknown; membership: unknown; lastRun: unknown; + lastResultRun: unknown; inflightRun: unknown; stakworkRunCreate: unknown; stakworkRunUpdate: unknown; @@ -87,6 +96,7 @@ function setupDb(overrides: Partial<{ ownedWorkspace = { id: "ws-1" }, membership = null, lastRun = null, + lastResultRun = null, inflightRun = null, stakworkRunCreate = makeRun("run-1"), stakworkRunUpdate = makeRun("run-1"), @@ -105,9 +115,10 @@ function setupDb(overrides: Partial<{ }, stakworkRun: { findFirst: vi.fn() - .mockResolvedValueOnce(lastRun) // cursor (COMPLETED run) - .mockResolvedValueOnce(inflightRun) // guard check - .mockResolvedValue(null), // subsequent users (cursor + guard → null) + .mockResolvedValueOnce(lastRun) // [0] cursor (COMPLETED run for since) + .mockResolvedValueOnce(lastResultRun) // [1] previous_recap (COMPLETED with result != null) + .mockResolvedValueOnce(inflightRun) // [2] guard check + .mockResolvedValue(null), // subsequent users create: vi.fn().mockResolvedValue(stakworkRunCreate), update: vi.fn().mockResolvedValue(stakworkRunUpdate), updateMany: vi.fn().mockResolvedValue({ count: reaperCount }), @@ -118,7 +129,7 @@ function setupDb(overrides: Partial<{ // ── vercel.json ────────────────────────────────────────────────────────────── describe("Daily Recap Cron — vercel.json configuration", () => { - it("should have daily-recap cron job configured", () => { + it("should have daily-recap cron scheduled hourly (0 * * * *)", () => { const vercelPath = path.join(process.cwd(), "vercel.json"); expect(fs.existsSync(vercelPath)).toBe(true); @@ -129,10 +140,7 @@ describe("Daily Recap Cron — vercel.json configuration", () => { (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); + expect(dailyCron.schedule).toBe("0 * * * *"); }); }); @@ -154,7 +162,7 @@ describe("executeScheduledActivityRecapRuns", () => { expect(mockedDb.stakworkRun.create).not.toHaveBeenCalled(); }); - it("skips users with empty activity feed", async () => { + it("skips users with empty activity feed (strict activity gate)", async () => { setupDb(); mockedGetUserActivityFeed.mockResolvedValue([]); @@ -203,6 +211,55 @@ describe("executeScheduledActivityRecapRuns", () => { expect(result.errors).toHaveLength(0); }); + it("dispatched vars contain exactly previous_recap, since, activity, window_start, webhookUrl", async () => { + setupDb({ lastResultRun: { result: "Prior recap text" } }); + mockedGetUserActivityFeed.mockResolvedValue(makeActivityItems(2)); + mockCreateBatchProjects.mockResolvedValue({ + data: { + ref_id: "ref-1", + projects: [{ name: "daily-recap-run-1", project_id: 1 }], + }, + }); + + const before = Date.now(); + await executeScheduledDailyRecapRuns(); + const after = Date.now(); + + const batchCall = mockCreateBatchProjects.mock.calls[0][0]; + const vars = batchCall[0].workflow_params.set_var.attributes.vars; + + expect(Object.keys(vars).sort()).toEqual( + ["activity", "previous_recap", "since", "webhookUrl", "window_start"].sort(), + ); + expect(vars.previous_recap).toBe("Prior recap text"); + expect(typeof vars.since).toBe("string"); + expect(typeof vars.activity).toBe("string"); + expect(typeof vars.window_start).toBe("string"); + expect(typeof vars.webhookUrl).toBe("string"); + + // window_start should be ~24h before dispatch time + const windowStart = new Date(vars.window_start as string).getTime(); + expect(windowStart).toBeGreaterThanOrEqual(before - 24 * 60 * 60 * 1000 - 1000); + expect(windowStart).toBeLessThanOrEqual(after - 24 * 60 * 60 * 1000 + 1000); + }); + + it("uses empty string for previous_recap when no prior result run exists", async () => { + setupDb({ lastResultRun: null }); + mockedGetUserActivityFeed.mockResolvedValue(makeActivityItems(1)); + mockCreateBatchProjects.mockResolvedValue({ + data: { + ref_id: "ref-1", + projects: [{ name: "daily-recap-run-1", project_id: 1 }], + }, + }); + + await executeScheduledDailyRecapRuns(); + + const batchCall = mockCreateBatchProjects.mock.calls[0][0]; + const vars = batchCall[0].workflow_params.set_var.attributes.vars; + expect(vars.previous_recap).toBe(""); + }); + it("splits >500 eligible users into exactly 2 batch calls", async () => { const users = Array.from({ length: 501 }, (_, i) => ({ id: `user-${i}` })); @@ -319,7 +376,7 @@ describe("Staleness reaper", () => { type: StakworkRunType.DAILY_RECAP, status: { in: [WorkflowStatus.PENDING, WorkflowStatus.IN_PROGRESS] }, }); - expect(call.where.createdAt).toHaveProperty("lt"); + expect(call.where?.createdAt).toHaveProperty("lt"); expect(call.data).toEqual({ status: WorkflowStatus.FAILED }); }); @@ -366,7 +423,7 @@ describe("COMPLETED-only cursor", () => { expect(cursorCall?.where).toMatchObject({ status: WorkflowStatus.COMPLETED }); }); - it("defaults since to 24h ago when no prior COMPLETED run exists", async () => { + it("defaults since to ~24h ago when no prior COMPLETED run exists", async () => { setupDb({ lastRun: null }); mockedGetUserActivityFeed.mockResolvedValue(makeActivityItems(1)); mockCreateBatchProjects.mockResolvedValue({ @@ -377,17 +434,17 @@ describe("COMPLETED-only cursor", () => { await executeScheduledActivityRecapRuns(); const after = Date.now(); - // The `since` value passed to getUserActivityFeed is derived from days (1–30) - // days = ceil((now - since) / 86400000); if since = 24h ago, days = 1 + // since is passed directly now (not days) const activityCall = mockedGetUserActivityFeed.mock.calls[0][0]; - expect(activityCall.days).toBe(1); - void before; void after; // suppress unused warning + expect(activityCall.since).toBeInstanceOf(Date); + + const sinceMs = (activityCall.since as Date).getTime(); + expect(sinceMs).toBeGreaterThanOrEqual(before - 24 * 60 * 60 * 1000 - 1000); + expect(sinceMs).toBeLessThanOrEqual(after - 24 * 60 * 60 * 1000 + 1000); }); it("uses lastRun.createdAt as the since date when a COMPLETED run exists", async () => { - // Subtract slightly less than 2 full days so Math.ceil reliably gives 2 - // (exact 2 days can tick over to 3 due to milliseconds elapsed during test) - const twoDaysAgo = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000 + 5 * 60_000); + const twoDaysAgo = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000); setupDb({ lastRun: { createdAt: twoDaysAgo } }); mockedGetUserActivityFeed.mockResolvedValue(makeActivityItems(1)); mockCreateBatchProjects.mockResolvedValue({ @@ -397,7 +454,34 @@ describe("COMPLETED-only cursor", () => { await executeScheduledActivityRecapRuns(); const activityCall = mockedGetUserActivityFeed.mock.calls[0][0]; - expect(activityCall.days).toBe(2); + expect(activityCall.since).toEqual(twoDaysAgo); + }); + + it("passes since directly to getUserActivityFeed (not days)", async () => { + const cursor = new Date(Date.now() - 90 * 60 * 1000); // 90 min ago + setupDb({ lastRun: { createdAt: cursor } }); + mockedGetUserActivityFeed.mockResolvedValue(makeActivityItems(2)); + mockCreateBatchProjects.mockResolvedValue({ + data: { ref_id: "r", projects: [{ name: "daily-recap-run-1", project_id: 1 }] }, + }); + + await executeScheduledDailyRecapRuns(); + + const activityCall = mockedGetUserActivityFeed.mock.calls[0][0]; + expect(activityCall.since).toEqual(cursor); + expect(activityCall.days).toBeUndefined(); + }); + + it("logs since and zero count when skipping due to empty activity", async () => { + const cursor = new Date(Date.now() - 30 * 60 * 1000); + setupDb({ lastRun: { createdAt: cursor } }); + mockedGetUserActivityFeed.mockResolvedValue([]); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + + await executeScheduledDailyRecapRuns(); + + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("no activity since")); + logSpy.mockRestore(); }); }); @@ -469,8 +553,8 @@ describe("In-flight guard", () => { await executeScheduledActivityRecapRuns(); - // Second findFirst call is the guard - const guardCall = vi.mocked(mockedDb.stakworkRun.findFirst).mock.calls[1][0]; + // Guard is the 3rd findFirst call (index 2): cursor [0], lastResultRun [1], guard [2] + const guardCall = vi.mocked(mockedDb.stakworkRun.findFirst).mock.calls[2][0]; expect(guardCall?.where).toMatchObject({ type: StakworkRunType.DAILY_RECAP, status: { in: [WorkflowStatus.PENDING, WorkflowStatus.IN_PROGRESS] }, @@ -478,3 +562,29 @@ describe("In-flight guard", () => { expect(guardCall?.where?.createdAt).toHaveProperty("gte"); }); }); + +// ── window_start rolling-24h horizon ───────────────────────────────────────── + +describe("window_start rolling-24h horizon", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("window_start is always ~24h before dispatch (not read from DB)", async () => { + setupDb(); + mockedGetUserActivityFeed.mockResolvedValue(makeActivityItems(1)); + mockCreateBatchProjects.mockResolvedValue({ + data: { ref_id: "r", projects: [{ name: "daily-recap-run-1", project_id: 1 }] }, + }); + + const before = Date.now(); + await executeScheduledDailyRecapRuns(); + const after = Date.now(); + + const batchCall = mockCreateBatchProjects.mock.calls[0][0]; + const windowStart = new Date(batchCall[0].workflow_params.set_var.attributes.vars.window_start as string).getTime(); + + expect(windowStart).toBeGreaterThanOrEqual(before - 24 * 60 * 60 * 1000 - 1000); + expect(windowStart).toBeLessThanOrEqual(after - 24 * 60 * 60 * 1000 + 1000); + }); +}); diff --git a/src/__tests__/unit/services/roadmap/user-activity.test.ts b/src/__tests__/unit/services/roadmap/user-activity.test.ts index a9c73a1531..76e8cbd712 100644 --- a/src/__tests__/unit/services/roadmap/user-activity.test.ts +++ b/src/__tests__/unit/services/roadmap/user-activity.test.ts @@ -405,3 +405,76 @@ describe("getUserActivityFeed — completed field", () => { expect(plan?.completed).toBe(true); }); }); + +// ── since exact lower bound ──────────────────────────────────────────────── + +describe("getUserActivityFeed — since exact lower bound", () => { + it("uses since directly as cutoff when provided (not days-based)", async () => { + const since = new Date("2026-06-01T12:00:00Z"); + setupEmptyMocks(); + mockedDb.task.findMany.mockResolvedValue([]); + + await getUserActivityFeed({ userId: USER_ID, since }); + + const taskCall = mockedDb.task.findMany.mock.calls[0][0]; + // createdAt filter should be { gte: since, ... } — not a days-based date + expect(taskCall.where.createdAt.gte).toEqual(since); + }); + + it("since overrides the days parameter when both supplied", async () => { + const since = new Date("2026-06-01T00:00:00Z"); + setupEmptyMocks(); + mockedDb.task.findMany.mockResolvedValue([]); + + await getUserActivityFeed({ userId: USER_ID, since, days: 30 }); + + const taskCall = mockedDb.task.findMany.mock.calls[0][0]; + expect(taskCall.where.createdAt.gte).toEqual(since); + }); + + it("falls back to days-based cutoff when since is omitted", async () => { + setupEmptyMocks(); + mockedDb.task.findMany.mockResolvedValue([]); + + const before = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000 - 1000); + await getUserActivityFeed({ userId: USER_ID, days: 30 }); + const after = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000 + 1000); + + const taskCall = mockedDb.task.findMany.mock.calls[0][0]; + const cutoff: Date = taskCall.where.createdAt.gte; + expect(cutoff.getTime()).toBeGreaterThanOrEqual(before.getTime()); + expect(cutoff.getTime()).toBeLessThanOrEqual(after.getTime()); + }); + + it("passes since to feature.findMany createdAt filter", async () => { + const since = new Date("2026-05-15T08:00:00Z"); + setupEmptyMocks(); + mockedDb.feature.findMany.mockResolvedValue([]); + + await getUserActivityFeed({ userId: USER_ID, since }); + + const featureCall = mockedDb.feature.findMany.mock.calls[0][0]; + expect(featureCall.where.createdAt.gte).toEqual(since); + }); + + it("passes since to sharedConversation.findMany lastMessageAt filter", async () => { + const since = new Date("2026-05-20T10:00:00Z"); + setupEmptyMocks(); + + await getUserActivityFeed({ userId: USER_ID, since }); + + const convCall = mockedDb.sharedConversation.findMany.mock.calls[0][0]; + expect(convCall.where.lastMessageAt.gte).toEqual(since); + }); + + it("passes since to milestone.findMany updatedAt filter", async () => { + const since = new Date("2026-05-10T00:00:00Z"); + setupEmptyMocks(); + mockedDb.milestone.findMany.mockResolvedValue([]); + + await getUserActivityFeed({ userId: USER_ID, since }); + + const msCall = mockedDb.milestone.findMany.mock.calls[0][0]; + expect(msCall.where.updatedAt.gte).toEqual(since); + }); +}); diff --git a/src/app/api/user/daily-recap/route.ts b/src/app/api/user/daily-recap/route.ts index 332643d2dc..ddb67d96ee 100644 --- a/src/app/api/user/daily-recap/route.ts +++ b/src/app/api/user/daily-recap/route.ts @@ -21,6 +21,7 @@ export async function GET() { userId: session.user.id, type: StakworkRunType.DAILY_RECAP, status: WorkflowStatus.COMPLETED, + result: { not: null }, }, orderBy: { createdAt: "desc" }, select: { result: true, createdAt: true }, diff --git a/src/services/daily-recap-cron.ts b/src/services/daily-recap-cron.ts index 134469ac41..2721b2bc71 100644 --- a/src/services/daily-recap-cron.ts +++ b/src/services/daily-recap-cron.ts @@ -82,13 +82,15 @@ export async function executeScheduledActivityRecapRuns(): Promise = []; @@ -120,7 +122,7 @@ export async function executeScheduledActivityRecapRuns(): Promise ({ kind, @@ -190,7 +206,10 @@ export async function executeScheduledActivityRecapRuns(): Promise ({ + const batchPayload = chunk.map(({ run, workflowWebhookUrl, since, activity, previousRecap, windowStart, webhookUrl }) => ({ name: `daily-recap-${run.id}`, workflow_id: parseInt(workflowId), webhook_url: workflowWebhookUrl, @@ -218,8 +237,10 @@ export async function executeScheduledActivityRecapRuns(): Promise { const { userId, category = null, cursor = null } = params; @@ -80,7 +82,8 @@ export async function getUserActivityFeed(params: { const limit = clampLimit(params.limit ?? DEFAULT_LIMIT); const q = (params.q ?? "").trim(); - const cutoff = new Date(Date.now() - days * 24 * 60 * 60 * 1000); + // When `since` is provided use it directly as the lower bound; otherwise derive from `days`. + const cutoff = params.since ?? new Date(Date.now() - days * 24 * 60 * 60 * 1000); const runChat = !category || category === "chat"; const runPlan = !category || category === "plan"; diff --git a/src/services/stakwork-run.ts b/src/services/stakwork-run.ts index 319217e8b3..e4936a6cde 100644 --- a/src/services/stakwork-run.ts +++ b/src/services/stakwork-run.ts @@ -794,7 +794,7 @@ export async function processStakworkRunWebhook( run_id?: string; } ) { - const { result, project_status, project_id } = webhookData; + const { result, project_status, project_id, recap_unchanged } = webhookData; const { workspace_id, feature_id, type, run_id } = queryParams; logger.info("[webhook] processStakworkRunWebhook called", "stakwork-run", { @@ -891,6 +891,20 @@ export async function processStakworkRunWebhook( // Step 1: Atomic update to prevent race conditions // Include COMPLETED in the status filter so the result webhook can still // write data even if the status-only webhook (/api/stakwork/webhook) arrived first. + // + // DAILY_RECAP + recap_unchanged: mark COMPLETED (so createdAt advances the cursor) + // but do NOT overwrite result — leave it null so the card surfaces the last real recap. + const isRecapUnchanged = + run.type === StakworkRunType.DAILY_RECAP && recap_unchanged === true; + + if (isRecapUnchanged) { + logger.info( + "[webhook] DAILY_RECAP recap_unchanged — completing run without overwriting result", + "stakwork-run", + { runId: run.id }, + ); + } + const updateResult = await db.stakworkRun.updateMany({ where: { id: run.id, @@ -898,7 +912,7 @@ export async function processStakworkRunWebhook( }, data: { status, - result: serializedResult, + ...(isRecapUnchanged ? {} : { result: serializedResult }), dataType, updatedAt: new Date(), }, diff --git a/src/types/stakwork.ts b/src/types/stakwork.ts index cfc2e1be06..9dce64ba94 100644 --- a/src/types/stakwork.ts +++ b/src/types/stakwork.ts @@ -83,6 +83,7 @@ export const StakworkRunWebhookSchema = z.object({ project_status: z.string().optional(), project_id: z.number().optional(), project_output: z.record(z.string(), z.unknown()).optional(), + recap_unchanged: z.boolean().optional(), }); export const UpdateStakworkRunDecisionSchema = z.object({ diff --git a/vercel.json b/vercel.json index 8f58dda5fb..04e51ef9e0 100644 --- a/vercel.json +++ b/vercel.json @@ -8,7 +8,7 @@ }, "crons": [{ "path": "/api/cron/daily-recap", - "schedule": "0 9 * * *" + "schedule": "0 * * * *" }, { "path": "/api/cron/janitors",