diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index 86230267..f0364d2f 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -23,7 +23,13 @@ jobs: - name: Check changed paths id: changes run: | - changed=$(git diff --name-only origin/main...HEAD 2>/dev/null || echo "") + if [[ "$GITHUB_EVENT_NAME" == "workflow_dispatch" ]]; then + echo "web=true" >> "$GITHUB_OUTPUT" + echo "desktop=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + + changed=$(git diff --name-only origin/main...HEAD) echo "web=$(echo "$changed" | grep -qE '^(apps/web/|scripts/)' && echo true || echo false)" >> "$GITHUB_OUTPUT" echo "desktop=$(echo "$changed" | grep -qE '^apps/desktop/' && echo true || echo false)" >> "$GITHUB_OUTPUT" diff --git a/.github/workflows/pr-governance.yml b/.github/workflows/pr-governance.yml index 9c0b2e6c..acfcef5f 100644 --- a/.github/workflows/pr-governance.yml +++ b/.github/workflows/pr-governance.yml @@ -69,11 +69,6 @@ jobs: check_package() { local pkg="$1" - if ! jq empty "$pkg" 2>/dev/null; then - echo "::warning file=${pkg}::Malformed package.json — skipped" - return 1 - fi - local unpinned unpinned=$(jq -r ' [(.dependencies // {}), (.devDependencies // {})] @@ -103,6 +98,11 @@ jobs: -not -path '*/.pnpm/*' \ | sort); do + if ! jq empty "$pkg" 2>/dev/null; then + echo "::warning file=${pkg}::Malformed package.json — skipped" + continue + fi + deps=$(jq -r '(.dependencies // {} | length) + (.devDependencies // {} | length)' "$pkg" 2>/dev/null) if [[ "$deps" == "0" ]] || [[ -z "$deps" ]]; then continue @@ -136,8 +136,8 @@ jobs: - name: Get changed files id: changes run: | - changed=$(git diff --name-only --diff-filter=ACR origin/main...HEAD 2>/dev/null \ - || git diff --name-only --diff-filter=ACR HEAD~1 2>/dev/null \ + changed=$(git diff --name-only --diff-filter=ACMR origin/main...HEAD 2>/dev/null \ + || git diff --name-only --diff-filter=ACMR HEAD~1 2>/dev/null \ || echo "") EOF_MARKER=$(dd if=/dev/urandom bs=15 count=1 2>/dev/null | base64) echo "files<<${EOF_MARKER}" >> "$GITHUB_OUTPUT" @@ -146,6 +146,8 @@ jobs: - name: Check for sensitive files if: steps.changes.outputs.files != '' + env: + CHANGED_FILES: ${{ steps.changes.outputs.files }} run: | sensitive_patterns=( '\.env$' @@ -170,7 +172,7 @@ jobs: ) found=0 - changed_files="${{ steps.changes.outputs.files }}" + changed_files="$CHANGED_FILES" for pattern in "${sensitive_patterns[@]}"; do matches=$(echo "$changed_files" | grep -iE "$pattern" || true) @@ -192,8 +194,10 @@ jobs: - name: Scan for hardcoded secrets if: steps.changes.outputs.files != '' + env: + CHANGED_FILES: ${{ steps.changes.outputs.files }} run: | - changed_files="${{ steps.changes.outputs.files }}" + changed_files="$CHANGED_FILES" # Only scan text files scannable=$(echo "$changed_files" | grep -vE '\.(png|jpg|jpeg|gif|ico|svg|woff2?|ttf|eot|mp[34]|webm|webp|zip|tar|gz|lock)$' || true) @@ -226,10 +230,10 @@ jobs: 'glpat-[a-zA-Z0-9_-]{20,}' 'xox[bsapr]-[a-zA-Z0-9-]+' 'BEGIN (RSA |EC |DSA |OPENSSH )?PRIVATE KEY' - 'password\s*[:=]\s*["\x27][^"\x27]{8,}' - 'secret\s*[:=]\s*["\x27][^"\x27]{8,}' - 'api[_-]?key\s*[:=]\s*["\x27][^"\x27]{8,}' - 'access[_-]?token\s*[:=]\s*["\x27][^"\x27]{8,}' + "password\\s*[:=]\\s*[\"'][^\"']{8,}" + "secret\\s*[:=]\\s*[\"'][^\"']{8,}" + "api[_-]?key\\s*[:=]\\s*[\"'][^\"']{8,}" + "access[_-]?token\\s*[:=]\\s*[\"'][^\"']{8,}" ) for pattern in "${secret_patterns[@]}"; do diff --git a/.github/workflows/pr-workflows.test.mjs b/.github/workflows/pr-workflows.test.mjs new file mode 100644 index 00000000..36019a1d --- /dev/null +++ b/.github/workflows/pr-workflows.test.mjs @@ -0,0 +1,75 @@ +import assert from 'node:assert/strict' +import { spawnSync } from 'node:child_process' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { readFile } from 'node:fs/promises' +import { join } from 'node:path' +import test from 'node:test' + +const governance = await readFile(new URL('./pr-governance.yml', import.meta.url), 'utf8') +const checks = await readFile(new URL('./pr-checks.yml', import.meta.url), 'utf8') + +test('SC-2: security scanning matches single-quoted secret assignments', () => { + const start = governance.indexOf(' secret_patterns=(') + const end = governance.indexOf(' )', start) + ' )'.length + const secretPatterns = governance.slice(start, end) + const script = `${secretPatterns} +for pattern in "\${secret_patterns[@]}"; do + if printf '%s\\n' "$1" | grep -iE "$pattern" >/dev/null; then + exit 0 + fi +done +exit 1` + + // Assemble the sample at runtime: the literal assignment would itself match + // the secret patterns this scan is checking for. + const fakeValue = 'hunter2secret' + for (const quote of ["'", '"']) { + const assignment = `password = ${quote}${fakeValue}${quote}` + const result = spawnSync('bash', ['-c', script, '--', assignment]) + assert.equal(result.status, 0, `secret scanner must match ${assignment}`) + } +}) + +test('SC-2: sensitive-file scanning includes modified paths', () => { + assert.match(governance, /--diff-filter=ACMR/) +}) + +test('SC-4: PR-controlled filenames are passed to security scan steps through the environment', () => { + assert.doesNotMatch(governance, /changed_files="\$\{\{ steps\.changes\.outputs\.files \}\}"/) + assert.equal( + governance.match(/CHANGED_FILES: \$\{\{ steps\.changes\.outputs\.files \}\}/g)?.length, + 2, + ) + assert.match(governance, /"\$CHANGED_FILES"/) +}) + +test('SC-6: manual PR-check runs enable both application suites and do not hide diff failures', () => { + assert.match(checks, /\$GITHUB_EVENT_NAME" == "workflow_dispatch"/) + assert.doesNotMatch(checks, /git diff --name-only origin\/main\.\.\.HEAD 2>\/dev\/null \|\| echo ""/) +}) + +test('SC-10: malformed manifests warn without failing the pinned-version check', () => { + const marker = ' - name: Check dependency version pinning\n run: |\n' + const start = governance.indexOf(marker) + marker.length + const end = governance.indexOf('\n\n security-scan:', start) + const script = governance.slice(start, end).replace(/^ /gm, '') + const fixtureDirectory = mkdtempSync(join(process.cwd(), '.pr-workflows-test-')) + + try { + writeFileSync(join(fixtureDirectory, 'package.json'), '{"dependencies":') + + const result = spawnSync('bash', ['-e', '-o', 'pipefail', '-c', script], { + cwd: fixtureDirectory, + encoding: 'utf8', + }) + + assert.equal( + result.status, + 0, + `expected malformed manifests to be non-fatal; stdout: ${result.stdout || ''}; stderr: ${result.stderr || ''}`, + ) + assert.match(result.stdout, /::warning file=\.\/package\.json::Malformed package\.json — skipped/) + } finally { + rmSync(fixtureDirectory, { force: true, recursive: true }) + } +}) diff --git a/apps/web/src/components/flows/__tests__/flow-run-history.test.tsx b/apps/web/src/components/flows/__tests__/flow-run-history.test.tsx index b843f604..e3deae89 100644 --- a/apps/web/src/components/flows/__tests__/flow-run-history.test.tsx +++ b/apps/web/src/components/flows/__tests__/flow-run-history.test.tsx @@ -1,10 +1,18 @@ /** @vitest-environment jsdom */ -import { cleanup, render, screen } from '@testing-library/react' -import { afterEach, describe, expect, it } from 'vitest' +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { FlowRunHistory } from '@/components/flows/flow-run-history' import type { FlowDetail } from '@/lib/flows/types' +const clientMocks = vi.hoisted(() => ({ + cancelFlowRunRequest: vi.fn(), +})) + +vi.mock('@/lib/flows/client', () => ({ + cancelFlowRunRequest: clientMocks.cancelFlowRunRequest, +})) + const flow: FlowDetail = { createdAt: '2026-05-12T10:00:00.000Z', cronExpression: null, @@ -59,6 +67,10 @@ const flow: FlowDetail = { } describe('FlowRunHistory', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + afterEach(() => cleanup()) it('renders run history and session link', () => { @@ -90,4 +102,38 @@ describe('FlowRunHistory', () => { expect(screen.getByText('No runs recorded yet.')).toBeTruthy() }) + + it('surfaces a rejected card Stop request', async () => { + clientMocks.cancelFlowRunRequest.mockResolvedValue({ ok: false, error: 'forbidden' }) + render() + + fireEvent.click(screen.getByRole('button', { name: 'Stop' })) + + expect(await screen.findByText('forbidden')).toBeTruthy() + }) + + it('surfaces a card Stop network failure', async () => { + clientMocks.cancelFlowRunRequest.mockRejectedValue(new Error('offline')) + render() + + fireEvent.click(screen.getByRole('button', { name: 'Stop' })) + + expect(await screen.findByText('Network error. Try again.')).toBeTruthy() + }) }) diff --git a/apps/web/src/components/flows/__tests__/flows-page.test.tsx b/apps/web/src/components/flows/__tests__/flows-page.test.tsx index 99e3a9de..f212d4ba 100644 --- a/apps/web/src/components/flows/__tests__/flows-page.test.tsx +++ b/apps/web/src/components/flows/__tests__/flows-page.test.tsx @@ -1,12 +1,13 @@ /** @vitest-environment jsdom */ import { FlowRunStatus, FlowRunTrigger } from '@prisma/client' -import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { FlowsPage } from '@/components/flows/flows-page' import type { FlowListItem } from '@/lib/flows/types' const clientMocks = vi.hoisted(() => ({ + cancelFlowRunRequest: vi.fn(), fetchFlowList: vi.fn(), push: vi.fn(), runFlowRequest: vi.fn(), @@ -14,6 +15,7 @@ const clientMocks = vi.hoisted(() => ({ vi.mock('next/navigation', () => ({ useRouter: () => ({ push: clientMocks.push }) })) vi.mock('@/lib/flows/client', () => ({ + cancelFlowRunRequest: clientMocks.cancelFlowRunRequest, fetchFlowList: clientMocks.fetchFlowList, runFlowRequest: clientMocks.runFlowRequest, })) @@ -69,6 +71,7 @@ function createFlow(overrides: Partial): FlowListItem { describe('FlowsPage', () => { beforeEach(() => { vi.clearAllMocks() + clientMocks.cancelFlowRunRequest.mockResolvedValue({ ok: true, data: { ok: true } }) clientMocks.fetchFlowList.mockResolvedValue({ ok: true, data: { flows: [flow] } }) clientMocks.runFlowRequest.mockResolvedValue({ ok: true, data: { ok: true, runId: 'run-1' } }) }) @@ -175,14 +178,139 @@ describe('FlowsPage', () => { expect(screen.getByText('Running')).toBeTruthy() }) - it('runs flows from the desktop list and navigates to history', async () => { - render() - await waitFor(() => expect(screen.getByText('Weekly Review')).toBeTruthy()) + it('keeps the list in place after a run and refreshes it silently while the run is active', async () => { + const pollCallbacks: Array<() => void> = [] + const setIntervalSpy = vi.spyOn(global, 'setInterval').mockImplementation(((callback: () => void) => { + pollCallbacks.push(callback) + return 0 as unknown as ReturnType + }) as typeof setInterval) + const clearIntervalSpy = vi.spyOn(global, 'clearInterval').mockImplementation(() => undefined) + + try { + clientMocks.fetchFlowList + .mockResolvedValueOnce({ ok: true, data: { flows: [createFlow()] } }) + .mockResolvedValueOnce({ ok: true, data: { flows: [createFlow({ latestRun: createRun(FlowRunStatus.running) })] } }) + .mockResolvedValue({ ok: true, data: { flows: [createFlow({ latestRun: createRun(FlowRunStatus.succeeded) })] } }) + + render() + await waitFor(() => expect(screen.getByText('Weekly Review')).toBeTruthy()) + + fireEvent.click(screen.getByRole('button', { name: 'Run' })) + await waitFor(() => expect(clientMocks.runFlowRequest).toHaveBeenCalledWith('alice', 'flow-1')) + await waitFor(() => expect(clientMocks.fetchFlowList).toHaveBeenCalledTimes(2)) + + // Active run registered a silent poll (StrictMode may register the + // effect twice); firing the latest one refreshes without mounting the + // loader or navigating away. + expect(pollCallbacks.length).toBeGreaterThan(0) + await act(async () => { + pollCallbacks[pollCallbacks.length - 1]!() + }) + await waitFor(() => expect(clientMocks.fetchFlowList).toHaveBeenCalledTimes(3)) + + expect(clientMocks.push).not.toHaveBeenCalled() + expect(screen.getByText('Weekly Review')).toBeTruthy() + expect(screen.queryByText('Loading flows...')).toBeNull() + + // Once no run is active, the poll tears itself down. + expect(clearIntervalSpy).toHaveBeenCalled() + } finally { + setIntervalSpy.mockRestore() + clearIntervalSpy.mockRestore() + } + }) - fireEvent.click(screen.getByRole('button', { name: 'Run' })) - await waitFor(() => expect(clientMocks.runFlowRequest).toHaveBeenCalledWith('alice', 'flow-1')) - expect(clientMocks.push).toHaveBeenCalledWith('/u/alice/flows/flow-1/runs') - expect(clientMocks.fetchFlowList).toHaveBeenCalledTimes(1) + it('keeps active flow controls mounted when a silent poll fails', async () => { + const pollCallbacks: Array<() => void> = [] + const setIntervalSpy = vi.spyOn(global, 'setInterval').mockImplementation(((callback: () => void) => { + pollCallbacks.push(callback) + return 0 as unknown as ReturnType + }) as typeof setInterval) + + try { + clientMocks.fetchFlowList + .mockResolvedValueOnce({ ok: true, data: { flows: [createFlow({ latestRun: createRun(FlowRunStatus.running) })] } }) + .mockRejectedValueOnce(new Error('temporary outage')) + + render() + await waitFor(() => expect(screen.getByRole('button', { name: 'Stop' })).toBeTruthy()) + + await act(async () => { + pollCallbacks[pollCallbacks.length - 1]!() + }) + await waitFor(() => expect(clientMocks.fetchFlowList).toHaveBeenCalledTimes(2)) + + expect(screen.getByText('Weekly Review')).toBeTruthy() + expect(screen.getByRole('button', { name: 'Stop' })).toBeTruthy() + expect(screen.queryByText('Could not load flows')).toBeNull() + } finally { + setIntervalSpy.mockRestore() + } + }) + + it('restores the list when a later silent poll succeeds', async () => { + const pollCallbacks = new Map void>() + let nextIntervalId = 0 + const setIntervalSpy = vi.spyOn(global, 'setInterval').mockImplementation(((callback: () => void, delay?: number) => { + const intervalId = nextIntervalId + nextIntervalId += 1 + if (delay === 5000) pollCallbacks.set(intervalId, callback) + return intervalId as unknown as ReturnType + }) as typeof setInterval) + const clearIntervalSpy = vi.spyOn(global, 'clearInterval').mockImplementation(((intervalId: number) => { + pollCallbacks.delete(intervalId) + }) as typeof clearInterval) + + try { + clientMocks.fetchFlowList + .mockResolvedValueOnce({ ok: true, data: { flows: [createFlow({ latestRun: createRun(FlowRunStatus.running) })] } }) + .mockResolvedValueOnce({ ok: false, error: 'load_failed' }) + .mockResolvedValue({ ok: true, data: { flows: [createFlow({ latestRun: createRun(FlowRunStatus.running) })] } }) + + const { rerender } = render() + await waitFor(() => expect(screen.getByRole('button', { name: 'Stop' })).toBeTruthy()) + + rerender() + await waitFor(() => expect(screen.getByText('Could not load flows')).toBeTruthy()) + await waitFor(() => expect(pollCallbacks.size).toBe(1)) + + await act(async () => { + [...pollCallbacks.values()][0]!() + }) + await waitFor(() => expect(clientMocks.fetchFlowList.mock.calls.length).toBeGreaterThan(2)) + + expect(clientMocks.fetchFlowList).toHaveBeenLastCalledWith('bob') + expect(screen.getByText('Weekly Review')).toBeTruthy() + expect(screen.queryByText('Could not load flows')).toBeNull() + } finally { + setIntervalSpy.mockRestore() + clearIntervalSpy.mockRestore() + clientMocks.fetchFlowList.mockReset() + clientMocks.fetchFlowList.mockResolvedValue({ ok: true, data: { flows: [flow] } }) + } + }) + + it('keeps the list mounted during a post-run refresh', async () => { + let resolveRefresh!: (value: { ok: true; data: { flows: FlowListItem[] } }) => void + const refresh = new Promise<{ ok: true; data: { flows: FlowListItem[] } }>((resolve) => { + resolveRefresh = resolve + }) + clientMocks.fetchFlowList + .mockResolvedValueOnce({ ok: true, data: { flows: [flow] } }) + .mockReturnValueOnce(refresh) + + try { + render() + await waitFor(() => expect(screen.getByText('Weekly Review')).toBeTruthy()) + + fireEvent.click(screen.getByRole('button', { name: 'Run' })) + await waitFor(() => expect(clientMocks.fetchFlowList).toHaveBeenCalledTimes(2)) + + expect(screen.getByText('Weekly Review')).toBeTruthy() + expect(screen.queryByText('Loading flows...')).toBeNull() + } finally { + resolveRefresh({ ok: true, data: { flows: [createFlow({ latestRun: createRun(FlowRunStatus.running) })] } }) + } }) it('shows action errors from run and network failures', async () => { diff --git a/apps/web/src/components/flows/flow-run-history-view.tsx b/apps/web/src/components/flows/flow-run-history-view.tsx index 6f4fc3b2..d45afc2b 100644 --- a/apps/web/src/components/flows/flow-run-history-view.tsx +++ b/apps/web/src/components/flows/flow-run-history-view.tsx @@ -2,11 +2,11 @@ import { useCallback, useEffect, useRef, useState } from 'react' import Link from 'next/link' -import { Lightning, PencilSimple, SpinnerGap } from '@phosphor-icons/react' +import { Lightning, PencilSimple, SpinnerGap, Stop } from '@phosphor-icons/react' import { FlowRunHistory } from '@/components/flows/flow-run-history' import { Button } from '@/components/ui/button' -import { fetchFlowDetail, runFlowRequest } from '@/lib/flows/client' +import { cancelFlowRunRequest, fetchFlowDetail, runFlowRequest } from '@/lib/flows/client' import { formatConnectorRequirement, getFlowErrorMessage } from '@/lib/flows/errors' import type { FlowDetail } from '@/lib/flows/types' @@ -24,6 +24,7 @@ export function FlowRunHistoryView({ editHref, flowId, slug }: FlowRunHistoryVie const [error, setError] = useState(null) const [actionError, setActionError] = useState(null) const [isRunning, setIsRunning] = useState(false) + const [isCancelling, setIsCancelling] = useState(false) const loadRequestIdRef = useRef(0) const mountedRef = useRef(false) @@ -110,6 +111,25 @@ export function FlowRunHistoryView({ editHref, flowId, slug }: FlowRunHistoryVie } }, [flow, flowId, loadFlow, slug]) + const activeRun = flow?.runs.find((run) => run.status === 'running' || run.status === 'waiting_for_human') ?? null + + const cancelRun = useCallback(async (runId: string) => { + setIsCancelling(true) + setActionError(null) + try { + const result = await cancelFlowRunRequest(slug, runId) + if (!result.ok) { + setActionError(result.error) + return + } + await loadFlow() + } catch { + setActionError('network_error') + } finally { + setIsCancelling(false) + } + }, [loadFlow, slug]) + const resolvedEditHref = editHref ?? `/u/${slug}/flows/${flowId}` return ( @@ -128,14 +148,26 @@ export function FlowRunHistoryView({ editHref, flowId, slug }: FlowRunHistoryVie {flow?.permissions.canEdit ? 'Edit flow' : 'View flow'} - + {activeRun ? ( + + ) : ( + + )} diff --git a/apps/web/src/components/flows/flow-run-history.tsx b/apps/web/src/components/flows/flow-run-history.tsx index 40dce403..2783aa5d 100644 --- a/apps/web/src/components/flows/flow-run-history.tsx +++ b/apps/web/src/components/flows/flow-run-history.tsx @@ -1,5 +1,6 @@ 'use client' +import { useState } from 'react' import Link from 'next/link' import { ArrowSquareOut, @@ -10,11 +11,16 @@ import { Hourglass, MinusCircle, Prohibit, + SpinnerGap, + Stop, XCircle, } from '@phosphor-icons/react' import { HumanStepResponseCard } from '@/components/flows/human-step-response-card' +import { Button } from '@/components/ui/button' +import { cancelFlowRunRequest } from '@/lib/flows/client' import { formatFlowRunDate } from '@/lib/flows/cron' +import { getFlowErrorMessage } from '@/lib/flows/errors' import { cn } from '@/lib/utils' import type { FlowDetail, FlowRunListItem, FlowRunStepListItem } from '@/lib/flows/types' import { getWorkspaceHref } from '@/lib/workspace-hrefs' @@ -185,6 +191,8 @@ function RunCard({ now: Date onRefresh?: () => Promise | void }) { + const [cancelError, setCancelError] = useState(null) + const [isCancelling, setIsCancelling] = useState(false) const tone = getRunTone(run.status) const startedDate = new Date(run.startedAt) const relative = formatRelativeTime(startedDate, now) @@ -192,6 +200,25 @@ function RunCard({ const duration = formatDuration(run.startedAt, run.finishedAt) const executionUser = run.executionUser ?? flow.owner const canOpenSession = Boolean(run.openCodeSessionId && (!executionUser || executionUser.slug === slug)) + const isActive = run.status === 'running' + const canCancel = isActive && (!executionUser || executionUser.slug === slug) + + async function handleCancel() { + setIsCancelling(true) + setCancelError(null) + try { + const result = await cancelFlowRunRequest(slug, run.id) + if (!result.ok) { + setCancelError(result.error) + return + } + await onRefresh?.() + } catch { + setCancelError('network_error') + } finally { + setIsCancelling(false) + } + } return (
  • @@ -216,6 +243,7 @@ function RunCard({ {executionUser ?

    Executed by {executionUser.slug}

    : null} {run.error ?

    {run.error}

    : null} + {cancelError ?

    {getFlowErrorMessage(cancelError)}

    : null} {run.retryScheduledFor ? (

    @@ -227,15 +255,29 @@ function RunCard({ ) : null} - {canOpenSession && run.openCodeSessionId ? ( - - Open session - - - ) : null} +

    + {canCancel ? ( + + ) : null} + {canOpenSession && run.openCodeSessionId ? ( + + Open session + + + ) : null} +
    {run.steps.length > 0 ? ( diff --git a/apps/web/src/components/flows/flows-page.tsx b/apps/web/src/components/flows/flows-page.tsx index f1c88cd1..8a2da8dc 100644 --- a/apps/web/src/components/flows/flows-page.tsx +++ b/apps/web/src/components/flows/flows-page.tsx @@ -2,27 +2,29 @@ import { useCallback, useEffect, useMemo, useState } from 'react' import Link from 'next/link' -import { useRouter } from 'next/navigation' -import { ClockCountdown, ClockCounterClockwise, DotsThreeVertical, DownloadSimple, GitBranch, PencilSimple, Play, SpinnerGap, TreeStructure } from '@phosphor-icons/react' +import { ClockCountdown, ClockCounterClockwise, DotsThreeVertical, DownloadSimple, GitBranch, PencilSimple, Play, SpinnerGap, Stop, TreeStructure } from '@phosphor-icons/react' import { DashboardEmptyState } from '@/components/dashboard/dashboard-empty-state' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' -import { fetchFlowList, runFlowRequest } from '@/lib/flows/client' +import { cancelFlowRunRequest, fetchFlowList, runFlowRequest } from '@/lib/flows/client' import { formatFlowRunDate } from '@/lib/flows/cron' import { getFlowErrorMessage } from '@/lib/flows/errors' import type { FlowListItem } from '@/lib/flows/types' import { cn } from '@/lib/utils' +// While any visible flow has an active run, refresh the list quietly so the +// badges and stop controls track the run without leaving the page. +const ACTIVE_RUN_REFRESH_INTERVAL_MS = 5000 + type FlowsPageProps = { buildCreateHref?: () => string buildEditHref?: (flowId: string) => string buildHistoryHref?: (flowId: string) => string hideHeader?: boolean - navigateToHistoryOnRun?: boolean slug: string } @@ -44,29 +46,32 @@ function getRunBadgeLabel(flow: FlowListItem): string { return 'Last run failed' } -export function FlowsPage({ buildCreateHref, buildEditHref, buildHistoryHref, hideHeader = false, navigateToHistoryOnRun = false, slug }: FlowsPageProps) { - const router = useRouter() +export function FlowsPage({ buildCreateHref, buildEditHref, buildHistoryHref, hideHeader = false, slug }: FlowsPageProps) { const [flows, setFlows] = useState([]) const [isLoading, setIsLoading] = useState(true) const [loadError, setLoadError] = useState(null) const [actionError, setActionError] = useState(null) const [runningFlowId, setRunningFlowId] = useState(null) + const [cancellingFlowId, setCancellingFlowId] = useState(null) - const loadFlows = useCallback(async () => { - setIsLoading(true) - setLoadError(null) + const loadFlows = useCallback(async (options: { silent?: boolean } = {}) => { + if (!options.silent) { + setIsLoading(true) + setLoadError(null) + } try { const result = await fetchFlowList(slug) if (!result.ok) { - setLoadError(result.error) + if (!options.silent) setLoadError(result.error) return } + setLoadError(null) setFlows(result.data.flows) } catch { - setLoadError('network_error') + if (!options.silent) setLoadError('network_error') } finally { - setIsLoading(false) + if (!options.silent) setIsLoading(false) } }, [slug]) @@ -90,18 +95,30 @@ export function FlowsPage({ buildCreateHref, buildEditHref, buildHistoryHref, hi return } - if (navigateToHistoryOnRun) { - router.push(getHistoryHref(flowId)) + await loadFlows({ silent: true }) + } catch { + setActionError('network_error') + } finally { + setRunningFlowId(null) + } + }, [loadFlows, slug]) + + const cancelFlow = useCallback(async (runId: string, flowId: string) => { + setCancellingFlowId(flowId) + setActionError(null) + try { + const result = await cancelFlowRunRequest(slug, runId) + if (!result.ok) { + setActionError(result.error) return } - - await loadFlows() + await loadFlows({ silent: true }) } catch { setActionError('network_error') } finally { - setRunningFlowId(null) + setCancellingFlowId(null) } - }, [getHistoryHref, loadFlows, navigateToHistoryOnRun, router, slug]) + }, [loadFlows, slug]) useEffect(() => { let cancelled = false @@ -138,6 +155,20 @@ export function FlowsPage({ buildCreateHref, buildEditHref, buildHistoryHref, hi const sortedFlows = useMemo(() => [...flows].sort((left, right) => left.name.localeCompare(right.name)), [flows]) const myFlows = useMemo(() => sortedFlows.filter((flow) => flow.permissions.isOwner), [sortedFlows]) const teamFlows = useMemo(() => sortedFlows.filter((flow) => !flow.permissions.isOwner), [sortedFlows]) + const hasActiveRun = useMemo( + () => flows.some((flow) => flow.latestRun?.status === 'running' || flow.latestRun?.status === 'waiting_for_human'), + [flows], + ) + + useEffect(() => { + if (!hasActiveRun) return + + const interval = setInterval(() => { + void loadFlows({ silent: true }) + }, ACTIVE_RUN_REFRESH_INTERVAL_MS) + + return () => clearInterval(interval) + }, [hasActiveRun, loadFlows]) function renderFlowGrid(items: FlowListItem[]) { return ( @@ -218,7 +249,18 @@ export function FlowsPage({ buildCreateHref, buildEditHref, buildHistoryHref, hi - {flow.permissions.canRun ? ( + {flow.permissions.canRun && flow.latestRun && (flow.latestRun.status === 'running' || flow.latestRun.status === 'waiting_for_human') ? ( + + ) : flow.permissions.canRun ? (