diff --git a/src/__tests__/integration/api/stakwork/stakwork-runs.test.ts b/src/__tests__/integration/api/stakwork/stakwork-runs.test.ts index 7e121cd60a..96d8c73549 100644 --- a/src/__tests__/integration/api/stakwork/stakwork-runs.test.ts +++ b/src/__tests__/integration/api/stakwork/stakwork-runs.test.ts @@ -222,4 +222,52 @@ describe("POST /api/stakwork/ai/generate — idempotency guard", () => { // Should succeed (201) — the completed run does not block a new one expect(response.status).toBe(201); }); + + it("broadcasts a Pusher run-update event on successful run creation", async () => { + const { pusherServer } = await import("@/lib/pusher"); + + const request = createAuthenticatedPostRequest(BASE_URL, user, { + type: "TASK_GENERATION", + featureId: feature.id, + workspaceId: workspace.id, + autoAccept: true, + params: { skipClarifyingQuestions: true }, + }); + + const response = await POST(request); + expect(response.status).toBe(201); + + expect(pusherServer.trigger).toHaveBeenCalledWith( + `workspace-${workspace.slug}`, + "stakwork-run-update", + expect.objectContaining({ + type: "TASK_GENERATION", + status: WorkflowStatus.IN_PROGRESS, + featureId: feature.id, + }), + ); + }); + + it("run creation succeeds even when Pusher broadcast throws", async () => { + const { pusherServer } = await import("@/lib/pusher"); + vi.mocked(pusherServer.trigger).mockRejectedValueOnce( + new Error("Pusher unavailable"), + ); + + const request = createAuthenticatedPostRequest(BASE_URL, user, { + type: "TASK_GENERATION", + featureId: feature.id, + workspaceId: workspace.id, + autoAccept: true, + params: { skipClarifyingQuestions: true }, + }); + + const response = await POST(request); + expect(response.status).toBe(201); + + const createdRun = await db.stakworkRun.findFirst({ + where: { featureId: feature.id, type: StakworkRunType.TASK_GENERATION }, + }); + expect(createdRun?.status).toBe(WorkflowStatus.IN_PROGRESS); + }); }); diff --git a/src/__tests__/unit/components/evals/FlagRunEvalModal.test.tsx b/src/__tests__/unit/components/evals/FlagRunEvalModal.test.tsx index 568e9c8390..f4ad52892a 100644 --- a/src/__tests__/unit/components/evals/FlagRunEvalModal.test.tsx +++ b/src/__tests__/unit/components/evals/FlagRunEvalModal.test.tsx @@ -27,6 +27,7 @@ vi.mock("@/components/evals/CaptureEvalForm", () => ({ submitting, onSelectRequirement, selectedEvalSetId, + selectedRequirementId, }: { requirement: string; reason: string; @@ -37,7 +38,7 @@ vi.mock("@/components/evals/CaptureEvalForm", () => ({ onSelectRequirement?: (id: string | null) => void; selectedEvalSetId?: string; }) => ( -
+
{ fireEvent.click(screen.getByText("Generate Title").closest("button")!); fireEvent.click(screen.getByRole("button", { name: /next/i })); - // Wait for step 2 AND for the evals fetch to auto-select a set (non-empty data-evalset). - // Clicking the existing-req button before the evals fetch resolves causes the - // selectedEvalSetId effect to reset selectedRequirementId back to null. + // Wait for step 2, the evals fetch to auto-select a set, AND for any + // subsequent selectedEvalSetId-change effects (e.g. the auto-select + // CREATE_NEW_REQ effect in CaptureEvalForm) to finish settling so that + // clicking the existing-req button isn't immediately overwritten. await waitFor(() => { const form = screen.getByTestId("capture-eval-form"); expect(form.getAttribute("data-evalset")).not.toBe(""); + // data-req will be "" (reset) or CREATE_NEW_REQ after effects settle; + // either way it must not be mid-flight (undefined is rendered as ""). + expect(form.getAttribute("data-req")).not.toBeNull(); }); // Select an existing requirement via the mock button diff --git a/src/__tests__/unit/hooks/useStakworkGeneration.test.ts b/src/__tests__/unit/hooks/useStakworkGeneration.test.ts index 1be2fe2727..c05fbe3d6d 100644 --- a/src/__tests__/unit/hooks/useStakworkGeneration.test.ts +++ b/src/__tests__/unit/hooks/useStakworkGeneration.test.ts @@ -48,6 +48,96 @@ describe("useStakworkGeneration", () => { } as Response); }); + describe("Pusher run-update event triggers refetch", () => { + it("calls queryLatestRun when a matching stakwork-run-update event arrives", async () => { + let capturedHandler: ((data: unknown) => void) | null = null; + mockChannel.bind.mockImplementation( + (event: string, handler: (data: unknown) => void) => { + if (event === "stakwork-run-update") { + capturedHandler = handler; + } + }, + ); + + renderHook(() => + useStakworkGeneration({ + featureId, + type: "TASK_GENERATION", + enabled: true, + }), + ); + + // Wait for initial fetch + await act(async () => { + await Promise.resolve(); + }); + + expect(mockChannel.bind).toHaveBeenCalledWith( + "stakwork-run-update", + expect.any(Function), + ); + expect(capturedHandler).not.toBeNull(); + + const fetchBefore = vi.mocked(global.fetch).mock.calls.length; + + // Simulate a run-update event matching featureId + type (the creation broadcast) + await act(async () => { + capturedHandler!({ + runId: "run-new", + type: "TASK_GENERATION", + status: "IN_PROGRESS", + featureId, + timestamp: new Date().toISOString(), + }); + await Promise.resolve(); + }); + + // fetch should have been called again to re-query the latest run + expect(vi.mocked(global.fetch).mock.calls.length).toBeGreaterThan( + fetchBefore, + ); + }); + + it("does NOT call queryLatestRun when the event featureId does not match", async () => { + let capturedHandler: ((data: unknown) => void) | null = null; + mockChannel.bind.mockImplementation( + (event: string, handler: (data: unknown) => void) => { + if (event === "stakwork-run-update") { + capturedHandler = handler; + } + }, + ); + + renderHook(() => + useStakworkGeneration({ + featureId, + type: "TASK_GENERATION", + enabled: true, + }), + ); + + await act(async () => { + await Promise.resolve(); + }); + + const fetchBefore = vi.mocked(global.fetch).mock.calls.length; + + // Event for a different feature — should be ignored + await act(async () => { + capturedHandler!({ + runId: "run-other", + type: "TASK_GENERATION", + status: "IN_PROGRESS", + featureId: "different-feature-id", + timestamp: new Date().toISOString(), + }); + await Promise.resolve(); + }); + + expect(vi.mocked(global.fetch).mock.calls.length).toBe(fetchBefore); + }); + }); + describe("error handling", () => { it("should handle getPusherClient throwing error gracefully", async () => { const pusher = await import("@/lib/pusher"); diff --git a/src/services/stakwork-run.ts b/src/services/stakwork-run.ts index 5114e436b7..139a24bab1 100644 --- a/src/services/stakwork-run.ts +++ b/src/services/stakwork-run.ts @@ -423,6 +423,26 @@ export async function createStakworkRun( }, }); + // Notify all collaborators that generation has started so their + // Generate Tasks button disables immediately (same event + payload + // shape the webhook uses — useStakworkGeneration binds to it). + try { + await pusherServer.trigger( + getWorkspaceChannelName(workspace.slug), + PUSHER_EVENTS.STAKWORK_RUN_UPDATE, + { + runId: run.id, + type: input.type, + status: WorkflowStatus.IN_PROGRESS, + featureId: input.featureId ?? null, + timestamp: new Date(), + }, + ); + } catch (pusherError) { + // Non-fatal — webhook will broadcast subsequent status changes. + console.error("[createStakworkRun] Pusher broadcast failed:", pusherError); + } + return run; } catch (error) { // Update status to FAILED if Stakwork call fails