diff --git a/src/components/admin/review-row.tsx b/src/components/admin/review-row.tsx index ed36d29..fe5c79e 100644 --- a/src/components/admin/review-row.tsx +++ b/src/components/admin/review-row.tsx @@ -3,10 +3,11 @@ import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react" import { createPortal } from "react-dom" import { useRouter } from "next/navigation" -import { ArrowRight, ArrowRightLeft, ChevronRight, GitMerge, Layers, Network, Pencil, PlusCircle, PlusSquare, Share2, Trash2, type LucideIcon } from "lucide-react" +import { ArrowRight, ArrowRightLeft, CheckCircle2, ChevronRight, GitMerge, Layers, Loader2, Network, Pencil, PlusCircle, PlusSquare, Share2, Trash2, Users, type LucideIcon } from "lucide-react" import { formatDateRelative } from "@/lib/date-format" import type { Review, ReviewStatus } from "@/lib/graph-api" -import { approveReview, dismissReview } from "@/lib/graph-api" +import { approveReview, dismissReview, triggerMergeWorkflow } from "@/lib/graph-api" +import { useStakworkRunStatus } from "@/lib/hooks/use-stakwork-run-status" import { cn, displayNodeType } from "@/lib/utils" import { Tooltip, @@ -558,6 +559,18 @@ export function ReviewRow({ const [dismissing, setDismissing] = useState(false) const [inlineError, setInlineError] = useState(null) + // ── Human Review dispatch (merge_nodes + pending only) ─────────────────────── + const isMergeReviewEligible = review.action_name === "merge_nodes" && review.status === "pending" + const { + status: humanReviewStatus, + isInFlight: humanReviewInFlight, + trigger: triggerHumanReview, + triggering: humanReviewTriggering, + } = useStakworkRunStatus( + isMergeReviewEligible ? review.ref_id : "__noop__", + "node_merge_review" + ) + // ── Merge-specific interactive state ──────────────────────────────────────── const [checkedSources, setCheckedSources] = useState>(new Set()) const [canonicalId, setCanonicalId] = useState("") @@ -748,6 +761,60 @@ export function ReviewRow({ onConfirm={(reason) => handleDismiss(reason)} minWidthClass="min-w-[68px]" /> + {/* Send for Human Review — merge_nodes + pending only */} + {isMergeReviewEligible && ( + + )} ) : ( diff --git a/src/components/layout/node-preview-panel.tsx b/src/components/layout/node-preview-panel.tsx index 9a221aa..5468f4b 100644 --- a/src/components/layout/node-preview-panel.tsx +++ b/src/components/layout/node-preview-panel.tsx @@ -23,7 +23,8 @@ import { cn, displayNodeType, formatCompactNumber } from "@/lib/utils" import { pickString, unescapeText, DISPLAY_KEY_FALLBACKS } from "@/lib/node-display" import { getStatusBadge, isBlockedStatus, isInProgress } from "@/lib/node-status" import type { GraphNode, GraphData, StakworkRun } from "@/lib/graph-api" -import { triggerDeepResearch, triggerEnrich, getLatestStakworkRun, getNode, isGraphData } from "@/lib/graph-api" +import { triggerDeepResearch, triggerEnrich, getNode, isGraphData } from "@/lib/graph-api" +import { useStakworkRunStatus, isInFlightStatus } from "@/lib/hooks/use-stakwork-run-status" import { getWatches, watchNode, unwatchNode } from "@/lib/watch-api" import { cookieStorage } from "@/lib/cookie-storage" import type { SchemaNode } from "@/lib/schema-types" @@ -788,127 +789,40 @@ export function NodePreviewPanel({ node, onBack, schemas }: NodePreviewPanelProp const [watched, setWatched] = useState(false) const [watchLoading, setWatchLoading] = useState(false) - // Deep Research state - type DeepResearchStatus = "PENDING" | "RUNNING" | "COMPLETED" | "FAILED" | "ERROR" | "HALTED" | null - const [deepResearchStatus, setDeepResearchStatus] = useState(null) - const [deepResearchLoading, setDeepResearchLoading] = useState(false) - const deepResearchPollRef = useRef | null>(null) + // Deep Research — shared hook + const { + status: deepResearchStatus, + isInFlight: deepResearchInFlight, + trigger: triggerDeepResearchRun, + triggering: deepResearchLoading, + } = useStakworkRunStatus( + DEEP_RESEARCH_NODE_TYPES.includes(currentNode.node_type) ? currentNode.ref_id : "__noop__", + "deep_research", + { onCompleted: () => setProbeNonce((n) => n + 1) } + ) - // Enrich state - const [enrichLoading, setEnrichLoading] = useState(false) - const [enrichStatus, setEnrichStatus] = useState(null) + // Enrich — shared hook const [enrichRunProjectId, setEnrichRunProjectId] = useState(null) - const enrichPollRef = useRef | null>(null) - - function isDeepResearchInFlight(status: DeepResearchStatus): boolean { - return status === "PENDING" || status === "RUNNING" - } - - function mapRunStatus(status: string): DeepResearchStatus { - const s = status.toUpperCase() - if (s === "PENDING" || s === "IN_PROGRESS" || s === "RUNNING") return "RUNNING" - if (s === "COMPLETED") return "COMPLETED" - if (s === "FAILED" || s === "ERROR" || s === "HALTED") return "FAILED" - return null - } - - function startDeepResearchPoll(refId: string) { - if (deepResearchPollRef.current) clearInterval(deepResearchPollRef.current) - deepResearchPollRef.current = setInterval(async () => { - try { - const run = await getLatestStakworkRun(refId, "deep_research") - if (!run) return - const mapped = mapRunStatus(run.status) - setDeepResearchStatus(mapped) - if (mapped !== "PENDING" && mapped !== "RUNNING") { - if (deepResearchPollRef.current) clearInterval(deepResearchPollRef.current) - deepResearchPollRef.current = null - if (mapped === "COMPLETED") { - // Trigger node refetch by bumping probeNonce - setProbeNonce((n) => n + 1) - } - } - } catch { - // silent — keep polling - } - }, 5000) - } - - // Hydrate deep research status on mount / node change - useEffect(() => { - setDeepResearchStatus(null) - if (!DEEP_RESEARCH_NODE_TYPES.includes(currentNode.node_type)) return - let cancelled = false - getLatestStakworkRun(currentNode.ref_id, "deep_research") - .then((run) => { - if (cancelled || !run) return - const mapped = mapRunStatus(run.status) - setDeepResearchStatus(mapped) - if (mapped === "PENDING" || mapped === "RUNNING") { - startDeepResearchPoll(currentNode.ref_id) - } - }) - .catch(() => {}) - return () => { - cancelled = true - if (deepResearchPollRef.current) { - clearInterval(deepResearchPollRef.current) - deepResearchPollRef.current = null - } - if (enrichPollRef.current) { - clearInterval(enrichPollRef.current) - enrichPollRef.current = null - } - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [currentNode.ref_id]) + const { + status: enrichStatus, + isInFlight: enrichInFlight, + trigger: triggerEnrichRun, + triggering: enrichLoading, + } = useStakworkRunStatus(currentNode.ref_id, "web_search_enrich", { + onCompleted: () => setProbeNonce((n) => n + 1), + onPoll: (run) => { if (run.project_id) setEnrichRunProjectId(run.project_id) }, + }) async function handleDeepResearch() { - if (deepResearchLoading || isDeepResearchInFlight(deepResearchStatus)) return - setDeepResearchLoading(true) - setDeepResearchStatus("PENDING") - try { - await triggerDeepResearch(currentNode.ref_id) - startDeepResearchPoll(currentNode.ref_id) - } catch { - setDeepResearchStatus("FAILED") - } finally { - setDeepResearchLoading(false) - } - } - - function startEnrichPoll(refId: string) { - if (enrichPollRef.current) clearInterval(enrichPollRef.current) - enrichPollRef.current = setInterval(async () => { - try { - const run = await getLatestStakworkRun(refId, "web_search_enrich") - if (!run) return - const mapped = mapRunStatus(run.status) - setEnrichStatus(mapped) - if (run.project_id) setEnrichRunProjectId(run.project_id) - if (mapped !== "PENDING" && mapped !== "RUNNING") { - if (enrichPollRef.current) clearInterval(enrichPollRef.current) - enrichPollRef.current = null - if (mapped === "COMPLETED") setProbeNonce((n) => n + 1) - } - } catch { - // silent — keep polling - } - }, 5000) + await triggerDeepResearchRun(() => triggerDeepResearch(currentNode.ref_id)) } async function handleEnrich() { - if (enrichLoading || isDeepResearchInFlight(enrichStatus)) return - setEnrichLoading(true) - setEnrichStatus("PENDING") - try { - await triggerEnrich(currentNode.ref_id) - startEnrichPoll(currentNode.ref_id) - } catch { - setEnrichStatus("FAILED") - } finally { - setEnrichLoading(false) - } + await triggerEnrichRun(async () => { + const result = await triggerEnrich(currentNode.ref_id) + // Capture project_id for the "View Enrich run" link if available + return result + }) } // Full reset (currentNode + history + scroll) only when a genuinely different @@ -1478,12 +1392,12 @@ export function NodePreviewPanel({ node, onBack, schemas }: NodePreviewPanelProp size="sm" variant="outline" className="w-full" - disabled={deepResearchLoading || isDeepResearchInFlight(deepResearchStatus)} + disabled={deepResearchLoading || deepResearchInFlight} onClick={handleDeepResearch} data-testid="deep-research-button" title="Deep Research this topic" > - {isDeepResearchInFlight(deepResearchStatus) ? ( + {deepResearchInFlight ? ( <> Researching… @@ -1493,7 +1407,7 @@ export function NodePreviewPanel({ node, onBack, schemas }: NodePreviewPanelProp Re-run Research - ) : (deepResearchStatus === "FAILED" || deepResearchStatus === "ERROR" || deepResearchStatus === "HALTED") ? ( + ) : (deepResearchStatus === "FAILED") ? ( <> Retry Research @@ -1515,12 +1429,12 @@ export function NodePreviewPanel({ node, onBack, schemas }: NodePreviewPanelProp size="sm" variant="outline" className="w-full" - disabled={enrichLoading || isDeepResearchInFlight(enrichStatus)} + disabled={enrichLoading || enrichInFlight} onClick={handleEnrich} data-testid="enrich-button" title="Enrich this node via web search" > - {isDeepResearchInFlight(enrichStatus) ? ( + {enrichInFlight ? ( <> Enriching… @@ -1530,7 +1444,7 @@ export function NodePreviewPanel({ node, onBack, schemas }: NodePreviewPanelProp Enriched - ) : (enrichStatus === "FAILED" || enrichStatus === "ERROR" || enrichStatus === "HALTED") ? ( + ) : (enrichStatus === "FAILED") ? ( <> Enrich failed diff --git a/src/lib/__tests__/node-preview-panel.test.tsx b/src/lib/__tests__/node-preview-panel.test.tsx index ece5f5d..4c10a13 100644 --- a/src/lib/__tests__/node-preview-panel.test.tsx +++ b/src/lib/__tests__/node-preview-panel.test.tsx @@ -2267,14 +2267,19 @@ describe("NodePreviewPanel – Enrich button", () => { it("shows 'View Enrich run' link once enrichRunProjectId is set via polling", async () => { vi.useFakeTimers({ shouldAdvanceTime: true }) try { - mockGetLatestStakworkRun - .mockResolvedValueOnce({ - ref_id: "enrich-run-1", - job_type: "web_search_enrich", - status: "RUNNING", - created_at: 0, - project_id: 42000, - }) + const runPayload = { + ref_id: "enrich-run-1", + job_type: "web_search_enrich", + status: "RUNNING", + created_at: 0, + project_id: 42000, + } + // beforeEach already sets mockResolvedValue(null); override for polling tick. + // mockImplementation lets us return runPayload only for web_search_enrich polls. + mockGetLatestStakworkRun.mockImplementation((_refId: string, jobType: string) => { + if (jobType === "web_search_enrich") return Promise.resolve(runPayload) + return Promise.resolve(null) + }) const node = makeEnrichNode("Location") mockApiGet.mockResolvedValue(makeGraphData(node)) diff --git a/src/lib/__tests__/reviews.test.tsx b/src/lib/__tests__/reviews.test.tsx index fecc109..f7b2b31 100644 --- a/src/lib/__tests__/reviews.test.tsx +++ b/src/lib/__tests__/reviews.test.tsx @@ -5,11 +5,20 @@ import React from "react" // ── Hoisted mocks ───────────────────────────────────────────────────────────── -const { mockApproveReview, mockDismissReview, mockListReviews, mockGetReviewNodeTypeCounts } = vi.hoisted(() => ({ +const { + mockApproveReview, + mockDismissReview, + mockListReviews, + mockGetReviewNodeTypeCounts, + mockTriggerMergeWorkflow, + mockGetLatestStakworkRun, +} = vi.hoisted(() => ({ mockApproveReview: vi.fn(), mockDismissReview: vi.fn(), mockListReviews: vi.fn(), mockGetReviewNodeTypeCounts: vi.fn(), + mockTriggerMergeWorkflow: vi.fn(), + mockGetLatestStakworkRun: vi.fn(), })) vi.mock("@/lib/graph-api", async (importOriginal) => { @@ -20,6 +29,8 @@ vi.mock("@/lib/graph-api", async (importOriginal) => { dismissReview: (...args: unknown[]) => mockDismissReview(...args), listReviews: (...args: unknown[]) => mockListReviews(...args), getReviewNodeTypeCounts: (...args: unknown[]) => mockGetReviewNodeTypeCounts(...args), + triggerMergeWorkflow: (...args: unknown[]) => mockTriggerMergeWorkflow(...args), + getLatestStakworkRun: (...args: unknown[]) => mockGetLatestStakworkRun(...args), } }) @@ -88,6 +99,9 @@ describe("ReviewRow", () => { beforeEach(() => { mockApproveReview.mockReset() mockDismissReview.mockReset() + // ReviewRow now calls getLatestStakworkRun on mount for merge_nodes+pending rows. + // Default to null (no prior run) so existing tests aren't broken. + mockGetLatestStakworkRun.mockResolvedValue(null) }) // ── Status badge colours ──────────────────────────────────────────────────── @@ -1183,6 +1197,255 @@ describe("ReviewRow merge_nodes interactive controls", () => { }) }) +// ── Send for Human Review button ────────────────────────────────────────────── + +describe("ReviewRow — Send for Human Review button", () => { + const noop = () => {} + + beforeEach(() => { + mockApproveReview.mockReset() + mockDismissReview.mockReset() + mockTriggerMergeWorkflow.mockReset() + mockGetLatestStakworkRun.mockReset() + // Default: no prior run exists (returns null = fresh) + mockGetLatestStakworkRun.mockResolvedValue(null) + }) + + function makeMergeReview(overrides: Partial = {}): import("@/lib/graph-api").Review { + return makeReview({ + action_name: "merge_nodes", + status: "pending", + action_payload: { from: ["n2"], to: "n1" }, + ...overrides, + }) + } + + // ── Visibility gating ────────────────────────────────────────────────────── + + it("renders Human Review button for merge_nodes + pending rows", async () => { + const { findByTestId } = render( + + ) + const btn = await findByTestId("send-for-human-review-btn") + expect(btn).toBeTruthy() + }) + + it("does NOT render Human Review button for non-merge_nodes pending rows", async () => { + const { queryByTestId } = render( + + ) + // Wait for hydration effect to settle + await waitFor(() => { + expect(queryByTestId("send-for-human-review-btn")).toBeNull() + }) + }) + + it("does NOT render Human Review button for merge_nodes rows that are not pending", async () => { + const { queryByTestId } = render( + + ) + await waitFor(() => { + expect(queryByTestId("send-for-human-review-btn")).toBeNull() + }) + }) + + // ── Click + pending/running state ───────────────────────────────────────── + + it("clicking triggers triggerMergeWorkflow and shows Sending… spinner", async () => { + const user = userEvent.setup() + // Never resolves during this test → stays in pending state + mockTriggerMergeWorkflow.mockReturnValue(new Promise(() => {})) + + const { findByTestId } = render( + + ) + const btn = await findByTestId("send-for-human-review-btn") + await user.click(btn) + + await waitFor(() => { + expect(mockTriggerMergeWorkflow).toHaveBeenCalledWith("rv-test-001") + }) + // Button text should show Sending… state + await waitFor(() => { + const updatedBtn = document.querySelector("[data-testid='send-for-human-review-btn']") + expect(updatedBtn?.textContent).toMatch(/Sending/i) + expect(updatedBtn).toHaveProperty("disabled", true) + }) + }) + + it("shows Human Review button text in fresh state (no prior run)", async () => { + const { findByTestId } = render( + + ) + const btn = await findByTestId("send-for-human-review-btn") + expect(btn.textContent).toMatch(/Human Review/i) + expect(btn).toHaveProperty("disabled", false) + }) + + // ── Mount-time hydration ─────────────────────────────────────────────────── + + it("hydrates to COMPLETED state from prior session (getLatestStakworkRun returns COMPLETED run)", async () => { + mockGetLatestStakworkRun.mockResolvedValue({ + ref_id: "mock-run-123", + job_type: "node_merge_review", + status: "COMPLETED", + created_at: Math.floor(Date.now() / 1000), + }) + + const { findByTestId } = render( + + ) + const btn = await findByTestId("send-for-human-review-btn") + + await waitFor(() => { + expect(btn.textContent).toMatch(/Sent/i) + // COMPLETED: not disabled by the HTML attribute, but click is a no-op + // (disabled only when humanReviewInFlight || humanReviewTriggering) + expect(btn).not.toHaveProperty("disabled", true) + }) + }) + + it("hydrates to FAILED state from prior session and button shows Retry Review", async () => { + mockGetLatestStakworkRun.mockResolvedValue({ + ref_id: "mock-run-456", + job_type: "node_merge_review", + status: "FAILED", + created_at: Math.floor(Date.now() / 1000), + }) + + const { findByTestId } = render( + + ) + const btn = await findByTestId("send-for-human-review-btn") + + await waitFor(() => { + expect(btn.textContent).toMatch(/Retry Review/i) + expect(btn).toHaveProperty("disabled", false) + }) + }) + + it("hydration fetch error leaves button in fresh (un-triggered) state — neutral, not a failure", async () => { + mockGetLatestStakworkRun.mockRejectedValue(new Error("network error")) + + const { findByTestId } = render( + + ) + const btn = await findByTestId("send-for-human-review-btn") + + await waitFor(() => { + // Should NOT show Retry Review or Sent — just the fresh un-triggered text + expect(btn.textContent).toMatch(/Human Review/i) + expect(btn).toHaveProperty("disabled", false) + }) + }) + + it("hydrates to RUNNING and shows Sending… (button disabled while in-flight)", async () => { + mockGetLatestStakworkRun.mockResolvedValueOnce({ + ref_id: "mock-run-789", + job_type: "node_merge_review", + status: "RUNNING", + created_at: Math.floor(Date.now() / 1000), + }) + // Subsequent polls never resolve (keeps running) + mockGetLatestStakworkRun.mockReturnValue(new Promise(() => {})) + + const { findByTestId } = render( + + ) + const btn = await findByTestId("send-for-human-review-btn") + + await waitFor(() => { + expect(btn.textContent).toMatch(/Sending/i) + expect(btn).toHaveProperty("disabled", true) + }) + }) + + // ── Re-triggering after FAILED ───────────────────────────────────────────── + + it("re-triggers after a FAILED run (button not permanently locked)", async () => { + const user = userEvent.setup() + mockGetLatestStakworkRun.mockResolvedValue({ + ref_id: "mock-run-fail", + job_type: "node_merge_review", + status: "FAILED", + created_at: Math.floor(Date.now() / 1000), + }) + mockTriggerMergeWorkflow.mockReturnValue(new Promise(() => {})) + + const { findByTestId } = render( + + ) + const btn = await findByTestId("send-for-human-review-btn") + + // Wait for hydration to show FAILED state + await waitFor(() => { + expect(btn.textContent).toMatch(/Retry Review/i) + }) + + // Click to re-trigger + await user.click(btn) + await waitFor(() => { + expect(mockTriggerMergeWorkflow).toHaveBeenCalledWith("rv-test-001") + }) + }) + + // ── Multiple simultaneous rows poll independently ───────────────────────── + + it("two pending merge rows each show the Human Review button independently", async () => { + mockGetLatestStakworkRun.mockResolvedValue(null) + + const review1 = makeMergeReview({ ref_id: "rv-a" }) + const review2 = makeMergeReview({ ref_id: "rv-b" }) + + const { container } = render( +
+ + +
+ ) + + await waitFor(() => { + const btns = container.querySelectorAll("[data-testid='send-for-human-review-btn']") + expect(btns).toHaveLength(2) + }) + }) + + it("hydration calls getLatestStakworkRun with the correct ref_id and job_type", async () => { + const { findByTestId } = render( + + ) + await findByTestId("send-for-human-review-btn") + + await waitFor(() => { + // Check the first two args (ref_id + job_type); third arg is AbortSignal + const calls = mockGetLatestStakworkRun.mock.calls + const matched = calls.some( + (c: unknown[]) => c[0] === "rv-specific-id" && c[1] === "node_merge_review" + ) + expect(matched).toBe(true) + }) + }) + + // ── Approve/Dismiss unaffected ───────────────────────────────────────────── + + it("Approve and Dismiss buttons remain visible alongside the Human Review button", async () => { + const { findByTestId, getByText } = render( + + ) + await findByTestId("send-for-human-review-btn") + expect(getByText("Merge")).toBeTruthy() + expect(getByText("Dismiss")).toBeTruthy() + }) +}) + // ── ReviewsPage page-fallback guard (pure logic) ───────────────────────────── describe("ReviewsPage page-fallback guard logic", () => { diff --git a/src/lib/graph-api.ts b/src/lib/graph-api.ts index b99171a..ae6ccc2 100644 --- a/src/lib/graph-api.ts +++ b/src/lib/graph-api.ts @@ -672,6 +672,24 @@ export async function triggerDeepResearch( ) } +// Admin-only — triggers NodeMergeReview workflow in Stakwork; no payment gate +export async function triggerMergeWorkflow( + refId: string, + signal?: AbortSignal +): Promise<{ stakwork_run_ref_id: string }> { + if (isMocksEnabled()) { + // Reset poll counter keyed to "node_merge_review" job type (no L402 simulation) + _mockDeepResearchPollCounts[refId + "_merge_review"] = 0 + return { stakwork_run_ref_id: "mock-merge-review-run-" + refId } + } + return api.post<{ stakwork_run_ref_id: string }>( + `/v2/reviews/${refId}/trigger-merge-workflow`, + {}, + undefined, + signal + ) +} + // Admin-only enrich (web search) — no payment gate export async function triggerEnrich( refId: string, @@ -696,10 +714,20 @@ export async function getLatestStakworkRun( signal?: AbortSignal ): Promise { if (isMocksEnabled()) { - const mockKey = jobType === "web_search_enrich" ? refId + "_enrich" : refId + const mockKey = + jobType === "web_search_enrich" + ? refId + "_enrich" + : jobType === "node_merge_review" + ? refId + "_merge_review" + : refId const count = (_mockDeepResearchPollCounts[mockKey] ?? 0) + 1 _mockDeepResearchPollCounts[mockKey] = count - const runPrefix = jobType === "web_search_enrich" ? "mock-enrich-run-" : "mock-deep-run-" + const runPrefix = + jobType === "web_search_enrich" + ? "mock-enrich-run-" + : jobType === "node_merge_review" + ? "mock-merge-review-run-" + : "mock-deep-run-" if (count <= 2) { return { ref_id: runPrefix + refId, diff --git a/src/lib/hooks/use-stakwork-run-status.ts b/src/lib/hooks/use-stakwork-run-status.ts new file mode 100644 index 0000000..d9f5bde --- /dev/null +++ b/src/lib/hooks/use-stakwork-run-status.ts @@ -0,0 +1,172 @@ +"use client" + +import { useState, useEffect, useRef, useCallback } from "react" +import { getLatestStakworkRun } from "@/lib/graph-api" + +// ── Types ───────────────────────────────────────────────────────────────────── + +export type StakworkRunStatus = + | "PENDING" + | "RUNNING" + | "COMPLETED" + | "FAILED" + | "ERROR" + | "HALTED" + | null + +export interface UseStakworkRunStatusOptions { + /** + * Called when the run reaches COMPLETED so the caller can react + * (e.g. bump a nonce to re-fetch node data). + */ + onCompleted?: () => void + /** + * Called on each successful poll with the raw StakworkRun object, + * useful for capturing metadata like `project_id` from the run. + */ + onPoll?: (run: import("@/lib/graph-api").StakworkRun) => void + /** Poll interval in milliseconds (default 5 000). */ + pollIntervalMs?: number +} + +export interface UseStakworkRunStatusResult { + /** Current mapped run status, or null if no run exists / not yet hydrated. */ + status: StakworkRunStatus + /** True during the initial mount-time hydration fetch. */ + loading: boolean + /** True while the run is PENDING or RUNNING (in-flight). */ + isInFlight: boolean + /** + * Call this to trigger the workflow. Pass a function that calls the API and + * returns a run ref id (e.g. `() => triggerMergeWorkflow(refId)`). + * The hook sets status to PENDING and starts polling automatically. + */ + trigger: (triggerFn: () => Promise<{ stakwork_run_ref_id: string }>) => Promise + /** Whether a trigger call is currently in flight (loading spinner). */ + triggering: boolean +} + +// ── Status normalizer ──────────────────────────────────────────────────────── + +export function mapRunStatus(raw: string): StakworkRunStatus { + const s = raw.toUpperCase() + if (s === "PENDING" || s === "IN_PROGRESS" || s === "RUNNING") return "RUNNING" + if (s === "COMPLETED") return "COMPLETED" + if (s === "FAILED" || s === "ERROR" || s === "HALTED") return "FAILED" + return null +} + +export function isInFlightStatus(status: StakworkRunStatus): boolean { + return status === "PENDING" || status === "RUNNING" +} + +// ── Hook ────────────────────────────────────────────────────────────────────── + +/** + * Shared hook for Stakwork run lifecycle: mount-time hydration + 5-second + * polling while pending/running, terminal-state cleanup, and a trigger helper. + * + * Used by NodePreviewPanel (deep research / enrich) and ReviewRow + * (human-review dispatch). + */ +export function useStakworkRunStatus( + refId: string, + jobType: string, + options: UseStakworkRunStatusOptions = {} +): UseStakworkRunStatusResult { + const { onCompleted, onPoll, pollIntervalMs = 5000 } = options + + const [status, setStatus] = useState(null) + const [loading, setLoading] = useState(false) + const [triggering, setTriggering] = useState(false) + + const pollRef = useRef | null>(null) + const onCompletedRef = useRef(onCompleted) + onCompletedRef.current = onCompleted + const onPollRef = useRef(onPoll) + onPollRef.current = onPoll + + function stopPoll() { + if (pollRef.current) { + clearInterval(pollRef.current) + pollRef.current = null + } + } + + function startPoll(id: string) { + stopPoll() + pollRef.current = setInterval(async () => { + try { + const run = await getLatestStakworkRun(id, jobType) + if (!run) return + const mapped = mapRunStatus(run.status) + setStatus(mapped) + onPollRef.current?.(run) + if (!isInFlightStatus(mapped)) { + stopPoll() + if (mapped === "COMPLETED") { + onCompletedRef.current?.() + } + } + } catch { + // silent — keep polling + } + }, pollIntervalMs) + } + + // Mount-time hydration (and re-run when refId changes) + useEffect(() => { + setStatus(null) + let cancelled = false + setLoading(true) + + getLatestStakworkRun(refId, jobType) + .then((run) => { + if (cancelled) return + if (!run) return + const mapped = mapRunStatus(run.status) + setStatus(mapped) + if (isInFlightStatus(mapped)) { + startPoll(refId) + } + }) + .catch(() => { + // Hydration fetch error — leave status as null (neutral, retry-safe) + }) + .finally(() => { + if (!cancelled) setLoading(false) + }) + + return () => { + cancelled = true + stopPoll() + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [refId, jobType]) + + const trigger = useCallback( + async (triggerFn: () => Promise<{ stakwork_run_ref_id: string }>) => { + if (isInFlightStatus(status)) return + setTriggering(true) + setStatus("PENDING") + try { + await triggerFn() + startPoll(refId) + } catch { + setStatus("FAILED") + } finally { + setTriggering(false) + } + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [refId, jobType, status] + ) + + return { + status, + loading, + isInFlight: isInFlightStatus(status), + trigger, + triggering, + } +}